---
title: 'Tagging Scheme'
source: 'https://academia.sh/en/courses/containers/tagging-scheme'
course: Containers
language: en
updated: '2026-08-23T16:55:08+00:00'
license: 'CC BY-SA 4.0'
---

# Tagging Scheme

A tag is measured in a layered image registry: how many distinct content digests twenty builds produce and how many tags point to the same digest are counted, how many of the four layers really change when a tag moves is calculated, and the rate at which a deployment can be traced back to its source from its digest is measured under three separate record disciplines.

The previous lesson treated the three base candidates as if their content were fixed. But no base
is chosen by content: it is pulled by a **name**. The same holds for the produced image — a build
produces new content, but what sits in a deployment record is the name.

M22/K01's build artifact repositories measured the distinction between a **portable tag** and an
**immutable version**; that count is not repeated here. The question here sits one layer lower: an
image is not a single object, it is a list of layers. Does a tag point to this list's entirety,
how many names can point to the same list at once, and when a name moves, how many of the list's
rows really change?

**IM31.** The registry model keeps two ledgers: image digest → manifest, and tag → image digest.
Access control and the network layer are outside the model. **IM32.** The build history comes from
our own generator; the seed `20260604` is visible, 20 builds are made, and one build in four is a
rebuild from the same source version. **IM33.** The image is four layers — dependencies,
application, and config on top of the previous lesson's slimmed base — and totals 34.5 MB.

## What a Tag Points To

A tag does not point directly to bytes. The chain has three levels: a tag names an **image
digest**, an image digest addresses a **manifest**, and a manifest lists layer digests in order.
These three levels are what makes the same name bring back the same set of layers.

**IM34.** The tagging scheme is this: every build gets an immutable version tag (`v-1`, `v-2`, …);
`latest` moves on every build, `stable` moves only to builds where the nightly batch job runs
clean, `branch-ready` moves on every fourth build. **IM35.** Three-quarters of builds write the
source version field into the config object; builds done by hand outside the pipeline do not write
it.

```js
// measurement-network/image.mjs — build history and tags in a layered image registry (model)
import { createHash } from "node:crypto";

export const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
export const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 12);

// [name, bytes, probability of changing independent of source]. The application layer depends on the source.
export const LAYER = [["base", 10.5e6, 0.08], ["dependencies", 16.0e6, 0.25],
  ["application", 7.2e6, null], ["config", 0.8e6, 0.30]];

export const history = (n = 20, seed = 20260604) => {
  const r = generator(seed);
  const version = [1, 1, 1, 1];
  const list = [];
  let source = 1, day = 0;
  for (let no = 1; no <= n; no++) {
    day += 1 + Math.round(r() * 8);
    const newSource = r() > 0.25;      // one in four is a rebuild from the same source
    if (newSource) { source += 1; version[2] += 1; }
    LAYER.forEach(([, , p], k) => { if (p !== null && r() < p) version[k] += 1; });
    const layers = LAYER.map(([name, bytes], k) => ({ name, bytes, digest: digest(`${name}@${version[k]}`) }));
    list.push({
      no, day, source: `s-${String(source).padStart(3, "0")}`, layers,
      image: digest("manifest:" + layers.map((k) => k.digest).join("|")),
      sourceWritten: r() > 0.25,        // builds run by hand outside the pipeline do not write this field
      cleanRun: r() > 0.40,             // did the nightly batch job run clean
    });
  }
  return list;
};

// Tag ledger: every build gets an immutable version tag, movable tags are conditional.
export const tag = (H) => {
  const ledger = new Map();             // tag -> image digest
  for (const d of H) {
    ledger.set(`v-${d.no}`, d.image);
    ledger.set("latest", d.image);
    if (d.cleanRun) ledger.set("stable", d.image);
    if (d.no % 4 === 0) ledger.set("branch-ready", d.image);
  }
  return ledger;
};

if (import.meta.url === `file://${process.argv[1]}`) {
  const H = history();
  const seen = new Set();
  console.log("no".padEnd(4) + "day".padEnd(5) + "source".padEnd(8) + "image digest".padEnd(15) + "status");
  for (const d of H) {
    const repeat = seen.has(d.image);
    seen.add(d.image);
    console.log(String(d.no).padEnd(4) + String(d.day).padEnd(5) + d.source.padEnd(8) +
      d.image.padEnd(15) + (repeat ? "repeat" : "new"));
  }
  const ledger = tag(H);
  const fanIn = new Map();
  for (const [t, image] of ledger) fanIn.set(image, [...(fanIn.get(image) ?? []), t]);
  const top = [...fanIn.entries()].sort((a, b) => b[1].length - a[1].length);
  console.log(`\n${H.length} builds produced ${seen.size} distinct image digests; ` +
    `${H.length - seen.size} builds reproduced a previous digest`);
  console.log(`${ledger.size} tags point to ${fanIn.size} distinct digests`);
  for (const [image, tags] of top.slice(0, 3)) {
    console.log(`  ${image}: ${tags.length} tags -> ${tags.join(", ")}`);
  }
  console.log(`builds with the source field written: ${H.filter((d) => d.sourceWritten).length}/${H.length}`);
}
```

```
no  day  source  image digest   status
1   7    s-002   2747388d03c9   new
2   11   s-002   acff51e2a712   new
3   18   s-002   acff51e2a712   repeat
4   25   s-003   4bd4e48c8ee9   new
5   29   s-004   81139748f001   new
6   33   s-005   cd88604b9280   new
7   41   s-006   91eb7d898a53   new
8   45   s-007   cd261660f128   new
9   54   s-008   4d5ddd536da3   new
10  60   s-009   ac698a72632e   new
11  66   s-010   13c70bced338   new
12  73   s-010   13c70bced338   repeat
13  78   s-010   3027786a6a54   new
14  83   s-011   a9e045075303   new
15  88   s-011   081c32254a6d   new
16  91   s-012   b3d1ed4a0430   new
17  95   s-013   385c204d516c   new
18  101  s-014   5ef86296e112   new
19  105  s-015   a246d27c5542   new
20  111  s-015   a246d27c5542   repeat

