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:
- Lock the APT closure — a dated
snapshot.debian.orgsuite, or explicitpkg=versioninstalls. - Explicit version pins, dropping
apt-get upgrade. - 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.txtcarries no artifact hashes. ADR-0177 defers--generate-hashesexplicitly. 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/Dockerfilebuilds on Alpine/musl.generate_notice.pyalready 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
| state | meaning | what qualifies today |
|---|---|---|
reproducible | a rebuild yields the same bytes | nothing |
version_locked | a rebuild yields the same versions; bytes unbound | python, javascript |
measured_at_date | versions themselves can move | system |
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 baris not pinned. A whole-command"=" in linetest 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 onapt-get upgradealone, 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
systemverdict is per Dockerfile, sofrontend/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
RUNlines 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
SCHEMAstays 2: the ecosystem structure is unchanged andcompare_measuredinspects 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.