---
title: 'Module Registries'
source: 'https://academia.sh/en/courses/infrastructure-as-code/module-registries'
course: 'Infrastructure as Code'
language: en
updated: '2026-09-13T05:08:25+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts how many times output changes with the definition text untouched, in an unpinned module dependency'
    - 'Measures the spread of a breaking release with two numbers: how many definitions broke, how many lines it demanded to fix'
    - 'Shows the cost of pinning through the count of definitions left on an old version and missed fixes'
---

# Module Registries

Three modules pulled from a shared module registry, nine definitions and nine releases run a ten-week period in two setups; the version-pinned and unpinned setups are compared by unexpected change, broken definitions and fix-line count. The cost of pinning itself is also counted.

The previous lesson kept the module under a single hand: the author and the user were the same
side, and a version upgrade was a single file edit. A **module registry** cuts that relationship.
The registry is a shared repository that keeps versioned module text outside the definitions;
here it is referred to not as a product or a service, but **as a type**. The module's author
publishes a release, the user pulls it, and the only link between them is a name written in the
definition.

Whether to write a version next to that name is this lesson's pair of measurements. In the
**unpinned** setup, the definition writes only the module's name, and the newest version in the
registry is pulled at apply time. In the **pinned** setup, the definition writes the version too,
and that version stands until someone changes it by hand. The same release flow and the same
apply calendar are run in both setups; what gets measured is the gap between them.

The measurement is again on the **regional measurement network**: fictional software that collects
water meter readings. No real infrastructure is provisioned, and no real registry service is
connected to; what is measured is a **model** built with `node`.

**SM31.** The registry holds three modules and twelve versions; each version is written on one
line, with its required inputs, defaulted inputs, and body.
**SM32.** The period is ten weeks, and nine releases are written in advance: four patches, two
minors, three breaking. There is no randomness.
**SM33.** Nine definitions each call one module; apply weeks are written in advance, and eighteen
applies run over the period.
**SM34.** The unpinned setup pulls the newest version at apply time, the pinned setup pulls the
version written in the definition.
**SM35.** A breaking release stops the apply; the missing required input is written into the
definition on the spot, and the line is counted. The fix rule is written in `FIX`.
**SM36.** An **unexpected change** is the output being different from the previous one even though
the definition text was not touched in that apply. In the pinned setup, two definitions are
upgraded by hand during the period.

## The Registry, the Release Flow and the Definitions