20 builds produced 17 distinct image digests; 3 builds reproduced a previous digest
23 tags point to 17 distinct digests
  a246d27c5542: 5 tags -> latest, stable, branch-ready, v-19, v-20
  acff51e2a712: 2 tags -> v-2, v-3
  13c70bced338: 2 tags -> v-11, v-12
builds with the source field written: 14/20
```

Twenty builds produced **seventeen** distinct image digests. Three builds reproduced a previous
build's digest: because neither the source nor the layers changed, the manifest came out the
same. This is visible proof of the deterministic build discipline from the previous lesson — under
a build that stamps, twenty builds would give twenty distinct digests.

The second number runs the other way: **23 tags point to 17 digests.** One digest has five names —
`latest`, `stable`, `branch-ready`, `v-19`, `v-20`. Two of them are immutable, three will point to
a different digest on the next build. Being able to look at the same object through five names is
not a flaw, it is the registry's design; the flaw is whether **which of these names gets written
to the deployment record** determines the object.

## What Changes When a Tag Moves

When a tag moves, a new image is addressed, but the image is not entirely new. A manifest is a
layer list, and two manifests can share some of the list's rows.

```js
// measurement-network/move.mjs — how many layers really change when a tag moves (model)
import { history, LAYER } from "./image.mjs";

const G = history();
const FULL = LAYER.reduce((t, [, b]) => t + b, 0);
const mb = (b) => (b / 1e6).toFixed(1) + " MB";

// Builds each tag targets, in order.
const targets = {
  latest: G,
  stable: G.filter((d) => d.cleanRun),
  "branch-ready": G.filter((d) => d.no % 4 === 0),
};

console.log("tag".padEnd(15) + "moves".padEnd(8) + "0 layers".padEnd(10) +
  "1 layer".padEnd(10) + "2+ layers".padEnd(11) + "new bytes".padEnd(11) + "full bytes");
const detail = [];
for (const [tag, sequence] of Object.entries(targets)) {
  const distribution = [0, 0, 0];
  let newTotal = 0;
  for (let i = 1; i < sequence.length; i++) {
    const old = sequence[i - 1].layers, updated = sequence[i].layers;
    const changed = updated.filter((k, j) => k.digest !== old[j].digest);
    const bytes = changed.reduce((t, k) => t + k.bytes, 0);
    newTotal += bytes;
    distribution[Math.min(changed.length, 2)] += 1;
    if (tag === "stable" && changed.length !== 1) {   // 1 layer is the ordinary case; it is in the summary
      detail.push(`s-${sequence[i - 1].no} -> s-${sequence[i].no}`.padEnd(16) +
        `${changed.length}/4`.padEnd(8) +
        (changed.map((k) => k.name).join(", ") || "-").padEnd(39) + mb(bytes));
    }
  }
  const moves = sequence.length - 1;
  console.log(tag.padEnd(15) + String(moves).padEnd(8) + String(distribution[0]).padEnd(10) +
    String(distribution[1]).padEnd(10) + String(distribution[2]).padEnd(11) +
    mb(newTotal).padEnd(11) + mb(moves * FULL));
}

console.log('\n"stable" moves with a layer count other than one:');
console.log("move".padEnd(16) + "layer".padEnd(8) + "changed".padEnd(39) + "new bytes");
for (const s of detail) console.log(s);

