Skip to main content

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 components123
...carrying notice texts9
...of which darwin-arm64 or fsevents9
linux-x64 components carrying notice texts0 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.

  1. collect_js carries the lockfile's own declaration onto each record: {"platform": {"os": [...], "cpu": [...], "optional": true}}. The information was already parsed and was being discarded — package-lock.json states @img/sharp-darwin-arm64 as os: ["darwin"], cpu: ["arm64"], optional: true.

  2. A new pure excluded_by_platform(record, host_platform) answers the question lost_notices could not ask, and lost_notices skips components it excludes — keyed off the new side only, because the question is whether this collection could have seen the text.

  3. Fail-closed is the load-bearing rule. The predicate answers True only when the lockfile positively excludes the collecting host. No declaration, an unrecognised os/cpu value, a negated entry (!win32), an unknown or unparseable host — all answer False and the component stays inside the closure. Wrongly excusing a loss is the expensive mistake; wrongly keeping one costs a re-measurement.

  4. The host is parsed as the structured os/arch that _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_platform is 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-arm64 and -linux-x64 ship 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-x64 appears 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, and fsevents carried 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-arm64 upstream 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_platform records 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-transition scopes the loss check out of python for 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. platform joins scope, licence_source and lockfiles on the record.

Neutral

  • No migration, no flag, no schema version. The platform key 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:

  1. 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.
  2. 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.
  3. 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 in test_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; the platform key would then be recorded and unread, which is inert.
  • Blast radius: generate_notice.py only, 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).