```js
// registry.mjs — module registry (as a type): three modules, twelve versions, nine releases and nine consumer definitions.
export const MODULE = {                                // version -> "required | defaulted | body"
  "collection-point": {
    "1.0.0": "source-block | count=6 | cluster count=$count; network block=$source-block",
    "1.1.0": "source-block | count=6,monitoring=on | cluster count=$count; network block=$source-block; monitoring status=$monitoring",
    "1.1.1": "source-block | count=6,monitoring=on | cluster count=$count; network block=$source-block direction=inbound; monitoring status=$monitoring",
    "2.0.0": "network-block | count=6,monitoring=on | cluster count=$count; network block=$network-block direction=inbound; monitoring status=$monitoring",
    "2.0.1": "network-block | count=6,monitoring=on | cluster count=$count type=collector; network block=$network-block direction=inbound; monitoring status=$monitoring" },
  "measurement-queue": {
    "1.0.0": "name | wait=30 | queue name=$name wait=$wait",
    "1.0.1": "name | wait=30 | queue name=$name wait=$wait; dead-letter name=dead-$name",
    "1.1.0": "name | wait=30,cap=50000 | queue name=$name wait=$wait cap=$cap; dead-letter name=dead-$name",
    "2.0.0": "name,region | wait=30,cap=50000 | queue name=$name wait=$wait cap=$cap region=$region; dead-letter name=dead-$name" },
  "field-network": {
    "1.0.0": "port |  | network-rule port=$port",
    "2.0.0": "port,direction |  | network-rule port=$port direction=$direction",
    "2.0.1": "port,direction |  | network-rule port=$port direction=$direction logging=on" } };

export const RELEASE = ("1 measurement-queue 1.0.1 patch|2 collection-point 1.1.0 minor|3 field-network 2.0.0 breaking|" +
  "4 collection-point 1.1.1 patch|5 measurement-queue 1.1.0 minor|6 collection-point 2.0.0 breaking|" +
  "7 measurement-queue 2.0.0 breaking|8 collection-point 2.0.1 patch|9 field-network 2.0.1 patch")
  .split("|").map((s) => s.split(" ")).map(([h, m, s, t]) => [+h, m, s, t]);

export const DEFINITION = {                            // "module | passed | pinned version | apply weeks"
  "north-point": "collection-point | source-block=10.20.0.0/16 count=4 | 1.0.0 | 2,6,9",
  "west-point": "collection-point | source-block=10.30.0.0/16 | 1.0.0 | 3,7",
  "east-point": "collection-point | source-block=10.40.0.0/16 count=8 | 1.0.0 | 1,5,10",
  "south-point": "collection-point | source-block=10.50.0.0/16 | 1.0.0 | 8",
  "north-queue": "measurement-queue | name=north-read | 1.0.0 | 2,8",
  "west-queue": "measurement-queue | name=west-read wait=60 | 1.0.0 | 4,9",
  "central-queue": "measurement-queue | name=central-read | 1.0.0 | 6,10",
  "field-inbound": "field-network | port=8080 | 1.0.0 | 4,10",
  "lab-inbound": "field-network | port=9443 | 1.0.0 | 5" };
export const UPGRADE = [[7, "west-point", "1.1.1"], [9, "north-queue", "1.1.0"]];   // manual, pinned setup
export const FIX = {                                   // the line written into the definition after a breaking release
  "network-block": (a) => a["source-block"], direction: () => "inbound", region: (a) => a.name.split("-")[0] };
```

The three modules' twelve versions are read together with the release flow. `collection-point`
renames its required input to `network-block` in the sixth week, `field-network` demands a new
required input called `direction` in the third week, `measurement-queue` demands `region` in the
seventh week. All three are **breaking** releases, and the cost of all three shows up in the
definitions that use the registry.

## Two Setups, the Same Ten Weeks