// Per layer: how many of the 19 "latest" moves changed it?
const count = {};
for (let i = 1; i < G.length; i++)
  G[i].layers.forEach((k, j) => {
    if (k.digest !== G[i - 1].layers[j].digest) count[k.name] = (count[k.name] ?? 0) + 1;
  });
console.log("\nchange per layer (across 19 moves): " +
  LAYER.map(([name]) => `${name} ${count[name] ?? 0}`).join(", "));
console.log(`full image ${mb(FULL)}; immutable version tags ${G.length}, movable tags ` +
  `${Object.keys(targets).length}`);
```

```
tag            moves   0 layers  1 layer   2+ layers  new bytes  full bytes
latest         19      3         10        6          141.7 MB   655.5 MB
stable         14      1         8         5          120.1 MB   483.0 MB
branch-ready   4       0         0         4          58.5 MB    138.0 MB

"stable" moves with a layer count other than one:
move            layer   changed                                new bytes
s-2 -> s-3      0/4     -                                      0.0 MB
s-3 -> s-5      3/4     base, application, config              18.5 MB
s-6 -> s-8      2/4     application, config                    8.0 MB
s-8 -> s-10     3/4     dependencies, application, config      24.0 MB
s-16 -> s-17    2/4     application, config                    8.0 MB
s-18 -> s-20    2/4     application, config                    8.0 MB

change per layer (across 19 moves): base 1, dependencies 2, application 13, config 7
full image 34.5 MB; immutable version tags 20, movable tags 3
```

The `latest` tag has moved nineteen times. Of these nineteen moves, ten changed only one layer,
three changed no layer at all. A zero-layer move is a case where the tag moved but the content
stayed the same: the name points to a new digest, the digest addresses the same manifest, and
there are zero bytes to pull. From the outside, though, it looks like "a new image was published,"
and a deployment can get triggered.

The bytes columns give the moves' real weight: across nineteen moves, the total that changed is
141.7 MB, while the same nineteen images' total size is 655.5 MB. Layer sharing removes 78 percent
of the moves' load. The `branch-ready` tag sits at the opposite end — in all four of its four
moves, two or more layers changed, because four builds pile up between them. **The less often a
tag moves, the more layers change per move.**

The last row shows where the change comes from: across nineteen moves, the application layer
changed 13 times, config 7, dependencies 2, the base only 1. This looks the same direction as the
previous lesson's update measurement — lower layers change rarely, upper layers change on almost
every build.

## Back From Digest to Source

Everything measured so far ran forward: from source to build, from build to digest, from digest to
tag. The question asked during an incident runs backward — **which source did this object running
in production come from?**

**IM36.** The 24 deployments are fictional; the seed `20260707` is visible, and the kind of
requested name — portable tag, immutable version tag, or direct digest — comes from the generator.
The chain has two links: from the deployment record to the image digest, from the image digest to
the source version.

```js
// measurement-network/traceability.mjs — can a deployment's source be traced back from its digest (model)
import { history, generator } from "./image.mjs";

const G = history();
const r = generator(20260707);
const FIELD_BYTES = 96;        // source version + build id + timestamp (fictional)
const FULL = 34.5e6;

// 24 deployments: day and requested name. The name is either a portable tag, a version tag, or a direct digest.
const deployments = [];
let day = 3;
while (deployments.length < 24) {
  day += 1 + Math.round(r() * 7);
  const p = r();
  deployments.push({ day, kind: p < 0.60 ? "portable" : p < 0.85 ? "version" : "digest" });
}

// The tag ledger advances by day: what resolves at deployment time is that moment's target.
const ledger = new Map();
let pos = 0;
for (const d of deployments) {
  while (pos < G.length && G[pos].day <= d.day) {
    const b = G[pos++];
    ledger.set("latest", b); ledger.set(`s-${b.no}`, b);
    if (b.cleanRun) ledger.set("stable", b);
  }
  const versionTags = [...ledger.keys()].filter((k) => k.startsWith("s-"));
  d.name = d.kind === "portable" ? (r() < 0.6 ? "stable" : "latest")
    : d.kind === "version" ? versionTags[versionTags.length - 1]
    : "digest:direct";
  d.target = d.kind === "digest" ? ledger.get("latest") : ledger.get(d.name);
}

// An immutable version tag determines the digest as long as the ledger refuses to move it; a
// portable tag does not. Three disciplines: (1) the record writes only the requested name, (2) it
// also writes the resolved digest, (3) every build additionally writes the source field.
const digestKnown = (d) => d.kind === "digest" || d.kind === "version";
const traceable = (allDigest, allSource) =>
  deployments.filter((d) => (allDigest || digestKnown(d)) &&
    (allSource || d.target.sourceWritten)).length;

