Skip to content
academia.sh

Lesson 06 / 16

Dependency Risk

A version policy is a configuration decision: five policies are run over the same dependency tree and the accumulated exposure days and broken upgrades are counted; the silent outcome of a loose version range is measured by how many packages diverge between two deployments of the same source version.

Contents

Every measurement in the previous lesson stayed inside code we wrote ourselves. When the loan service’s process comes up, most of the loaded files are not lines the team wrote; the tree holds twenty-four packages, and only eight of their names appear in a manifest file. The remaining sixteen arrive transitively: each is a dependency of a dependency, no one chose it, and no one wrote its version down.

Scanning these dependencies was measured in the Dependency and Component Scanning lesson; the question there was what the tool finds. Here, what the tool finds is assumed, and the next question is asked: what happens when a finding arrives depends on a rule. That rule is the version policy, and it is a configuration decision — it is written where a manifest file sets a version constraint, and its result shows up in a trade-off the team never sees: upgrading more often reduces accumulated risk and raises the number of broken versions.

No real package name, version number, or vulnerability record is used. The tree, the release calendar, and the record set below are produced with our own generator; the seed is visible and the run is repeatable.

The Tree and the Record Set

AS13 — the tree has 24 packages: eight direct, sixteen transitive, with a 360-day horizon. Every package has a release interval and a break rate: how much of its releases break our code. AS15 — a vulnerability record takes between 0 and 90 days to reach us. The record reaches us not on the day the vulnerability begins to exist, but on the day it is reported; the exposure window is counted from the day it exists to the day it closes.

// tree.mjs — our own dependency tree, our own release calendar, our own record set
export const HORIZON = 360;              // days
export const SEED = 8617;
let state = SEED;
const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;
const pick = (arr) => arr[Math.floor(rand() * arr.length)];

// depth 1: package named in our manifest; 2 and 3: transitive, not named.
const DEPTH = [...Array(8).fill(1), ...Array(10).fill(2), ...Array(6).fill(3)];
const INTERVAL = [14, 30, 45, 60, 90];          // release interval (days)
const BREAK_RATE = [0, 0.08, 0.18, 0.35];       // fraction of releases that break us

export const packages = DEPTH.map((depth, i) => {
  const interval = pick(INTERVAL);
  const breakRate = pick(BREAK_RATE);
  // transitive package: the fix cannot be installed until the parent package cuts a new release.
  const upstreamDelay = depth === 1 ? 0 : Math.round(pick(INTERVAL) * rand());
  const releases = [{ day: 0, breaking: false }];
  for (let g = Math.round(rand() * interval); g <= HORIZON; g += interval) {
    if (g > 0) releases.push({ day: g, breaking: rand() < breakRate });
  }
  return { name: `p${String(i + 1).padStart(2, '0')}`, depth, interval, upstreamDelay, releases };
});

// A record: a vulnerability present in a package. day: day it started existing; fixedBy: the
// release that closes it; known: day the record reaches us. reachable: our code path touches that surface.
export const records = [];
for (let k = 0; k < 11; k++) {
  const p = Math.floor(rand() * packages.length);
  const day = 20 + Math.floor(rand() * (HORIZON - 80));
  const fixedBy = packages[p].releases.findIndex((y) => y.day >= day);
  if (fixedBy <= 0) continue;
  const notice = Math.round(rand() * 90);      // AS15: record delay 0-90 days
  records.push({ package: p, day, fixedBy, known: day + notice, notice, reachable: rand() < 0.55 });
}

// The constraint sits separately in each service's own manifest.
export const SERVICES = ['catalog', 'loan', 'membership', 'notification', 'penalty', 'search', 'reporting'];
export const constraintLocation = packages.map((p) => (p.depth === 1 ? 1 + Math.floor(rand() * SERVICES.length) : 0));
export const readyDay = (p, i) => packages[p].releases[i].day + packages[p].upstreamDelay;

Five Policies

AS14 — code releases happen every fourteen days. The policies differ at exactly one point: on which days, and for which packages, the tree changes. The fifth is not a policy but a shape of constraint — when the version range is left loose, the tree changes on its own at release time, without anyone touching a file.

// policy.mjs — five version policies on the same tree: open days versus broken upgrades
import { packages, records, constraintLocation, readyDay, SERVICES, HORIZON, SEED } from './tree.mjs';

const DEPLOY = 14;   // AS14: release day; the loose range only changes the tree on these days

const policies = {
  'fixed': { day: () => false },
  'immediate': { day: () => true },
  'monthly': { day: (g) => g % 30 === 0 },
  'security only': { day: () => true, selective: true },
  'loose range': { day: (g) => g % DEPLOY === 0, fileless: true },
};