```js
// period.mjs — the same release flow and the same apply calendar are run in two setups; the gap is counted.
import { MODULE, RELEASE, DEFINITION, UPGRADE, FIX } from "./registry.mjs";

const kv = (s) => Object.fromEntries(s.trim() ? s.trim().split(/[ ,]+/).map((x) => x.split("=")) : []);
const DEF = Object.fromEntries(Object.entries(DEFINITION).map(([t, s]) => {
  const [m, g, p, h] = s.split(" | ");
  return [t, { m, g: kv(g), p, h: h.split(",").map(Number) }];
}));
const resolve = (s) => { const [z, v, b] = s.split(" | ");
  return { req: z.trim() ? z.trim().split(",") : [], vars: kv(v), body: b.split("; ") }; };
const latest = (m, h) => (RELEASE.filter((y) => y[1] === m && y[0] <= h).at(-1) ?? [, , "1.0.0"])[2];
const render = (mv, a) => mv.body.map((l) => l.replace(/\$([\w-]+)/g, (_, x) => a[x])).join("; ");
const diff = (a, b) => {                               // words added/changed between two outputs
  const x = a.split(/[ ;]+/), y = b.split(/[ ;]+/);
  return y.filter((w) => !x.includes(w)).join(" ") || "(removed)";
};

const run = (pinned) => {
  const args = {}, pin = {}, prevOut = {}, ran = {}, last = {}, prevVer = {}, unexpected = [];
  let unexpectedCount = 0, brokenDefs = new Set(), brokenLines = 0, changed = 0;
  for (const t in DEF) { args[t] = { ...DEF[t].g }; pin[t] = DEF[t].p; ran[t] = new Set(); }
  for (let h = 1; h <= 10; h++) {
    if (pinned) for (const [hy, t, s] of UPGRADE) if (hy === h) { pin[t] = s; args[t].$fixed = 1; }
    for (const t in DEF) {
      if (!DEF[t].h.includes(h)) continue;
      const s = pinned ? pin[t] : latest(DEF[t].m, h);
      const mv = resolve(MODULE[DEF[t].m][s]), full = { ...mv.vars, ...args[t] };
      const missing = mv.req.filter((a) => !(a in full));
      let fixed = args[t].$fixed;
      if (missing.length) {                            // breaking release: the definition is fixed on the spot
        for (const e of missing) { args[t][e] = FIX[e](full); full[e] = args[t][e]; }
        brokenDefs.add(t); brokenLines += missing.length; fixed = 1;
      }
      const c = render(mv, full);
      ran[t].add(s);
      if (prevOut[t] !== undefined && prevOut[t] !== c) {
        changed++;
        if (!fixed) { unexpectedCount++; unexpected.push([h, t, prevVer[t], s, diff(prevOut[t], c)]); }
      }
      prevOut[t] = c; last[t] = s; prevVer[t] = s; delete args[t].$fixed;
    }
  }
  return { unexpectedCount, brokenDefs, brokenLines, changed, pin, ran, last, unexpected };
};

const UNPINNED = run(false), PINNED = run(true), total = Object.keys(DEF).length;
const versionsOf = (m) => RELEASE.filter((y) => y[1] === m);
console.log(`period 10 weeks | releases ${RELEASE.length}: ` + ["patch", "minor", "breaking"]
  .map((k) => `${k} ${RELEASE.filter((y) => y[3] === k).length}`).join(", ") +
  ` | definitions ${total} | applies ${Object.values(DEF).reduce((a, b) => a + b.h.length, 0)}`);
const mismatched = Object.keys(DEF).filter((t) => PINNED.pin[t] !== PINNED.last[t]).length;   // pinned version is not what is running
for (const [label, r, y] of [["unpinned ", UNPINNED, total], ["pinned   ", PINNED, mismatched]])
  console.log(`${label} | output changes ${r.changed} | unexpected changes ${r.unexpectedCount}` +
    ` | broken definitions ${r.brokenDefs.size} | fix lines ${r.brokenLines} | distinct versions run ` +
    `${Object.values(r.ran).reduce((a, b) => a + b.size, 0)} | definitions the record does not say what is running for ${y}/${total}`);

for (const [h, t, e, s, d] of UNPINNED.unexpected)     // outputs that changed with the definition untouched
  console.log(`  unexpected   h${String(h).padEnd(2)} ${t.padEnd(14)} ${e} -> ${s}  added: ${d}`);
for (const [h, m, s, kind] of RELEASE) {               // how many users a release reached
  const u = Object.keys(DEF).filter((t) => DEF[t].m === m && UNPINNED.ran[t].has(s));
  const b = Object.keys(DEF).filter((t) => DEF[t].m === m && PINNED.ran[t].has(s));
  console.log(`  h${String(h).padEnd(2)} ${m.padEnd(17)} ${s} ${kind.padEnd(9)} definitions using it ${Object.keys(DEF)
    .filter((t) => DEF[t].m === m).length} | reached: unpinned ${u.length}, pinned ${b.length}`);
}

let behind = 0, missedPatches = 0, stale = 0;          // the cost of pinning: at the end of the period
for (const t in DEF) {
  const l = versionsOf(DEF[t].m), after = l.slice(l.findIndex((y) => y[2] === PINNED.pin[t]) + 1);
  behind += after.length; missedPatches += after.filter((y) => y[3] === "patch").length;
  if (after.length) stale++;
  console.log(`  ${t.padEnd(18)} pinned ${PINNED.pin[t]}  ran ${PINNED.last[t]}  newest ${l.at(-1)[2]}` +
    `  behind ${after.length}  missed patches ${after.filter((y) => y[3] === "patch").length}`);
}
const used = new Set(Object.keys(DEF).map((t) => `${DEF[t].m} ${PINNED.pin[t]}`));
const outsideLines = [...used].reduce((a, k) => {
  const [m, s] = k.split(" "), v = resolve(MODULE[m][s]);
  return a + v.req.length + Object.keys(v.vars).length + v.body.length;
}, 0);
console.log(`definitions left on an old version ${stale}/${total} | total behind ${behind} versions | missed patches ${missedPatches}` +
  ` | definitions in the repo ${Object.values(DEF).reduce((a, d) => a + 3 + Object.keys(d.g).length, 0)} lines` +
  ` | depends on ${used.size} module versions, ${outsideLines} lines outside the repo`);
```

