ADR-0182: Regenerating a legal artifact that would SHRINK it is refused, with no bypass
Date: 2026-08-10 Status: Accepted Extends: ADR-0161 (legal artifacts collected from the artifact); ADR-0161 stays Accepted Deciders: Adrian (Soft4U BV), Claude Opus 5, Codex (review, PR #1115)
Context
ADR-0161 made THIRD_PARTY_LICENSES hold 2,494 verbatim notices
hash-addressed by SHA-256, and made --check block every PR. Both controls
verify internal agreement: NOTICE, the texts and the inventory describe the
same thing.
Neither can see a loss, and #1106 is the live instance. A stale node_modules
(dompurify 3.4.13 on disk, 3.4.12 in the lockfile) meant the text walk matched
nothing, text_sha256 went 2 → 0, and an MPL-2.0 notice this Software is
required to preserve left the file. Both commands a developer runs reported the
artifact fine, and both were telling the truth:
collectoverwrote the inventory without reading the one it replaced;--checkverifies NOTICE and the texts agree with the inventory, and a run that drops a notice from the inventory and the texts together leaves them in perfect agreement.
Only the scheduled freshness job called compare_measured, and ADR-0176 moved
that to weekly — so the guard ran up to seven days after the commit and never at
the moment the loss occurred.
A licence notice removed from a distributed artifact is a compliance failure against the licence itself, not a cosmetic regression. It is also invisible: the file still exists, still parses, still passes every check.
Decision
collect compares its result against the committed inventory and refuses
to write when a (name, version) present in both would hold strictly fewer
preserved notices than it does today.
The committed bytes are the baseline because they are the only copy that cannot have been shortened by the run being checked.
Scoped to the same (name, version) in both inventories, which is what makes
it safe to fail closed. A version bump is a removal plus an addition and can
never trip it; only the same package, at the same version, holding strictly
fewer notices. That is not a legitimate outcome of any collection.
There is no bypass — no flag, no environment variable, no --force. This is
the load-bearing part of the decision, and it is deliberate:
- the failure mode is a developer in a hurry with a red gate and a deadline, which is exactly when a bypass is used and exactly when it must not exist;
- every legitimate cause has a specific remedy the refusal names (refresh the dependency tree, migrate the base image, record the platform), and a bypass would substitute for finding out which one applies;
- the artifact is distributed to third parties, so the cost of being wrong is borne by someone who cannot inspect it.
Three flags exist, and none is a bypass: --accept-removed-source,
--accept-manager-migration and --accept-python-transition. Stating
this plainly rather than letting the heading above stand unqualified, because a
policy that quietly acquires an exception is the shape this repository keeps
finding in other people's controls.
--accept-python-transition was added later and is recorded here rather than
left to the code (Codex P1 on PR #1115), because an exception discovered by
reading the source is the shape this ADR exists to refuse.
It answers the same prior question as the other two: what artifact was this
collection made from? When the Python boundary moves — an upstream
python:3.13-slim patch release, or a deliberate move between image and prefix
collection — the wheels legitimately differ at unchanged package versions, so a
loss comparison across that boundary is not sound. It carries the same three
properties: refused when no transition is detected, written into the inventory
as collected_from.accepted_python_transition, and scoped to python alone.
WHY IT HAD TO EXIST, stated because the alternative was worse than the
exception: without it a routine base-image bump made the artifacts
unregenerable. The boundary move landed in incomparable, whose only remedy
is "rebuild for linux/amd64" — which reproduces the changed runtime. A
fail-closed control with no reachable answer is not fail-closed; it is broken,
and it gets removed by whoever needs to ship.
The distinction is what the flag answers. A bypass answers "the check fired;
proceed anyway" — it relaxes the check. --accept-removed-source answers a
prior question: which sources was this collection made from? Notices are
UNIONED across sources, so deleting an owned lockfile or Dockerfile makes that
ecosystem's union legitimately smaller; a comparison across it is no longer
sound, in either direction. Three properties keep it from becoming a --force:
- it names each removed source exactly, in both directions — a supplied set that does not equal the detected set is refused, so it cannot be pasted in once and left to cover whatever changes later;
- the acknowledgement is written into the inventory
(
collected_from.accepted_source_removals), so the decision is visible in the artifact it changed rather than only in a shell history; - the loss check is scoped, never relaxed. It is skipped only for the
ecosystem(s) whose union the named removals made unsound — one per removed
source kind, and a removed kind with no recorded ecosystem REFUSES rather than
scoping nothing out — and stays fully in force for every other. Deleting
frontend/Dockerfiledoes not weaken the Python or JavaScript guard by one component.
--accept-manager-migration carries the identical contract for a base-image
migration (dpkg → apk) on an unchanged platform. That was classified
incomparable and given the platform remedy — "rebuild for linux/amd64" —
which PRESERVES the new manager, so every subsequent correct collection failed
identically and the artifacts could never be written again (Codex P2).
A package manager governs the OS closure, so it scopes the loss check out of
system. And python as well, when the migrated image is the one --python
was collected from — because then the SAME artifact supplied both ecosystems,
and its wheels can legitimately differ at unchanged package versions. This
paragraph previously said "nothing else… system alone", which the decision
table beside it had already been corrected to contradict, leaving the
authoritative ADR carrying two incompatible rules for one transition (Codex P2
on PR #1115). The implementation matches the table; this text now does too.
The two images are matched by content-addressed image_id, never by reference
substring — image:app is not image:myapp, and treating it as one let a
SYSTEM migration silently scope Python out of a run it had nothing to do with.
A stale acknowledgement is refused, not ignored. Either flag supplied on a run where its condition does not hold now REFUSES. Silently ignoring it let a wrapper script carry the flag indefinitely, so the day the source was actually removed the removal was pre-authorised with nobody deciding anything. An exception that outlives the condition it was granted for is a bypass, whatever it is called.
Two weaker designs were built and rejected on the way here. Acknowledging the removal and then still running the unscoped check refuses on the very shrink the acknowledgement described — a route that looks like a path forward and is not. Downgrading the refusal to a recorded warning would have relaxed the guard across every ecosystem at once to solve a problem confined to one.
The residual, stated rather than left to be found. Scoping is per ECOSYSTEM,
not per component, because the inventory records notice hashes unioned across
sources and does not record which source contributed which hash. So on the one
run that acknowledges a removed Dockerfile, a genuine and unrelated loss in the
system ecosystem would not be reported. Three things bound it: the window is a
single run, not a state; the very next collect — with the source set now
settled — enforces system again against the newly committed baseline; and the
other ecosystems are never weakened. Closing it properly means recording
per-source provenance for each preserved text, which is a schema change and is
not attempted here. It is written down because a limit nobody wrote down is
indistinguishable from a limit nobody knew about.
Three conditions are not losses and must not be reported as one, because each has a different remedy and a wrong remedy is worse than none:
| Condition | Why it is not a loss | How it is reported |
|---|---|---|
| Different platform (arm64 vs amd64) | A different closure was measured — ADR-0171 records what measuring the wrong architecture costs | incomparable, refuses to write, names the platforms |
| Different package manager (dpkg → apk) | A base-image migration; two packaging ecosystems are not the same artifact | migrated — named, then ACCEPTED through --accept-manager-migration, which scopes the loss check out of system (and out of python when the migrated image is also the --python source, because the wheel tag moves with it). Reported as incomparable in an earlier revision of this table, which contradicted the acceptance path three sections above and would have reinstated the no-regeneration dead end |
| Added or removed collection source | Notices are unioned across sources, so the union legitimately shrinks | named as a source-set change, never as a shrunken notice set |
Missing provenance is a THIRD state, not a failure. A committed inventory predating the platform field records no platform. That absence is not evidence of a different platform, so the loss check still runs — if the platforms really did differ it reports false losses, which is the safe direction — and the field is recorded so the next run can verify it. When every boundary is unrecorded (the first run after this change) the message collapses to one line, because one line per lockfile buries the case worth reading: a single later boundary going unrecorded.
Consequences
Positive
- The one unambiguous regression a re-collect can produce is caught at the moment it occurs, by the command that produces it, rather than up to seven days later by a weekly job.
- Each non-loss condition names its own remedy, so the refusal is actionable rather than a wall.
Negative
- A developer can be genuinely blocked with no escape hatch. If the gate misfires, the only routes are to fix the cause or to change this code — and changing it is a reviewed commit, which is the intended friction and is nonetheless real friction.
- The comparison needs the committed inventory to be readable and well-formed, and absence is treated exactly like corruption: untracked, uncommitted, no HEAD, or outside the repository all refuse. Three diagnoses, one consequence. The intermediate design that proceeded with an announcement was weaker than the working-tree fallback it replaced — that fallback at least compared against the prior artifact, while proceeding with no baseline compares against nothing, and an announcement does not stop a loss. The cost is real: the tool cannot bootstrap a repository that has never committed an inventory, which is the same stance the missing-file check already took.
- First-run noise on any repository whose inventory predates the platform field.
Neutral
- No schema migration: the provenance fields are additive and absence is handled as its own state.
--checkis unchanged and still blocks every PR.
Alternatives Considered
Alternative 1: A --force (or ALLOW_NOTICE_LOSS=1) escape hatch
Why rejected: the gate exists for the case where someone is in a hurry with
a red gate. A bypass is used precisely then, and it converts a detection into a
permission — the same argument ADR-0162 makes for refusing to relax
MIN_SEQUENCE_COVERAGE, and ADR-0157/0164 make for changing the artifact rather
than silencing the scanner.
Alternative 2: Warn loudly and write anyway
Why rejected: measured — this is what the tooling already did. collect
wrote, --check agreed with the result, and the MPL-2.0 notice was gone. A
warning nobody blocks on is what let #1106 reach a commit.
Alternative 3: Keep the check weekly, in compare_measured only
Why rejected: ADR-0176 moved it to weekly for cost, which is right for a
scan whose input cannot change between runs. This input changes on every
collect, so the tier is wrong — and a guard that runs seven days after the
loss cannot prevent it.
Decision context
- Latency: one extra read and parse of the committed inventory per
collect.collectalready walks thousands of packages; the comparison is set arithmetic over data already in memory. - Dependency surface: none — standard library only.
- Debuggability: improved. Each refusal names the condition, the affected packages, and the remedy for that specific condition.
- Reversibility: one function and its call site. But a revert restores the silent-loss window, so the cost is not symmetric.
- Blast radius: every
collectrun.--check, rendering and the published artifacts are untouched. - Alternative considered: a
--forcebypass — rejected because the gate's whole purpose is the moment a bypass would be reached for (Alternative 1).