Lesson 02 / 11
Shift Left
The fix cost per stage is derived from the work that gets undone, and the same 23-item set is run through two flows: on the 18 items common to both, the late flow costs 318 hours, the early flow 68 hours, a 4.68 multiplier, and once the check load is subtracted from the 250 hours prevented, a net gain of 217.5 hours remains. The multiplier is not a single number — it is 22.5 for an item born in the requirement stage and 13 for one born in deployment; half of the early flow's cost comes from the single item shift left can never touch.
Published Updated
In this lesson
- Derives the per-stage fix cost from the work that gets undone and shows that the multiplier changes with the stage an item is born in
- Runs the same item set through two flows with late and early checks: calculates the overall multiplier, the hours prevented, and the net gain after subtracting the check load
- Separates out the class of items for which catching them early gains nothing, and counts that class's share of the early flow's cost
Contents
The previous lesson moved the check: with the check spread across six stages instead of a single gate, items caught rose from 18 to 23 and total delay dropped from 35 stages to 3 stages. Delay was a number, but a stage is not a unit of currency. Fixing an item at the design stage is a meeting’s decision; taking the same item back out of a running system is a data migration, a release plan, and an outage window. The two jobs cannot be measured with the same unit.
This lesson builds that unit. Moving the check earlier in the flow is called shift left, and the phrase by itself carries no decision — it does not answer how far left, for which item, or at what cost. Here the per-stage fix cost enters the model, the same item set runs through two flows with late and early checks, and the overall multiplier is calculated. The second half of the lesson looks the other way: it separates out the class of items for which shifting gains nothing, and counts that class’s share of the cost.
- DA7. The item set is the same as the previous lesson’s and does not change; the only thing added is cost.
- DA8. The fix cost is not a given multiplier, it is derived from the work that gets undone: however many stages have passed since the item was born, the work produced in those stages is redone as well.
- DA9. The per-stage undo costs and the base fix effort are model values; the unit is fictional developer-hours. In a real organization these numbers are drawn from past fix records.
- DA10. The comparison is made only on items caught in both flows. The cost of an item the late flow never sees is not zero, it is unpaid; it is written down separately.
- DA11. The running load of the early checks is taken from the previous lesson and subtracted from the net gain as a single item. Setup, training, and maintenance load are not in the model; all of them would lower the net gain further.
The Per-Stage Fix Cost
Cost is not given as a multiplier, it is built. Every stage produces work; if the item is fixed after that stage has passed, the work produced there gets undone too. The total cost is the base fix effort plus the undo cost of every stage passed through.
// cost.mjs — the same item set, now with a per-stage fix cost (model) export const STAGE = ['requirement', 'design', 'code', 'build', 'deployment', 'runtime']; // The undo cost of passing a stage (fictional developer-hours): if the item has passed // that stage, fixing it redoes the work produced there as well. export const REDO = [0, 3, 8, 2, 6, 24]; export const BASE = 2; // the item's own fix effort, independent of stage // Cost: base effort plus the undo cost of every stage passed between birth and catch. export const cost = (born, caught) => BASE + REDO.slice(born + 1, caught + 1).reduce((s, x) => s + x, 0); export const ITEM = ` no-data-classification 0 100000 undefined-retention-period 0 100001 unclear-personal-data-scope 0 100010 unauthenticated-internal-call 1 011110 weak-session-model 1 010011 trust-boundary-free-flow 1 010000 missing-role-in-authz-model 1 011001 missing-authorization-check 2 001101 embedded-secret-key 2 001110 unvalidated-input 2 001001 personal-data-in-logs 2 001001 incomplete-error-path 2 001000 weak-randomness 2 001000 unbounded-query-size 2 000001 outdated-dependency 3 000110 unsigned-build-output 3 000110 unnecessary-build-tool 3 000100 default-password 4 000011 overly-broad-network-rule 4 000011 unencrypted-backup 4 000010 open-admin-endpoint 4 000011 race-under-scale 5 000001 third-party-disclosed-flaw 5 000001` .trim().split('\n').map((r) => { const [name, d, g] = r.trim().split(/\s+/); return { name, born: +d, g }; }); export const firstSeen = (k, stages) => stages.find((i) => i >= k.born && k.g[i] === '1') ?? null;
// run1.mjs — the multiplier is not one number: it changes with the stage of birth import { STAGE, REDO, cost, ITEM } from './cost.mjs'; console.log('birth stage redo work earliest cost latest cost x items'); for (const [i, a] of STAGE.entries()) { const early = cost(i, i), late = cost(i, STAGE.length - 1); console.log(a.padEnd(16) + String(REDO[i]).padEnd(15) + String(early).padEnd(16) + String(late).padEnd(14) + (late / early).toFixed(1).padEnd(6) + ITEM.filter((k) => k.born === i).length); } console.log('\nsame item, delayed stage by stage (an item born in design)'); console.log('stage caught in cost rise from previous stage'); let previous = null; for (let j = 1; j < STAGE.length; j++) { const b = cost(1, j); console.log(STAGE[j].padEnd(19) + String(b).padEnd(7) + (previous === null ? '-' : `${b - previous} hours, ${(b / previous).toFixed(2)}x`)); previous = b; }
birth stage redo work earliest cost latest cost x items requirement 0 2 45 22.5 3 design 3 2 42 21.0 4 code 8 2 34 17.0 7 build 2 2 32 16.0 3 deployment 6 2 26 13.0 4 runtime 24 2 2 1.0 2 same item, delayed stage by stage (an item born in design) stage caught in cost rise from previous stage design 2 - code 10 8 hours, 5.00x build 12 2 hours, 1.20x deployment 18 6 hours, 1.50x runtime 42 24 hours, 2.33x
The first table shows why reducing the multiplier column to a single number is wrong. An item born in the requirement stage, when caught at the latest, costs 22.5 times more to fix; for an item born in deployment, the same number is 13. The difference does not come from the item’s severity but from how many stages’ worth of work got produced after the point it was born. The sentence “a vulnerability found late costs multiple times more” is true, but which multiple depends on where the item comes from, and there is a 1.7-fold difference between the two.
The second table says something sharper: the rise is not even. When an item born in design slips into the code stage, the cost jumps to five times; moving on from there into the build stage, only to 1.2 times. There are two boundaries where the rise concentrates — the stage where the code is written and the stage where the system becomes live. The build and deployment boundaries between them are cheap. This directly determines where to shift the check to: pulling a check back from deployment to build gains 6 hours, pulling it back from code to design gains 8 hours. The same effort crosses different boundaries, and they are not all equal.
Two Flows, Overall Multiplier
The same item set runs through two flows: a late flow, where the check exists only at deployment and runtime, and an early flow, where it exists in all six stages.
// run2.mjs — the same item set in two flows: checked late and checked early import { STAGE, cost, ITEM, firstSeen } from './cost.mjs'; const LATE = [4, 5], EARLY = [0, 1, 2, 3, 4, 5]; const CHECK_LOAD = 32.5; // running load of the early checks (from the previous lesson, hours) const rows = ITEM.map((k) => { const g = firstSeen(k, LATE), e = firstSeen(k, EARLY); return { k, g, e, gb: g === null ? null : cost(k.born, g), eb: cost(k.born, e) }; }); const common = rows.filter((s) => s.g !== null); const earlyOnly = rows.filter((s) => s.g === null); const noGain = common.filter((s) => s.g === s.e); const sum = (l, f) => l.reduce((a, s) => a + f(s), 0); console.log(`${ITEM.length} items; caught in both ${common.length}, ` + `never seen by late flow ${earlyOnly.length}`); const gt = sum(common, (s) => s.gb), et = sum(common, (s) => s.eb); console.log(`late flow ${gt} hours, early flow ${et} hours, x ${(gt / et).toFixed(2)}, ` + `prevented ${gt - et} hours`); console.log(`net gain: ${gt - et} - ${CHECK_LOAD} = ${(gt - et - CHECK_LOAD).toFixed(1)} hours`); console.log(`the ${earlyOnly.length} items the late flow never sees close out at ` + `${sum(earlyOnly, (s) => s.eb)} hours in the early flow; in the late flow their cost is never paid`); console.log('\nwhere the gain concentrates'); console.log('birth stage items late cost early cost prevented share %'); for (const [i, a] of STAGE.entries()) { const l = common.filter((s) => s.k.born === i); if (l.length === 0) { console.log(a.padEnd(16) + '0'); continue; } const g = sum(l, (s) => s.gb), e = sum(l, (s) => s.eb); console.log(a.padEnd(16) + String(l.length).padEnd(7) + String(g).padEnd(11) + String(e).padEnd(13) + String(g - e).padEnd(11) + (((g - e) / (gt - et)) * 100).toFixed(1)); } // Items with no gain fall into two classes: those unreachable because they are born // late, and those born early but visible only late. const lateBorn = noGain.filter((s) => s.k.born >= 4); const lateSeen = noGain.filter((s) => s.k.born < 4); console.log(`\n${noGain.length} items the early check does not help, two classes`); console.log('class items cost in early flow % of early flow cost'); for (const [name, l] of [['born early, seen late', lateSeen], ['born late', lateBorn]]) console.log(name.padEnd(26) + String(l.length).padEnd(7) + String(sum(l, (s) => s.eb)).padEnd(22) + ((sum(l, (s) => s.eb) / et) * 100).toFixed(1)); for (const s of lateSeen) console.log(` ${s.k.name}: born in the ${STAGE[s.k.born]} stage, visible in the ${STAGE[s.e]} ` + `stage, ${s.eb} hours in both flows`);
23 items; caught in both 18, never seen by late flow 5 late flow 318 hours, early flow 68 hours, x 4.68, prevented 250 hours net gain: 250 - 32.5 = 217.5 hours the 5 items the late flow never sees close out at 10 hours in the early flow; in the late flow their cost is never paid where the gain concentrates birth stage items late cost early cost prevented share % requirement 2 66 4 62 24.8 design 3 78 6 72 28.8 code 5 146 42 104 41.6 build 2 16 4 12 4.8 deployment 4 8 8 0 0.0 runtime 2 4 4 0 0.0 7 items the early check does not help, two classes class items cost in early flow % of early flow cost born early, seen late 1 34 50.0 born late 6 12 17.6 unbounded-query-size: born in the code stage, visible in the runtime stage, 34 hours in both flows
On the eighteen items common to both, the late flow spends 318 hours, the early flow 68 hours; the overall multiplier is 4.68. This is considerably lower than the 13–22.5 range read off a single item, and it should be lower: the multiplier is weighted by the distribution of when items are born, and a quarter of the set is already born in deployment or runtime and produces no gain at all. A program’s real multiplier is always smaller than the multiplier of any single item shift left can reach.
Once the running load of the early checks is subtracted from the 250 hours prevented, a net gain of 217.5 hours remains. The form of the number matters as much as the number itself: shift left has become something that can be written not as a principle but as a two-item subtraction. Setup, training, and maintenance loads, which are not in the model, would lower this number further; in a real organization the decision looks at whether the hours prevented also cover these three items.
The same subtraction also gives a break-even point. The 250 hours prevented can carry at most 217.5 hours of setup, training, and maintenance on top of the running load; every hour above that threshold turns shifting into a loss. If setting up all six checks does not fit this budget, the right decision is not to set up all of them but to start with the stages whose share of the gain is highest — and that order is written in this table’s share column.
A silent assumption of the model needs to be said out loud here: every item caught was counted as fixed. In reality, a portion of caught items are deliberately deferred. A deferred item follows the same path as an item never caught, and in the end pays the late flow’s cost; the early check has only made the payment visible. This is why the 250 hours prevented is not a gain but an upper bound: realizing it depends on having the capacity to actually fix the items that were caught.
The five items the late flow never sees were left out of the comparison. The early flow closes them out at 10 hours; in the late flow their cost is not zero, it is unpaid. This is the most misleading row a fix-cost table can have: an item that is never found looks, in the table, like the cheapest item of all.
The distribution of the gain says where to shift to. Of the 250 hours prevented, 41.6% comes from items born in the code stage, 28.8% from design, 24.8% from items born in the requirement stage. Items born in the build stage produce only 4.8% of the total; those born in deployment and runtime produce zero. A check placed on the first three stages carries 95% of the gain.
The Item Shifting Does Not Help
The last table is this lesson’s real finding. Seven of the eighteen items gain nothing at all from the early check, and these seven items form two separate classes.
The first class has six items and is unreachable by definition: they are born in deployment or runtime. A configuration item or a dependency vulnerability disclosed after the fact cannot be discussed in a design session, because it does not exist yet when that session happens. This class’s cost is low — 17.6% of the early flow’s cost — because they are caught in the very stage they are born in anyway.
The second class is a single item, and it is this lesson’s hard finding. unbounded-query-size is
born in the code stage but becomes visible only at runtime, and it costs 34 hours in both flows.
Exactly half of the early flow’s total 68-hour cost comes from this one item. No matter how far
left you push the check, this number does not drop, because there is nothing to see in the code:
there is no wrongly written line, only a limit that was never put in place, and the need for that
limit shows up only once the data grows.
What this class has in common is that the item’s very existence depends on a scale, a data distribution, or an external event. It includes race conditions that surface under load, missing limits that only become meaningful past a certain data volume, and dependency vulnerabilities that become known only through an outside disclosure. For these items, the right decision is not to shift the check but to make seeing them at runtime cheaper — measurement, limit checks, and rollback readiness. A program that does not write down the scope of shift left reads this class’s cost as a failure of shifting too, and puts more checks in the wrong place. The measurable counterpart of this distinction is: if an item’s late catch comes from a missing check, the early flow makes it cheaper; if the item itself only becomes visible late, the early flow’s cost stays exactly the same as the late flow’s. The last column of the table looks for exactly this equality.
Summary
- The fix cost is not given as a multiplier, it is derived from the work that gets undone: base effort plus the undo cost of every stage passed between birth and catch.
- The multiplier is not a single number: 22.5 for an item born in the requirement stage, 13 for one born in deployment. The rise is not even either — moving from design to code is a 5x jump, from code to build only 1.2x.
- On the 18 items common to both, the late flow spends 318 hours, the early flow 68 hours; the overall multiplier is 4.68, prevented hours 250. After subtracting the check load, net gain is 217.5 hours.
- The 5 items the late flow never sees are outside the comparison: their cost is not zero, it is unpaid, and the early flow closes them out at 10 hours.
- 95% of the hours prevented come from items born in the first three stages; those born in build produce 4.8%, and those born in deployment and runtime produce 0%.
- 7 of the 18 items gain nothing at all from the early check. Six are unreachable because they are born late; the seventh is born in code and becomes visible only at runtime, carrying half of the early flow’s cost on its own.
Next Step
This lesson pinned the gain and the limit of shifting to a number, but left the stage carrying the largest share of that gain inside a black box. A quarter of the hours prevented comes from items born in the requirement stage, and the check that catches those items passed by in a single table row: eighteen requirement reviews, five alerts, three real items. How a requirement review catches an item was never opened up. For it to be able to catch one, the requirement has to be written testably; a line written as “the system must be secure” produces no item in any review. The next lesson classifies security requirements along the axis of testability: how many requirements are automatically testable, how many can only be checked by eye, how many cannot be tested at all, and how many new requirements enter the list once an abuse case is written.
To keep your progress and take notes, Log in
My notes
Log in to take notes.