ADR-0186: The notice closure is platform-scoped, and absence-by-platform is not loss
Date: 2026-08-17 Status: Accepted Deciders: Adrian (project owner), Claude Opus 5, Codex (review, PR #1131) Refines: ADR-0182 (legal-artifact regeneration has no bypass), ADR-0161 (legal artifacts collected from the artifact) Relates to: ADR-0171 (amd64 is the platform that ships)
Context
Master's Security workflow was red for nine days on one job. The stated cause
— the image's CPython moving 3.13.14 → 3.13.15 with the upstream
python:3.13-slim tag — was real but was only the outer layer. Clearing it with
the documented --accept-python-transition route surfaced a second refusal: nine
JavaScript components losing their preserved notices, for which ADR-0182
deliberately provides no flag at all.
Every one of the nine was macOS-only: @img/sharp-darwin-arm64,
@swc/core-darwin-arm64, fsevents, and six more.
Measured against the committed NOTICE.inventory.json:
| count | |
|---|---|
| platform-specific JavaScript components | 123 |
| ...carrying notice texts | 9 |
...of which darwin-arm64 or fsevents | 9 |
| linux-x64 components carrying notice texts | 0 of 17 |
The committed legal artifact had been collected on a Mac. It preserved licence
texts for a platform the product does not ship and omitted them for the platform
it does. collected_from.javascript[].host_platform was null for both
lockfiles, which is why compare_measured never surfaced it — the field it
compares had nothing in it.
The structural defect is narrower than the symptom. lost_notices cannot
distinguish a component that is not in the closure from one whose notice
vanished, because both produce an identical shape: a lockfile entry with an
empty text_sha256. collect_js reads texts from the installed tree, and npm
does not install a package whose os/cpu exclude the host. So a correct
Linux collection is refused by a guard that is correctly refusing what it can
see.
This is the third instance of the class ADR-0163 named — an absence of input rendered as a definite value rather than a typed absence — and here it renders "never present" as "lost".
Decision
The notice closure is scoped to the platform the collection ran on, and that platform is recorded.
-
collect_jscarries the lockfile's own declaration onto each record:{"platform": {"os": [...], "cpu": [...], "optional": true}}. The information was already parsed and was being discarded —package-lock.jsonstates@img/sharp-darwin-arm64asos: ["darwin"], cpu: ["arm64"], optional: true. -
A new pure
excluded_by_platform(record, host_platform)answers the questionlost_noticescould not ask, andlost_noticesskips components it excludes — keyed off the new side only, because the question is whether this collection could have seen the text. -
Fail-closed is the load-bearing rule. The predicate answers
Trueonly when the lockfile positively excludes the collecting host. No declaration, an unrecognisedos/cpuvalue, a negated entry (!win32), an unknown or unparseable host — all answerFalseand the component stays inside the closure. Wrongly excusing a loss is the expensive mistake; wrongly keeping one costs a re-measurement. -
The host is parsed as the structured
os/archthat_host_platform()actually emits, not substring-matched.
Union of all platforms was considered and rejected — see Alternatives.
The C library is recorded, disclosed, and deliberately NOT an excuse
libc is the one platform axis excluded_by_platform does not act on, and the
asymmetry is deliberate in both halves.
Why not excusing. The JavaScript closure is collected where npm ci runs,
which is a glibc host, while frontend/Dockerfile builds the shipping tree on
Alpine — musl. If libc excused, every musl-targeted component would be
declared foreign on that collection and its missing notice forgiven, for
software the frontend image actually ships. That is the false clear this ADR
exists to prevent, arriving through the mechanism meant to prevent it. So those
components stay in the closure and their absent notices stay visible in
lost_notices.
Why disclosing was still necessary. Leaving the reasoning in a source
comment was not a disclosure. A reader auditing NOTICE saw components listed as
shipped with no notice held and nothing explaining why — and ADR-0181 records the
rule directly: an internal-only disclosure is the same as absent. NOTICE now
carries a paragraph naming the axis, the two hosts that disagree, the counts, and
that the components are not excused. Measured on the committed inventory over
the shipped set: 18 components declare a C library (9 glibc, 9 musl), and of the 9
musl-targeted ones 7 are runtime components shipping with no notice held; the
remaining 2 (@tailwindcss/oxide-linux-x64-musl,
@unrs/resolver-binding-linux-x64-musl) are development-scope and are described
in NOTICE as not deployed.
The split matters and is not a rounding detail. "9 ship without a notice"
overstates the legal gap by two, and _shipped — which means "not excluded by
platform" — includes development records. An earlier revision of this ADR and of
the rendered paragraph both said 9; both were corrected (Codex P1 then P2 on
PR #1140). The second correction is the ADR itself: a decision record that keeps
restating a number the artifact has already corrected is the drift these
documents exist to prevent.
The paragraph appears only when such a component exists. A permanent caveat on a clean artifact is a false alarm, and ADR-0121 records what happens to controls that raise those.
Scoped to the shipped set rather than the whole lockfile, because a musl
component also excluded on another axis (@img/sharp-linuxmusl-arm64 on an amd64
host) is already named as foreign, and counting it twice would report the same
absence in two places. That is why the figure is 9 and not the 20 a whole-lockfile
count gives.
The repair is not this change. Collecting the frontend closure from inside
the frontend image — as the Python closure already is — resolves the axis rather
than describing it. That is a larger change, and it is named as the known gap
here and in NOTICE rather than half-built. Codex proposed "evaluate the libc
constraint against each artifact's target" as an alternative remedy; on a single
collection with one host that reduces to excusing, which is why it is refused
above.
Consequences
Positive
- A correct collection on the shipping platform is no longer refused, so the legal artifacts are regenerable again. Master's red clears because the artifact changed, never because a check was relaxed (ADR-0164 principle).
- The artifact now describes what ships. After regeneration, 7 platform-specific components carry texts and all 7 are linux.
host_platformis recorded (['linux/amd64', 'linux/amd64']where it was[null, null]), so a future collection on the wrong platform is visible rather than silent — the property whose absence hid this for months.- The real licence-exposure change was one pair:
fsevents(MIT, macOS-only, absent from the image) out,@emnapi/runtime(MIT, in the linux closure) in. Distinct notices held at 1238. The raw "9 darwin vs 0 linux" figure overstated the exposure, because platform variants share notice text —@img/sharp-libvips-darwin-arm64and-linux-x64ship the same LGPL text.
Negative
- A component can be listed as shipped with no notice held, and that is now
the honest rendering rather than a defect.
@img/sharp-linuxmusl-x64appears under both "JavaScript components — shipped" and "Notices not collected", because it ships and its notice is not held. Distinguish that from the contradiction this round fixed: "shipped" beside "not in this artifact" are mutually exclusive claims about the same package, andfseventscarried both. - The libc gap is disclosed, not closed. Seven runtime components ship with a notice we do not hold, and two more are missing one in the build tree. Naming them is better than the previous silence and is not the same as preserving them.
- A notice genuinely lost on a platform-excluded component is now excused.
That is the cost of the excusal and it is not mitigated: if
@img/sharp-darwin-arm64upstream removed its licence file, a Linux collection would not notice. Bounded by the fact that such a component is not distributed by us, so no preservation obligation attaches to it — but the detection is genuinely gone, not merely deferred. - The artifact is now platform-relative, and nothing in it says which platform
a reader should expect.
host_platformrecords where it was collected; it does not assert that this is the delivery boundary. That sentence is not written anywhere (see Alternatives 1 and the open question below). --accept-python-transitionscopes the loss check out ofpythonfor the accepting run, so a Python-side loss can ride along. Unchanged by this ADR and hand-checked for this regeneration: the Python set is unchanged at 264 and no Python component lost a notice.- One more concept in a script that already carries several.
platformjoinsscope,licence_sourceandlockfileson the record.
Neutral
- No migration, no flag, no schema version. The
platformkey is additive and absent where it means nothing. - 157 of 2,481 JavaScript records now carry the key.
Alternatives Considered
Alternative 1: Collect the union of all platforms
- Fetch every platform variant's tarball (npm will not install them) and preserve every notice, so no platform's texts can ever be missing.
- Why rejected: it puts licence texts for software this product does not ship into an artifact that claims to describe the artifact — wrong in the other direction, and an auditor reading a linux-only image's NOTICE would find darwin binaries listed. It also adds ~114 network fetches to a collection that is otherwise offline, i.e. a new failure mode in a legal gate. This was the author's initial recommendation and was abandoned on contact with the code.
Alternative 2: Keep the file and record the divergence as a known gap
- Leave the macOS-collected inventory, note in §4 that it describes the wrong platform.
- Why rejected: the gap is not the kind that can be honestly declared. A NOTICE exists to accompany a distribution; one that omits the distributed platform's notices is not a disclosed gap, it is a missing legal artifact. And it leaves master red indefinitely.
Alternative 3: Pin the collection to macOS so the committed file stays valid
- Declare the Mac the collection platform and require future collections there.
- Why rejected: it contradicts ADR-0171 — amd64 is the platform that ships, and only the shipping closure is a fact about the artifact. It would also make CI, which runs on Linux, permanently unable to verify the file it gates.
Alternative 4: Relax lost_notices to a warning
- Downgrade the refusal so any loss reports without blocking.
- Why rejected: that is the bypass ADR-0182 exists to forbid, and it would relax the guard for every ecosystem to solve a problem in one dimension of one of them. The narrow predicate does strictly less damage.
The delivery boundary, answered
What is delivered: the two container images. Owner decision, 2026-08-17.
This was drafted as an open question, because it decides whether the 1,481
Docusaurus entries and 537 devDependencies belong in the SBOM at all, and whether
a platform-scoped notice closure is the right scope or merely a convenient one. It
was written nowhere — docs/oem/ held only a torch note and a size measurement —
and the ADR took the narrower reading provisionally, because that is what
ADR-0171 already assumes and what CI measures.
The answer confirms the narrower reading, which means three things stop being provisional:
- The platform-scoped closure is correct, not convenient. An image has one platform. Scoping the closure to it is a description of the artifact, and the union rejected in Alternative 1 stays rejected on the merits rather than on cost.
- The libc divergence becomes the one real defect on this axis, not one reading among several. The frontend image ships musl; the closure is collected on glibc; seven runtime components therefore ship with a notice we do not hold. Under a source delivery that would be a scoping argument. Under an image delivery it is simply wrong, and the repair — collect the frontend closure from inside the frontend image — is the work, not an option.
- Development-scope records are outside the delivery. They are still named (the lockfile lists them, and silence is its own inaccuracy) and their missing notices are still disclosed, but they are reported separately from what ships. That is why the round-3 correction split 9 into 7 runtime plus 2 build-only rather than narrowing the number.
Recorded here rather than in a new ADR because it answers this ADR's own question and changes no decision in it — it removes a conditional. If the boundary later becomes source, that is a new decision with a new record, not a stretch of this one.
Decision context:
- Latency: none at runtime. Collection gains one dict comprehension per npm record; the collection is already dominated by two image builds.
- Dependency surface: none added. Pure standard library.
- Debuggability: the predicate is pure and total over
(record, host), and every uncertain input is enumerated intest_notice_platform_closure_1136.py. A wrong answer is reproducible from two values. - Reversibility: one predicate and one skip in
lost_notices. Deleting both restores the previous behaviour exactly; theplatformkey would then be recorded and unread, which is inert. - Blast radius:
generate_notice.pyonly, plus the three regenerated artifacts. No application code, no schema, no runtime path. - Alternative considered: union of all platforms — rejected because it lists software the artifact does not contain (Alternative 1).