```
period 10 weeks | releases 9: patch 4, minor 2, breaking 3 | definitions 9 | applies 18
unpinned  | output changes 9 | unexpected changes 3 | broken definitions 9 | fix lines 9 | distinct versions run 18 | definitions the record does not say what is running for 9/9
pinned    | output changes 1 | unexpected changes 0 | broken definitions 0 | fix lines 0 | distinct versions run 10 | definitions the record does not say what is running for 1/9
  unexpected   h5  east-point     1.0.0 -> 1.1.1  added: direction=inbound monitoring status=on
  unexpected   h9  north-point    2.0.0 -> 2.0.1  added: type=collector
  unexpected   h10 field-inbound  2.0.0 -> 2.0.1  added: logging=on
  h1  measurement-queue 1.0.1 patch     definitions using it 3 | reached: unpinned 2, pinned 0
  h2  collection-point  1.1.0 minor     definitions using it 4 | reached: unpinned 2, pinned 0
  h3  field-network     2.0.0 breaking  definitions using it 2 | reached: unpinned 2, pinned 0
  h4  collection-point  1.1.1 patch     definitions using it 4 | reached: unpinned 1, pinned 1
  h5  measurement-queue 1.1.0 minor     definitions using it 3 | reached: unpinned 1, pinned 0
  h6  collection-point  2.0.0 breaking  definitions using it 4 | reached: unpinned 2, pinned 0
  h7  measurement-queue 2.0.0 breaking  definitions using it 3 | reached: unpinned 3, pinned 0
  h8  collection-point  2.0.1 patch     definitions using it 4 | reached: unpinned 3, pinned 0
  h9  field-network     2.0.1 patch     definitions using it 2 | reached: unpinned 1, pinned 0
  north-point        pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 4  missed patches 2
  west-point         pinned 1.1.1  ran 1.1.1  newest 2.0.1  behind 2  missed patches 1
  east-point         pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 4  missed patches 2
  south-point        pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 4  missed patches 2
  north-queue        pinned 1.1.0  ran 1.0.0  newest 2.0.0  behind 1  missed patches 0
  west-queue         pinned 1.0.0  ran 1.0.0  newest 2.0.0  behind 3  missed patches 1
  central-queue      pinned 1.0.0  ran 1.0.0  newest 2.0.0  behind 3  missed patches 1
  field-inbound      pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 2  missed patches 1
  lab-inbound        pinned 1.0.0  ran 1.0.0  newest 2.0.1  behind 2  missed patches 1
definitions left on an old version 9/9 | total behind 25 versions | missed patches 11 | definitions in the repo 39 lines | depends on 5 module versions, 20 lines outside the repo
```

All eighteen of the eighteen applies ran in both setups; the difference is in what ran. In the
unpinned setup, output changed nine times; in the pinned setup, once. That one time is also a
manual upgrade; no output changed without someone wanting it.

**Three of the nine changes are unexpected changes**: the definition text was never touched in
that apply, yet the produced resource came out different from the previous one. The remaining six
came right after breaking releases, and the definition had already been fixed by that point. The
breakdown of the three unexpected ones shows what they have in common: `east-point` jumped two
versions at once in the fifth week and gained a network field plus a monitoring line, `north-point`
picked up `type=collector` on its machine cluster in the ninth week, `field-inbound` picked up
`logging=on` on its network rule in the tenth week. All three are the result of a release that
shows up nowhere in the record, all three applied silently, and **none of them raised an error**.