const N = deployments.length;
console.log("discipline".padEnd(34) + "digest known".padEnd(16) + "source readable");
console.log("name only in record".padEnd(34) +
  `${deployments.filter(digestKnown).length}/${N}`.padEnd(16) +
  `${traceable(false, false)}/${N}`);
console.log("record also has digest".padEnd(34) + `${N}/${N}`.padEnd(16) +
  `${traceable(true, false)}/${N}`);
console.log("every build also writes source".padEnd(34) + `${N}/${N}`.padEnd(16) +
  `${traceable(true, true)}/${N}`);

// The other direction: how many distinct image digests did the same source version produce?
const fromSource = new Map();
for (const b of G) fromSource.set(b.source, new Set([...(fromSource.get(b.source) ?? []), b.image]));
const multi = [...fromSource.entries()].filter(([, s]) => s.size > 1);
console.log(`\ndeployment kind: portable ${deployments.filter((d) => d.kind === "portable").length}, ` +
  `version tag ${deployments.filter((d) => d.kind === "version").length}, ` +
  `direct digest ${deployments.filter((d) => d.kind === "digest").length}`);
console.log(`${fromSource.size} source versions, ${multi.length} of them produced more than one image digest: ` +
  multi.map(([k, s]) => `${k} -> ${s.size}`).join(", "));
console.log(`traceability's cost: ${FIELD_BYTES} bytes per image, 1 in ` +
  `${Math.round(FULL / FIELD_BYTES)} of the full image; ${N} x 1 field in the deployment record`);
```

```
discipline                        digest known    source readable
name only in record               14/24           9/24
record also has digest            24/24           17/24
every build also writes source    24/24           24/24

deployment kind: portable 10, version tag 10, direct digest 4
14 source versions, 3 of them produced more than one image digest: s-002 -> 2, s-010 -> 2, s-011 -> 2
traceability's cost: 96 bytes per image, 1 in 359375 of the full image; 24 x 1 field in the deployment record
```

The three rows form a ladder. Under the current setup, **nine** of twenty-four deployments'
sources can be read. The loss is in two places: ten deployments were made with a portable name, so
which digest they resolved to is not in the record, and part of the deployments with a known
digest also targeted a build whose source field was not written. Also writing the resolved digest
to the record raises the rate to **17/24**; writing the source field on every build raises it to
**24/24**.

The ten deployments made with an immutable version tag determining the digest rests on one
assumption: the ledger **refuses to move** the `v-N` tag. This refusal is not a feature, it is a
rule the registry enforces — the counterpart here of the immutability check measured in M22/K01.
If the rule were not enforced, the 14 in the first row would drop too, and the ladder's first step
would fall to four.

The second result runs the other way. Three of fourteen source versions produced more than one
image digest: the dependency or config layer changed without the source changing. Tracing back
leads all the way to the source; it does not lead forward from the source to a single object.

**This isolation's cost is small in number.** The source field is 96 bytes per image — 1 in
359,375 of the full image — and a single field added to the deployment record. The gain measured
is a rate that climbs from 9/24 to 24/24. **Where isolation is pierced** is the tag ledger itself:
the ledger is not inside the image, it is inside the registry. The image is a portable output; its
**name** is not — an image copied to another registry does not carry its tags along, only its
digest.

## Summary

- A tag is a three-level chain: a tag names an image digest, an image digest names a manifest, and
  a manifest names layer digests. A tag points to a list, not to bytes.
- 20 builds produced 17 distinct image digests; 3 builds reproduced a previous digest. This is
  visible proof of deterministic building — under stamped building, the count would be 20.
- 23 tags point to 17 digests; one digest has five names. The flaw is not having many names — it
  is whether the name written to the record determines the object.
- 10 of the `latest` tag's 19 moves changed a single layer, 3 changed none. The changed total is
  141.7 MB, the same images' total is 655.5 MB: sharing removes 78 percent of the load. The less
  often a tag moves, the more layers change per move.
- The rate a deployment can be traced back to its source depends on record discipline: 9/24; 17/24
  once the resolved digest is also written; 24/24 once the source field is written on every build.
  The cost is 96 bytes per image and a single field in the record.

## Next Step

This lesson's measurement silently assumed one thing: that the counterpart of every row in the
manifest sits somewhere. That place was never measured. Against the 141.7 MB that changed across
nineteen moves, there are 513.8 MB that did not, and how many copies of those bytes exist depends
on whether the layers are shared.

The next lesson builds that place and counts it: when multiple images reference the same layer,
how much does the store really grow; when an image is deleted, how many layers can really be
deleted; what happens when a retention rule runs into layer references; and how much does
downloaded traffic drop when a layer already present locally gets skipped?
