Skip to main content

ADR-0191: Accept a floating OS closure, and declare the strength of every closure

Date: 2026-08-20 Status: Accepted — amended by ADR-0197 (the frontend OS closure now floats too, and the unchanging upgrade layer this ADR accepted never patched a cached rebuild). Body unchanged per s4u-adr §10. Deciders: Adrian (project owner — chose option 3), Claude Opus 5, Codex (review, PR #1176)

Context

ADR-0171 and #993 pinned both base images by digest. That stops the base layer moving under NOTICE.inventory.json, and it was presented — by me — as fixing the recurring Security red for good.

It does not. backend/Dockerfile's runtime stage runs apt-get update && apt-get upgrade -y with unversioned installs, so an uncached rebuild of the same commit resolves whatever Debian publishes that day, and the system section of the inventory can move with the digest unchanged. The claim in #993 that the inventory "stays true until the digest is deliberately moved" was false as written and was withdrawn there.

That left a decision, not a bug. Three options were costed in #1174:

  1. Lock the APT closure — a dated snapshot.debian.org suite, or explicit pkg=version installs.
  2. Explicit version pins, dropping apt-get upgrade.
  3. Accept and declare — keep the floating closure, and state in the artifact which sections are reproducible and which record one measurement.

Review of the first implementation then found the declaration itself overclaiming, which is what forced the second half of this decision. Two sections were marked reproducible on the strength of exact pins and lockfiles, and neither survived inspection against this repository's own records:

  • backend/requirements.txt carries no artifact hashes. ADR-0177 defers --generate-hashes explicitly. A pinned version fixes which release is installed, not which bytes; a republished artifact at that version would not be detected.
  • The JavaScript closure is collected under glibc while frontend/Dockerfile builds on Alpine/musl. generate_notice.py already documents that 58 locked packages declare a libc constraint and that the two installs produce different closures (Codex P1 on PR #1140). A lockfile fixes versions; it does not make a glibc-collected closure identical to a musl-shipped one.

Decision

Keep the floating OS closure (option 3), and declare the strength of every closure in three states rather than two.

Why the OS closure stays floating

apt-get upgrade -y exists to pull security fixes the base image predates. Freezing it to a snapshot buys reproducibility by stopping security patches reaching the image until someone bumps the snapshot date — trading a visible gate failure for an invisible security regression. That is the direction this repository refuses to move a control, and the reason option 1 was rejected rather than deferred.

Three states, because two force an overclaim

statemeaningwhat qualifies today
reproduciblea rebuild yields the same bytesnothing
version_lockeda rebuild yields the same versions; bytes unboundpython, javascript
measured_at_dateversions themselves can movesystem

reproducible is reserved for byte-level fixity and is currently unclaimed. Stating that no section reaches the strongest sense is the honest reading of the evidence, and it is more useful to a licence reviewer than a binary that would have to round one way or the other.

version_locked is the state a licence review actually needs — the set of components and their versions is fixed, which is what NOTICE asserts — while making clear that byte-level fixity is not claimed.

The declaration is derived, never written

derive_reproducibility() reads the committed build inputs: pin/floor counts and --hash= presence in requirements.txt, committed lockfiles, and a per-Dockerfile scan for floating OS installs. Pure over files, like validate — the same answer on a laptop and a runner, no image required.

A hand-written declaration is the claim-vs-check defect aimed at the legal artifact: it would still read reproducible after someone loosened a pin. The paired test compares the complete derived record, not just the state label, because comparing labels alone let the explanation go stale — adding one exact pin keeps the state and leaves the committed text asserting the old count, which NOTICE then renders.

Two further properties are load-bearing:

  • Per-package, not per-command. apt-get install -y foo=1.0 bar is not pinned. A whole-command "=" in line test called it pinned because one argument was.
  • The test shares the implementation's predicate. Its oracle is dockerfile_floats_os_packages, not a substring of its own. An earlier version keyed on apt-get upgrade alone, so removing the upgrade while leaving an unversioned install would have produced a misleading failure telling the developer to close a still-open issue.

Absence is fail-closed. An inventory collected before this ADR has no declaration and renders as "not declared … do not read the absence as a guarantee", because a reader told nothing assumes the stronger claim.

Consequences

Positive

  • The legal artifact stops implying a guarantee it cannot support, in the direction that matters: an OEM reader is told what is fixed and what is not.
  • The declaration cannot rot. Loosening a pin, or genuinely locking the OS closure, both fail the test until the artifact is regenerated — in both directions, so a future improvement cannot leave a stale disclaimer understating it either.
  • Security patches keep reaching the image, which locking the closure would have stopped.
  • The system verdict is per Dockerfile, so frontend/Dockerfile — which installs no OS packages — is not tarred with the backend's floating closure.

Negative

  • The images are not byte-reproducible, and now say so. An escrow beneficiary or SBOM reviewer who requires byte-level reproducibility will read this and conclude it is not provided. That is the correct conclusion; recording it does not create the limitation, it stops concealing it.
  • Three states are more to explain than two, and "version-locked" is a distinction a casual reader may collapse back into "reproducible". The rendered paragraph spells the difference out for exactly that reason.
  • The derivation is heuristic over Dockerfile text. It reads RUN lines rather than executing a build, so an install reached by an indirect mechanism — a shell script copied in and invoked — would not be seen. Recorded rather than solved: the alternative is executing builds inside a legal-artifact check.

Neutral

  • SCHEMA stays 2: the ecosystem structure is unchanged and compare_measured inspects records and provenance, not top-level keys, so the scheduled NOTICE comparison is unperturbed.
  • Nothing about the build changes. This ADR changes what the artifact says, not what the artifact is.

Alternatives Considered

Alternative 1: lock the APT closure to a dated Debian snapshot

  • snapshot.debian.org/archive/debian/<date>/ plus a scheduled bump.
  • Why rejected: it stops security patches reaching the image between bumps, and adds a build-time dependency on snapshot.debian.org's availability, which has been unreliable. Reproducibility bought with an invisible security regression is the wrong trade for this artifact.

Alternative 2: explicit pkg=version installs, dropping the upgrade

  • Why rejected: same security consequence in a less visible form — the pinned versions rot silently, with no scheduled mechanism forcing review, and the base image's own packages remain whatever the digest carries.

Alternative 3: keep two states and call the pinned ecosystems reproducible

  • The first implementation. Why rejected: it is false in two places, both refutable from this repository's own files (ADR-0177's deferred hashes; generate_notice.py's recorded libc divergence). A legal artifact that overclaims is worse than one that declares a limit.

Alternative 4: say nothing, as before this ADR

  • Why rejected: silence is read as the stronger claim. The inventory would continue to look like a reproducible manifest for a closure that is not one.