The spread of breaking releases is the second number. Three breaking releases broke **nine of nine
definitions** in the unpinned setup and demanded nine lines of fixing. Three releases, nine fixes:
the cost of one breaking release is paid once on the publishing side, and once per user on the
consuming side. In the pinned setup, the same three releases broke no definition and demanded
zero lines of fixing — but that does not mean the cost was erased, it means it was deferred.

The per-release breakdown gives how many users each release reached. None of the nine releases
reached every definition using that module: the widest reach is three of three, the narrowest is
one of four. Who a release reaches is decided not by the publishing side, but by **the consuming
side's apply calendar**. In the pinned setup, eight of the same nine releases reached no user at
all; the one release that did reach someone is the patch the manually upgraded definition pulled.
In the unpinned setup, nine definitions ran eighteen distinct versions over the period; in the
pinned setup, ten: two per definition against 1.1.

## The Cost of Pinning

At the end of the period, the table flips. In the pinned setup, **nine of nine definitions are on
an old version**; they are twenty-five versions behind in total, and **eleven patches** never
reached them at all. A patch is a bug fix; pinning does not only stop unwanted change, it stops
wanted fixes too.

The distribution shows how the deferral accumulates. Five of the seven definitions that were never
upgraded are three to four versions behind, two are two versions behind; `west-point`, upgraded once
during the period, is two versions behind and missing one patch. An upgrade pulls a definition forward,
but because the release flow does not stop, the distance reopens right away. The cost of pinning is not a
one-time delay, it is **a gap that keeps growing**.

The interest on that deferral is countable too: the nine lines of fixing not paid today are not
erased; if those nine definitions are ever upgraded, they will meet all three breaking releases at
once.

## What the Record Does Not Say

The third number concerns the record itself. In the unpinned setup, the definition writes no
version at all; **in nine of nine definitions, the record does not describe what is running.**
Reading the definition file cannot tell you what resource will be produced, because part of the
text lives somewhere else, and that place can change.

Pinning brings this down to one, but not to zero. The `north-queue` row shows the gap: pinned
version 1.1.0, running version 1.0.0. The version was upgraded in the ninth week, but that
definition had no other apply during the period. **The record says the new version, the field
still holds the old one.** Pinning does not eliminate the gap between record and reality; it moves
the gap from release time to **apply time**.

The last line gives the size of the dependency. The repository's nine definitions are thirty-nine
lines, and they depend on five separate module versions, **twenty lines of text outside the repo**
in total. In the pinned setup, which version of those twenty lines is in effect is written in the
definition; in the unpinned setup it is not written anywhere, and it can change shape without a
single record in the repository changing.

## Summary

- The same 18 applies ran in both setups: output changed 9 times in the unpinned setup, 1 time in
  the pinned setup; that 1 change is also a manual upgrade.
- 3 of the 9 changes are **unexpected changes** — the definition text was untouched, the output
  changed, and none of them raised an error.
- 3 breaking releases broke 9 definitions in the unpinned setup and demanded 9 lines of fixing;
  in the pinned setup, 0 definitions broke, but the cost was not erased, it was deferred.
- The cost of pinning is seen at the end of the period: 9 of 9 definitions are on an old version,
  25 versions behind in total, and 11 patches never arrived.
- In the unpinned setup, 9 of 9 definitions have a record that does not say what is running; in
  the pinned setup, 1 does not — because the written version was never applied, record and
  reality diverged again.

## Next Step

Text coming from a module registry now behaves like part of the definition: it takes inputs,
produces resources, gives outputs. In this lesson, all of those inputs were harmless — a network
block, a port number, a queue name. What would happen if one of them were a data store password?
A value passes from definition to module, from module to resource, from resource to the state
file; along the way it also passes through the plan output, the log, and the pipeline record. The
next lesson sends a fictional sensitive value down that path and counts, with a trace scanner
written in `node`, how many places it shows up; then that value is stamped **sensitive** and the
scan is run again — counting how many places it is now hidden, and how many places it still
stands.