function run(pol) {
  const installed = packages.map(() => 0);
  const open = records.map(() => 0);
  let upgrades = 0, broken = 0, intervention = 0, brokenDay = 0, mostInDay = 0;
  const snapshot = {};
  for (let day = 1; day <= HORIZON; day++) {
    let dailyUpgrades = 0, dailyBroken = 0;
    if (pol.day(day)) {
      for (let p = 0; p < packages.length; p++) {
        // Selective policy upgrades a package only once its record has reached us.
        if (pol.selective && !records.some((k) => k.package === p && day >= k.known && installed[p] < k.fixedBy)) continue;
        let target = installed[p];
        while (target + 1 < packages[p].releases.length && readyDay(p, target + 1) <= day) target += 1;
        if (target === installed[p]) continue;
        for (let i = installed[p] + 1; i <= target; i++) if (packages[p].releases[i].breaking) dailyBroken += 1;
        dailyUpgrades += 1;
        installed[p] = target;
      }
    }
    upgrades += dailyUpgrades;
    broken += dailyBroken;
    if (dailyUpgrades > 0) intervention += 1;
    if (dailyBroken > 0) brokenDay += 1;
    mostInDay = Math.max(mostInDay, dailyBroken);
    records.forEach((k, i) => { if (day >= k.day && installed[k.package] < k.fixedBy) open[i] += 1; });
    if (day === 180 || day === 194) snapshot[day] = [...installed];
  }
  const reachable = records.map((k, i) => (k.reachable ? open[i] : 0));
  return {
    intervention, upgrades, broken, brokenDay, mostInDay,
    openDays: reachable.reduce((a, x) => a + x, 0),
    allDays: open.reduce((a, x) => a + x, 0),
    longest: Math.max(...reachable),
    fileless: pol.fileless ? upgrades : 0,
    diff: snapshot[180].filter((v, i) => v !== snapshot[194][i]).length,
  };
}

const s = (x, n) => String(x).padStart(n);
console.log(`seed ${SEED}: ${packages.length} packages, ${records.length} records, horizon ${HORIZON} days`);
console.log(`${'policy'.padEnd(17)}${s('interv.', 10)}${s('upgrades', 11)}${s('broken', 9)}`
  + `${s('broken day', 11)}${s('most', 8)}${s('open day', 10)}${s('longest', 9)}`);
const result = {};
for (const [name, pol] of Object.entries(policies)) {
  const r = (result[name] = run(pol));
  console.log(`${name.padEnd(17)}${s(r.intervention, 10)}${s(r.upgrades, 11)}${s(r.broken, 9)}`
    + `${s(r.brokenDay, 11)}${s(r.mostInDay, 8)}${s(r.openDays, 10)}${s(r.longest, 9)}`);
}

const g = result['loose range'];
console.log(`\nin the loose range, all ${g.fileless} upgrades arrived without touching a manifest file;`
  + ` the day-180 and day-194 deployments of the same source version diverge on ${g.diff} packages`);
console.log(`the same divergence is ${result.immediate.diff} packages under immediate, ${result.fixed.diff} under fixed`);

const direct = records.filter((k) => packages[k.package].depth === 1);
const delay = records.reduce((a, k) => a + packages[k.package].upstreamDelay, 0);
const notice = records.reduce((a, k) => a + k.notice, 0) / records.length;
console.log(`${direct.length} of the records are in a direct package, ${records.length - direct.length} transitive;`
  + ` ${records.filter((k) => k.reachable).length} are reachable`);
console.log(`total upstream wait for transitive records: ${delay} days; average record delay: ${notice.toFixed(1)} days`);
const rank = (field) => Object.keys(result).sort((a, b) => result[a][field] - result[b][field]).join(' < ');
console.log(`open days with the reachability filter removed: `
  + Object.entries(result).map(([name, r]) => `${name} ${r.allDays}`).join(', '));
console.log(`ranking, filtered: ${rank('openDays')}`);
console.log(`ranking, unfiltered: ${rank('allDays')}`);
console.log(`depth-1 packages: ${packages.filter((p) => p.depth === 1).length}, `
  + `total constraints in service manifests: ${constraintLocation.reduce((a, x) => a + x, 0)} `
  + `(${SERVICES.length} services); most files one package touches: ${Math.max(...constraintLocation)}`);
seed 8617: 24 packages, 11 records, horizon 360 days
policy              interv.   upgrades   broken broken day    most  open day  longest
fixed                     0          0        0          0       0       860      269
immediate               237        330       58         54       3       113       43
monthly                  12        214       58         12       8       195       65
security only            14         15       12          7       6       178       67
loose range              25        320       56         24       6       147       49

in the loose range, all 320 upgrades arrived without touching a manifest file; the day-180 and day-194 deployments of the same source version diverge on 14 packages
the same divergence is 13 packages under immediate, 0 under fixed
7 of the records are in a direct package, 4 transitive; 5 are reachable
total upstream wait for transitive records: 51 days; average record delay: 42.8 days
open days with the reachability filter removed: fixed 2181, immediate 349, monthly 520, security only 534, loose range 422
ranking, filtered: immediate < loose range < security only < monthly < fixed
ranking, unfiltered: immediate < loose range < monthly < security only < fixed
depth-1 packages: 8, total constraints in service manifests: 39 (7 services); most files one package touches: 7

