Lesson 10 / 10
Operations Handover
The responsibility contract between development and operations: twenty of twenty-eight responsibility items are handed over and eight stay with development for lack of information, tooling, or a runbook; the same fourteen events close in 3.8 rounds under shared on-call versus 5.0 under full handover, while the interruption cost pulled from the development flow rises from 37 to 54 and rework from 9 to 20; once three undocumented runbooks are written, escalations fall from 8 to 5 and the flow end from round 94 to 84.
Contents
The previous lesson tied scope change to control and counted its cost, but it stopped the counter the moment the item was delivered. Delivery is the last step finishing in the model; in the field it is the first day the service also has to stay up at night.
Operations handover is a service’s operating responsibility moving from the team that wrote it to the team that operates it, and it is not one piece — it is handed over item by item. This lesson models the handover as a contract: which item was handed over, why an unhandled one could not be, and where its bill lands.
The Handover Contract, Item by Item
The example is the course’s fictional regional library network and the three teams that build
it. TD28: seven services are split across three writing teams, plus one operations team; three
teams together touch the shared-registry service. Each team has six items ahead of it, six
rounds of work per item. TD29: the day is ten rounds; the first six rounds are the workday, the
last four are night, and an item only advances during the workday. TD30: the contract is four
responsibility items — operation, first response, restart, configuration change; the reason an
item cannot be handed over is a lack of information, tooling, or a runbook. TD31: a
critical event is met at night too, a minor event waits until morning. TD32: the interrupted
team is pulled out of the flow for the response time plus two rounds of recovery; if the
interruption lands in the middle of an item, at most two rounds of rework are written, and a team
woken at night loses two rounds the next morning. TD33: the operations team closes a
handed-over responsibility in two rounds; because the context has to be rebuilt secondhand,
resolving an escalated event costs the writing team three rounds (two under shared on-call).
The contract and the two on-call policies are an in-process model; there is no real on-call schedule or incident log.
// handover/oncall.mjs — the fictional library network's operations handover and on-call model // (model): no real on-call schedule or incident log exists, rounds are a data structure. No randomness. export const DAY = 10, WORKDAY = 6; // each day is 10 rounds: first 6 rounds workday, last 4 night export const FIRST = 1, WAKE = 2, TRANSFER = 1, RESOLVE_OPS = 2, RESOLVE_BUILD = 3, RESOLVE_LOCAL = 2; export const RECOVERY = 2, SLEEP_COST = 2, REWORK_CAP = 2; export const ITEM_WORK = [6, 6, 6, 6, 6, 6]; // six items ahead of each writing team export const TEAMS = ["E1", "E2", "E3"]; export const isNight = (t) => t % DAY >= WORKDAY; export const nextMorning = (t) => (Math.floor(t / DAY) + 1) * DAY; const table = (s, f) => s.trim().split("\n").map((r) => f(r.trim().split(/\s+/))); // Handover contract: service, owning team, number of teams touching it, four responsibilities. "+" is // handed over; anything else is not handed over, and names the reason (information, tooling, runbook). export const CONTRACT = table(` loan E2 1 + + + information fee E2 1 + + + + branch-front E1 1 + + + + membership-gate E1 1 + + tooling tooling catalog-gate E3 1 + + + runbook notification E3 1 + + runbook runbook shared-registry E3 3 + + information information`, ([service, team, teams, ...h]) => ({ service, team, teams: +teams, status: { operation: h[0], "first-response": h[1], restart: h[2], configuration: h[3] } })); // Event: arrival round, service, responsibility needed to close it, severity. export const EVENT = table(` 3 loan configuration critical 7 fee restart critical 12 shared-registry configuration critical 16 notification restart critical 18 branch-front configuration critical 23 membership-gate restart critical 27 catalog-gate configuration minor 31 loan restart critical 36 shared-registry restart critical 39 notification configuration critical 44 fee configuration critical 47 membership-gate configuration minor 50 catalog-gate restart critical 52 branch-front restart minor`, ([round, service, resolution, a]) => ({ round: +round, service, resolution, critical: a === "critical" })); // policy: "full" means the operations team meets every event, "shared" means the writing team is on call. // closeReason: responsibility items not handed over for this reason are treated as closed (counter-run). export function run(policy, closeReason = null) { const S = Object.fromEntries(CONTRACT.map((s) => [s.service, s])); const team = Object.fromEntries(TEAMS.map((e) => [e, { busy: 0, i: 0, progress: 0, diverted: 0, rework: 0, finish: null }])); const pending = {}, log = []; const schedule = (e, t, duration) => ((pending[t] ??= []).push([e, duration])); const isOpen = (s, c) => s.status[c] === "+" || s.status[c] === closeReason; const handle = (o, t) => { const s = S[o.service]; if (policy === "full" && isOpen(s, o.resolution)) // resolved by ops via a written runbook return { s, first: FIRST, closure: FIRST + RESOLVE_OPS, handoffs: 1, escalated: false, woke: false, interruption: 0, reason: "-" }; const escalated = policy === "full"; const handoffs = (escalated ? 2 : 1) + (s.teams > 1 ? 1 : 0); const transfer = TRANSFER * (handoffs - 1), resolve = escalated ? RESOLVE_BUILD : RESOLVE_LOCAL; const firstResponse = escalated ? FIRST : isNight(t) ? (o.critical ? WAKE : nextMorning(t) - t) : FIRST; const reason = escalated ? s.status[o.resolution] : "-"; const common = { s, first: firstResponse, handoffs, escalated, reason }; if (!isNight(t)) { const interruption = (escalated ? 0 : FIRST) + transfer + resolve + RECOVERY; schedule(s.team, t, interruption); return { ...common, closure: firstResponse + transfer + resolve, woke: false, interruption }; } if (o.critical) { // night wake-up: the cost lands next morning schedule(s.team, nextMorning(t), SLEEP_COST); return { ...common, closure: (escalated ? FIRST + WAKE : WAKE) + transfer + resolve, woke: true, interruption: SLEEP_COST }; } const interruption = transfer + resolve + RECOVERY; // a minor night event waits until morning schedule(s.team, nextMorning(t), interruption); return { ...common, closure: (escalated ? nextMorning(t) - t : firstResponse) + transfer + resolve, woke: false, interruption }; }; const events = policy === "none" ? [] : EVENT; for (let t = 0; t < 200; t++) { for (const o of events.filter((x) => x.round === t)) log.push({ o, ...handle(o, t) }); for (const [e, duration] of pending[t] ?? []) { const k = team[e]; k.busy += duration; const d = Math.min(k.progress, REWORK_CAP); // interruption landed mid-item k.progress -= d; k.rework += d; } if (isNight(t)) continue; // items do not advance outside the workday for (const e of TEAMS) { const k = team[e]; if (k.finish !== null) continue; if (k.busy > 0) { k.busy--; k.diverted++; continue; } if (++k.progress < ITEM_WORK[k.i]) continue; k.progress = 0; k.i++; if (k.i >= ITEM_WORK.length) k.finish = t + 1; } } const total = (f) => TEAMS.reduce((a, e) => a + f(team[e]), 0); const avg = (f) => (log.length ? log.reduce((a, x) => a + f(x), 0) / log.length : 0); return { log, team, first: avg((x) => x.first).toFixed(1), closure: avg((x) => x.closure).toFixed(1), handoffs: log.reduce((a, x) => a + x.handoffs, 0), escalated: log.filter((x) => x.escalated).length, woke: log.filter((x) => x.woke).length, diverted: total((k) => k.diverted), rework: total((k) => k.rework), end: Math.max(...TEAMS.map((e) => team[e].finish)) }; }
// handover/measure.mjs — handover contract, two handover policies, cost of what cannot be handed over, team boundary. import { run, CONTRACT, EVENT, isNight } from "./oncall.mjs"; const write = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join("")); const avg = (a) => (a.length ? (a.reduce((s, x) => s + x, 0) / a.length).toFixed(1) : "-"); const RESPONSIBILITY = ["operation", "first-response", "restart", "configuration"]; const items = CONTRACT.flatMap((s) => RESPONSIBILITY.map((c) => s.status[c])); const reasons = ["information", "tooling", "runbook"]; const count = (x) => items.filter((k) => k === x).length; console.log(`contract: ${items.length} items, handed over ${count("+")}, not handed over ` + `${items.length - count("+")} (${reasons.map((n) => `${n} ${count(n)}`).join(", ")})`); const R = { none: run("none"), full: run("full"), shared: run("shared"), withRunbooks: run("full", "runbook") }; console.log(`\n${EVENT.length} events, ${EVENT.filter((o) => isNight(o.round)).length} outside the workday, ` + `${EVENT.filter((o) => o.critical).length} critical; without events the flow ends at round ${R.none.end}`); const O = [-34, 15, 17, 29]; console.log("\n1. the same set of events under two handover policies (third column: counter-run)"); write(O, "measure", "full handover", "shared on-call", "full handover + 3 runbooks"); const MEASURES = [["time to first response (rounds)", "first"], ["closure delay (rounds)", "closure"], ["handoffs", "handoffs"], ["escalations", "escalated"], ["out-of-hours pages", "woke"], ["interruption cost (rounds)", "diverted"], ["rework (rounds)", "rework"], ["end of development flow", "end"]]; for (const [name, a] of MEASURES) write(O, name, R.full[a], R.shared[a], R.withRunbooks[a]); const N = [-13, 8, 14, 22, 21]; console.log("\n2. why events escalate under full handover"); write(N, "reason", "events", "avg. closure", "interruption cost", "out-of-hours pages"); for (const n of reasons) { const g = R.full.log.filter((x) => x.reason === n); write(N, n, g.length, avg(g.map((x) => x.closure)), g.reduce((s, x) => s + x.interruption, 0), g.filter((x) => x.woke).length); } const M = [-13, 8, 14, 21]; console.log("\n3. does the service overlap the team boundary? (full handover)"); write(M, "service", "events", "avg. closure", "handoffs per event"); for (const [name, f] of [["single-team", (x) => x.s.teams === 1], ["multi-team", (x) => x.s.teams > 1]]) { const g = R.full.log.filter(f); write(M, name, g.length, avg(g.map((x) => x.closure)), avg(g.map((x) => x.handoffs))); }
contract: 28 items, handed over 20, not handed over 8 (information 3, tooling 2, runbook 3) 14 events, 7 outside the workday, 11 critical; without events the flow ends at round 56 1. the same set of events under two handover policies (third column: counter-run) measure full handover shared on-call full handover + 3 runbooks time to first response (rounds) 1.0 1.6 1.0 closure delay (rounds) 5.0 3.8 4.1 handoffs 24 16 21 escalations 8 0 5 out-of-hours pages 3 5 1 interruption cost (rounds) 37 54 27 rework (rounds) 9 20 10 end of development flow 94 105 84 2. why events escalate under full handover reason events avg. closure interruption cost out-of-hours pages information 3 6.3 15 1 tooling 2 6.0 12 0 runbook 3 7.0 10 2 3. does the service overlap the team boundary? (full handover) service events avg. closure handoffs per event single-team 12 4.7 1.5 multi-team 2 7.0 3.0
The numbers are in the measurement class; their inputs are the assumptions above. Twenty of the twenty-eight items are handed over, and the contract table says this: operation and first response are handed over in all seven of the seven services, and the entire unhandled eight sit in the restart and configuration columns. The handover does not stop at keeping the service running — it stops where the service needs to be touched.
Full Handover versus Shared On-Call
The first table’s first two columns meet the same fourteen events under two policies. Under full handover, the operations team is on call, and time to first response is 1.0 rounds with no day-night distinction; under shared on-call, the writing team gives the first response and the delay rises to 1.6 rounds, because a minor event arriving at night waits until morning. At closure the order reverses: shared on-call closes in 3.8 rounds, full handover in 5.0. The difference is that eight events cannot be resolved and escalate back to the writing team; every escalation is a handoff and a context rebuilt secondhand (24 handoffs against 16).
The bill is in the rows below. With no events, the three teams finish their work at round 56; under full handover the flow stretches to round 94, under shared on-call to round 105, interruption cost is 37 against 54, and rework is 9 against 20. Out-of-hours pages are also higher under shared on-call (5 against 3): under full handover, only the critical night event that cannot be handed over wakes anyone. Shared on-call closes events faster and pays for it out of the flow.
The Cost of What Cannot Be Handed Over
The second table splits the eight escalated events by reason. The most expensive reason is the undocumented runbook: three events, an average closure of 7.0 rounds, and two of the three out-of-hours pages. Missing information accounts for three events at 6.3 rounds and produces the highest interruption cost (15); missing tooling accounts for two events at 6.0 rounds.
The first table’s third column gives the price of the runbook: once three runbooks are written, escalations fall from 8 to 5, out-of-hours pages from 3 to 1, interruption cost from 37 to 27, and the flow end from round 94 to 84. An undocumented runbook is not a missing document — it is ten rounds of flow delay and two out-of-hours pages.
Which Team Boundary an Event Falls Into
The third table splits events by whether the service overlaps the team boundary. In single-team
services, twelve events close in 4.7 rounds and 1.5 handoffs per event; in the shared-registry
service that three teams touch, two events close in 7.0 rounds and 3.0 handoffs. The reason is in
the contract: both of that service’s unhandled items are for the reason information, because
information does not stay inside a single team. A service that does not overlap the team
boundary is expensive not only while being built, but at night too.
Summary
- The handover contract splits into 28 items; 20 are handed over and 8 are not (information 3, tooling 2, runbook 3), and all of the unhandled items sit in the restart and configuration columns.
- The same 14 events close in 5.0 rounds under full handover and 3.8 under shared on-call; time to first response is reversed (1.0 against 1.6), and handoffs are 24 against 16.
- The cost is in the development flow: with no events the flow ends at round 56, but it stretches to 94 under full handover and 105 under shared on-call; interruption cost is 37 against 54 and rework is 9 against 20.
- Once three undocumented runbooks are written, escalations fall from 8 to 5, out-of-hours pages from 3 to 1, and the flow end from round 94 to 84; the service that three teams touch closes its events in 7.0 rounds and 3.0 handoffs (against 4.7 and 1.5 in a single-team service).
Course Wrap-Up
| Lesson | Flow Object | Handoffs / Waiting / Rework | Cost of Architectural Alignment |
|---|---|---|---|
| Lifecycle Models | 18 items, 4 cycles | 125 → 120 / 243 → 18 rounds / 105 → 90 units | wrong-boundary delay 14.8 → 7.8 rounds |
| Agile Frameworks | 30 items, 1–6 weeks | carry-over 8 → 0 / flow 12.5 → 31.2 days / 19 → 23 person-days | 75 linked pairs determine rework |
| Flow-Based Management | 60 items, limit 1–16 | lead time 118.5 → 17.3 days / waiting share 0% → 84.1% | the limit is the WIP limit, not the team; 195.9 person-days go to switching |
| Scaled Frameworks | 40 items, 1–12 teams | coordination rounds 0 → 66 / per item 0 → 5.9 periods | shared channels grow quadratically; lowest cost at 3 teams |
| Engineering Practices | 48 items, 2–40 hours | incidents 21 → 2 / delay 8.0 → 21.4 hours / 82 → 132 hours | rework grows with same-module items |
| Conway’s Law | 12 items, two splits | 24 → 17 / 203 → 55 rounds / 24 → 15 | boundary-crossing links 40/43 → 32/43; pathless cross-team links 16 → 0 |
| Team Topologies | 12 items, three arrangements | 24 / 17 / 25 — 203 / 55 / 78 rounds — 24 / 15 / 22 | cognitive load falls from 1–13 to 3–9, duplicated expertise 18 / 18 / 24 |
| Estimation and Planning | duration of 12 items | folding waiting into the estimate takes error from 58.4% to 23.5% | at 3+ teams, waiting share is 51.7% and error 72.2% (24.3% and 44.6% at 1–2 teams) |
| Change Management | 8 items, 6 requests | 31 / 38 — 44 / 133 rounds — 8 / 4 rounds | a request that falls outside the boundary makes delivery 28 rounds instead of 23 |
| Operations Handover | 14 events, 28 items | 24 / 16 — closure 5.0 / 3.8, interruption cost 37 / 54 — 9 / 20 rounds | the service that three teams touch has 3.0 handoffs instead of 1.5 |
This is the rule of the Process, Team and Delivery course: process and team decisions shape the architecture from wherever the work is waiting. No lesson found a process good on its own; each one was run on the same set of work items, and what it cost where it won was counted.
The Software Architecture curriculum ends here. The five courses’ five measurement axes come together in one sentence: a decision is an object and its traceability is measured (The Architect’s Role), a document is measured by the question it answers (Architectural Decisions and Documentation), at the scale of the enterprise the measure is a link (Enterprise Context and Integration), a rule is governable to the extent it can be checked by machine (Quality Attributes and Governance), and process and team decisions are measured from wherever the work is waiting (Process, Team and Delivery).
To keep your progress and take notes, Log in
My notes
Log in to take notes.