Reading the Trade-Off

The first row is the cost of not upgrading, and it reads zero in the broken-version column. The tree stays as it was on day one, no release wakes anyone, no test breaks — and the reachable records stay open for a total of 860 days, with the longest single one at 269. This row is this course’s rule at its most expensive: a fixed tree is the quietest configuration, and its quiet is paid for in accumulated risk.

The second row is the other extreme. Daily upgrading brings open days down from 860 to 113, roughly sevenfold. Its cost is 237 intervention days and 58 broken versions. The distribution of the breaks matters: they spread across 54 separate days, with at most three landing on a single day. Finding which upgrade a break came from is cheap in this distribution.

The third row is where intuition is wrong. Monthly batch upgrading does not reduce the number of broken versions at all — still 58. Batching does not eliminate breaks, it only compresses them into twelve days, with eight breaks landing on a single day. What is gained is intervention count (12 instead of 237); what is lost is open days (195 instead of 113, eighty-two more) and the ease of tracing a break to its source. Batching upgrades does not reduce the cost of breaking; it piles it onto a single day.

The fourth row upgrades only the package that has a record, bringing intervention down to fifteen upgrades. Broken versions drop to twelve, because most of the tree is never touched. Against that, open days are 178 — sixty-five more than daily upgrading — and the longest single window, at 67 days, is the worst of any policy. The cause is in the line below the table: average record delay is 42.8 days. A policy that acts on a record has to wait for the record to arrive; a policy that does not wait has already installed the fix. The security-only policy is cheap, and its ceiling is not in its own hands.

The Days No Policy Can Reach

Part of the open days sits where no policy can reach. Four of the eleven records are in a transitive package; even once the fix ships, the upstream package cannot be installed until its parent cuts a new release, and that wait totals 51 days. These days are the same in all five rows — a version constraint does not bind a package that is not in a manifest file. Combined with the 42.8-day record delay, part of what the table shows is a product of the calendar, not the policy.

The reachability filter carries a warning of its own. Removing it doubles or triples the numbers (immediate goes from 113 to 349, fixed from 860 to 2181), and the two extremes stay in place — but the two middle policies swap places. Filtered, monthly looks worse than security-only; unfiltered, it looks better. Separating the surface our code path actually touches is therefore not a detail but a decision that flips the ranking.

Where the Range Sits

The last row belongs to this course’s rule. A loose version range looks good in the numbers: 147 open days, 56 broken versions, 25 interventions. Yet all 320 of those upgrades arrived without touching a manifest file. Its one measurable consequence is the line below the table: the day-180 and day-194 deployments of the same source version diverge on fourteen of twenty-four packages. The two deployments carry the same code, run with a different tree, and there is no record anywhere of the difference. Rolling back the code does not roll back the tree.

Under daily upgrading, the same divergence is thirteen packages — nearly the same number. The difference is the record of the divergence: there, every upgrade is a file change; here, none is. Under the fixed policy, divergence is zero, and that is exactly why 860 open days get paid.

Where the constraint sits is counted in the last line: eight direct packages are written as a total of 39 constraints across seven services’ manifest files, and a single package can appear in as many as seven separate files at once. Responding to one record means finding all seven of the seven files; an upgrade that finds six and misses one raises no error — it just leaves one service on the old version.

Summary

  • Never upgrading produced zero breaks and 860 open days; the longest single window ran 269 days. The cost of a quiet configuration here is accumulated risk.
  • Daily upgrading brought open days down to 113; its cost was 237 intervention days and 58 broken versions spread across 54 days.
  • Monthly batch upgrading did not reduce the broken-version count (still 58); it piled them into twelve days, up to eight in a single day, and raised open days to 195.
  • The security-only policy ran on fifteen upgrades but, because of a 42.8-day average record delay, pushed the longest window to 67 days; its ceiling is not in its own hands.
  • Transitive packages’ upstream wait is 51 days and closes under no policy; the reachability filter swaps the order of the two middle policies.
  • In the loose range, all 320 upgrades arrived without touching a file, and two deployments of the same source version diverged on fourteen packages; the numbers look good while the tree has no record.

Next Step

Two lessons measured two configuration decisions, and both left a question open. Who rotated a key, when, with which version; which day the tree changed when a package was upgraded; which deployment the fourteen diverging packages in the loose range arrived in — none of these questions has an answer sitting inside the system. The application’s own logs write the request and the error, not the decision. The next lesson takes on this gap: it counts which events get logged, how to verify a log has not been altered afterward, and how much personal data the log itself carries.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close