Skip to content

RFC: shared side-effects cache - #20

Draft
zkochan wants to merge 14 commits into
mainfrom
rfc-shared-side-effects-cache
Draft

RFC: shared side-effects cache#20
zkochan wants to merge 14 commits into
mainfrom
rfc-shared-side-effects-cache

Conversation

@zkochan

@zkochan zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member

Makes pnpm's side-effects cache shareable across machines: pnpm may fetch a package's built form instead of running its install scripts, signed either by the organization whose CI produced it or by the package's own publisher. pnpr is the reference implementation; the protocol is specified as an open contract so other registries can implement it.

Tracking issue: pnpm/pnpm#13771. Context: this came out of evaluating pnpm/pnpm#13639, which reaches shared build outputs by delegating materialization to an external packageProvider executable — at the cost of a second install mode that voids the build allowlist, GVS, the hoisted linker, and the Rust fast path. This proposal targets the same need without a second install path.

This description was rewritten at 200c1d4 and updated at c8b84d9. Three review rounds materially changed the design, and the original text asserted several claims the RFC now contradicts. See the thread for the round-by-round record.

The shape of it

Two modes, one mechanism. An artifact is signed either by the organization whose CI produced it, or by the package's own publisher — the same protocol with a different signer and a different party asserting eligibility. Both are in scope. Publisher mode is the more defensible of the two: a consumer already executes that publisher's install script, so accepting a prebuilt artifact signed by the same identity is a smaller grant than arbitrary code execution at install time, and it needs no new trust root. Organization mode is expected to ship first because it is entirely within pnpm and pnpr — a rollout order, not a scope boundary.

What stays out of scope is an anonymous, multi-builder cache where anyone may contribute an artifact for anyone's package. There the residual risk has no owner.

The registry is the default channel; the signature is the authority. Lookup happens at the registry a package resolved from, which reuses per-registry auth, certs, and proxies unchanged. But acceptance turns on the signature, not on which host served the bytes — so a mirror, a proxying pnpr, or a publisher's existing distribution point can serve an artifact the client will accept. Publisher mode therefore does not depend on registry.npmjs.org adopting anything.

Lookup is by input key; correctness is enforced by compatibility tags. The input key is the dependency graph and patches. Platform identity lives in tags the artifact advertises as floors, and the client selects from an ordered set of tags it supports — the wheels model, not exact-match hashing. An artifact built against an older libc therefore stays valid on newer systems.

Requests are batched per channel, in parallel, and fold into the existing POST /-/pnpr/v0/resolve round trip where the client is already talking to pnpr. Candidates are requiresBuild packages only.

What the review rounds changed

Recording these because each replaced something the first draft got wrong:

  • The build-input problem. The dependency-state key is not a function of everything a build depends on and cannot be made one for arbitrary npm scripts — env, compilers, SDKs, system libraries, CPU features, network, clock. libc was one omission, not the blocker. The RFC now bounds rather than closes: a named owner, compatibility constraints, package eligibility.
  • The builder profile is provenance, not part of the key. Putting it in the key fragmented the cache along an axis that mostly doesn't affect correctness — a routine base-image patch would invalidate every artifact under it, in exactly the CI scenario the feature exists for. It is also a producer-side claim, not a verifiable property.
  • Trust is not free. A frozen install pins tarball integrity, so a registry cannot substitute source bytes; nothing pins an artifact. Remote artifacts therefore grant strictly more authority than a registry has today. Hence: separate per-registry opt-in (never a silent widening of sideEffectsCacheRead), signed provenance, and a signing key independent of the registry — a registry signing its own artifacts defends against nothing it isn't already trusted for.
  • Artifact scope is not source visibility. A public source package built by one org still yields an artifact that can reveal builder identity or embed org-specific output. Namespaced by tenant and signing trust domain, with the domain identifier covered by the signature to prevent cross-domain replay.
  • Remote artifacts stay labelled once stored. The store is global; an unlabelled remote entry would be reused after key revocation, after remote artifacts were disabled, by another tenant sharing the store, or for a package now resolving through a different registry. Origin metadata persists and trust policy is applied at every reuse, not only at download.
  • Manifest validation is mandatory before remote paths reach importIndexedDir. The existing sanitizeFilenames is a post-failure compatibility retry, not a security boundary.

Alternatives

External package provider (pnpm/pnpm#13639), standalone cache URL in Turborepo's shape, and adopting the Bazel REAPI.

The REAPI decline is worth flagging because its stated grounds changed. It is not declined on round-trip count — gRPC multiplexes, and batching is now a benchmark question in Unresolved Questions — nor on payload shape or a missing platform/provenance model, all of which were withdrawn as incorrect. It is declined on one sufficient objection: GetActionResult answers "is there a result for precisely this digest" and structurally cannot answer "give me the best artifact compatible with this tag set, signed by a key I trust." A hybrid (REAPI CAS plus a batched lookup extension) is named as the most likely route back.

Open for review

  • Placement. In text/ rather than pnpr/text/ because the substance is a pnpm client feature plus a protocol meant to be openly implementable, and pnpr/ is PolyForm Shield licensed, which would work against that. Easy to move.
  • Cost is now visible. The first draft claimed remote artifacts could feed the existing sideEffectsMaps path so nothing downstream changed. That was false — the type carries only added/deleted, so origin-aware storage and a reuse-time policy check are both real work. Still much smaller than the packageProvider alternative, but not a thin layer over the existing cache.
  • Unresolved Questions are substantive, not filler: artifact discovery in publisher mode (the main unspecified piece), who runs the builds (if pnpr does, it becomes a build service — remote execution, a declared non-goal), who asserts eligibility, key distribution and revocation, and batching-versus-concurrent-lookups as a measurement.

Draft because the endpoint shape is a sketch and the compatibility tag format deserves its own round — that format is set by publisher mode's requirements, since a publisher targets every consumer and must get tag floors right the first time.


Written by an agent (Claude Code, claude-opus-5).

pnpm's side-effects cache is local to one machine, so every CI runner and
every fresh container re-runs node-gyp for the same native module with
byte-identical inputs. The key, the payload format, and the read/write
split already exist; what is missing is a transport and a trust model.

Proposes that a registry may serve the built form of a package it already
serves, fetched from the registry the package resolved from. Attaching
artifacts to registries rather than to a standalone cache service means no
new principal is trusted to write into node_modules, and reuses the
existing per-registry auth, the registry-qualified lockfile keys for
routing, and pnpr's route classification, storage split, and package
rules.

Two prerequisites are called out as blocking. Platform tags: the current
key carries no libc, so a musl and a glibc builder compute the same key
for a from-source build; nothing may cross a machine boundary until that
is fixed. And the build allowlist: a cached side-effect currently bypasses
the allowBuild check, which is an annoyance locally and a hole once the
artifact came from elsewhere.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 521fda0e-000b-497a-a9c2-ab050779872c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The endpoint sketch was per-package, which is the same flaw the REAPI
section was criticising. Cache-hit determination is now one whole-install
request mirroring POST /-/pnpr/v0/resolve, and should fold into an
existing resolve round trip where the client is already talking to pnpr.

Rewrites the REAPI alternative. The previous text claimed REAPI carries no
notion of platform tags or provenance, which is wrong: Platform properties
are explicitly for OS/toolchain and are part of the Action digest,
ExecutedActionMetadata records the producing worker, and the ActionCache
works without the Execution service.

The real objection is performance. ActionCache exposes only
GetActionResult/UpdateActionResult and the request carries a single
action_digest, so cache-hit determination is one round trip per package
with no batch form, reintroducing per-package chatter where pnpm and pnpr
engineered it out. ActionResult also carries a full output tree, scaling
the response with package size rather than build-output size, and the
TypeScript client would need grpc-js bundled. Two non-performance
objections stand: the key would come from a synthesized Command no worker
can execute, and range-vs-exact platform matching is server-dependent,
which a format served by many registries cannot rely on.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Pushed 16b81b8, deciding the REAPI question on performance grounds and fixing a flaw the comparison exposed in the original design.

The endpoint sketch was per-packageGET /-/pnpr/v0/side-effects/<pkgKey>/<sideEffectsKey> — which is precisely the thing the REAPI section was criticising. Cache-hit determination is now a single whole-install request mirroring POST /-/pnpr/v0/resolve, and where the client is already talking to pnpr it should fold into the resolve response so a warm install adds no round trip at all.

The REAPI decline is rewritten, and the previous version was wrong on its main point. It claimed REAPI carries no notion of platform tags or provenance. It does: Platform properties are explicitly for "hardware, operating system, or compiler toolchain" and are implicitly part of the Action digest, ExecutedActionMetadata records the producing worker, and GetActionResult/UpdateActionResult work without the Execution service. The RFC now says so.

The decline stands on round trips instead. ActionCache exposes exactly two RPCs and GetActionResultRequest carries a single action_digest — there is no batched cache-hit lookup. Its CAS batches blob I/O, but the critical-path question is which packages hit, and REAPI answers that one package per round trip. Adopting it would reintroduce per-package chatter exactly where pnpm and pnpr engineered it out, at the tail of the install, scaling with workspace size — and folding the lookup into an existing resolve request is structurally unavailable to a separate service on a separate protocol.

Compounding: ActionResult carries a full output tree, so a native module adding 3 files to an 800-file package moves 800 manifest entries — response size scaling with packages the build never touched. And the TS client would need @grpc/grpc-js bundled, or the HTTP binding, which forfeits the multiplexing that would partly offset the round-trip count.

Two non-performance objections also stand: Action requires a command_digest, but pnpm has no command, so the key would derive from a synthesized Command no worker can execute — making its canonicalization an unintended compatibility surface. And range-vs-exact platform matching is explicitly server-dependent, which is fine for one operator's cluster but not for a format many independent registries serve, where a manylinux-style floor must mean the same thing everywhere.

Left as revisit triggers: a batched ActionCache lookup appearing upstream, or this endpoint drifting toward REAPI's shape anyway.


Written by an agent (Claude Code, claude-opus-5).

Review found the previous draft unsound in three places and factually
wrong in three others.

The dependency-state key is not a function of everything a build depends
on, and for arbitrary npm scripts it cannot be made one: env vars,
compilers, SDKs, system libraries, CPU features, network, and the clock
are all reachable. libc was one omission, not the blocker. Two honest
builders could publish different outputs under one key. Adds a declared
builder profile and a package eligibility contract, and notes that
sandboxing is what actually closes the input set rather than describing
it. This is also where the Nix analogy breaks, since Nix models inputs
and builds restricted.

Floor-based tags cannot be hashed into an exact lookup key: a glibc 2.39
client would never request a 2.17 producer's key. Splits the input key
from advertised compatibility constraints, with wheel-style selection
from the client's ordered supported-tag set.

The trust argument was incomplete. A frozen install pins tarball
integrity, so a registry cannot substitute source bytes; nothing pins an
artifact, so remote artifacts grant strictly more authority than a
registry has today, and onlyBuiltDependencies does not cover accepting
another builder's output. Remote reads become a separate per-registry
opt-in rather than a widening of sideEffectsCacheRead, and provenance is
signed over protocol version, source integrity, input key, constraints,
manifest digest, and builder identity.

Adds mandatory manifest validation before remote paths reach the
importer; the existing sanitizeFilenames helper is a post-failure
compatibility retry, not a security boundary. Batching is now per
registry and parallel, since artifacts belong to the package's registry
and broadcasting all candidates would leak cross-registry dependency
information.

Corrections: the local allowlist bypass was fixed by merged pnpm/pnpm#11039,
so the RFC now requires preserving that gate rather than claiming to fix
it. REAPI's ActionResult does not necessarily carry a full tree, since
Command declares which outputs are captured. And concurrent gRPC RPCs are
not serialized round trips, so raw round-trip count is no longer claimed
as decisive; REAPI is now declined on its exact-digest lookup model being
unable to express compatibility selection, with batching left as a
benchmark question and a hybrid named as the route back.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Reviewed all six points against the code and the specs. All six are valid; three correct factual errors in the draft, two of which were load-bearing for the REAPI decision. Rewritten in 4c01826.

Accepted as design changes

1 — build-input closure. Correct, and the deepest point. The draft said the key "proves" identical inputs; it does not, and for arbitrary npm scripts it cannot be made to. Env vars, compilers, SDKs, system libraries, CPU features, network, and the clock are all reachable from a lifecycle script, so libc was one omission rather than the blocker. Two honest builders could publish different outputs under one key. The RFC now carries a build-input closure section proposing both remedies — a declared, versioned builder profile and a package eligibility contract — and adopts the framing that this is where the Nix analogy breaks, since Nix models inputs and builds restricted. It also connects sandboxing (pnpm/pnpm#13772) as the mechanism that closes the input set rather than merely describing it.

2 — trust. Correct and sharp. In a frozen install the lockfile pins tarball integrity, so a registry cannot substitute source bytes undetected; nothing pins an artifact. Remote artifacts therefore grant strictly more authority than a registry has today, and onlyBuiltDependencies authorizes running a package's own script, not accepting arbitrary output attributed to it. Taken both remedies: remote reads are now a separate per-registry opt-in rather than a silent widening of sideEffectsCacheRead, and provenance is signed, binding protocol version, source integrity, input key, compatibility constraints, manifest digest, and builder identity.

3 — floor semantics vs exact lookup. Correct; the draft was incoherent. It invoked manylinux while designing an exact-match lookup, so a glibc 2.39 client would never request a 2.17 producer's key. Now split into an input key (what was built, including the builder profile) and advertised compatibility constraints, with wheel-style selection from the client's ordered supported-tag set. Thanks for the PyPA reference — cited.

4 — manifest validation. Correct. Worth adding for the record: sanitizeFilenames in importIndexedDir.ts is not a partial defense here — it is a post-failure compatibility retry for badly-named tarball entries, splitting on / and sanitizing per segment. The RFC now requires upfront rejection of absolute paths, .., platform separators, NUL/control characters, case-collisions, unmodelled symlink/special entries, out-of-range modes, and size/count limits, with failure treated as a cache miss — and states your reasoning that this matters more than the tarball equivalent precisely because artifact-publish authority is weaker and more widely delegated.

5 — per-registry batching. Correct. Now grouped by concrete registry and issued in parallel, with the leak argument stated explicitly: broadcasting the full candidate set would expose an organization's cross-registry dependency graph to every registry it talks to.

Corrections

6a — pnpm/pnpm#11035 was fixed by merged pnpm/pnpm#11039. Confirmed: merged 2026-03-21, gate live at building/during-install/src/index.ts:111,141. My error — I had the issue's CLOSED status in hand and wrote about it as open anyway. The RFC now lists the gate under "what already exists" and requires the remote path to preserve it.

6b — ActionResult need not carry a full tree. Confirmed against the proto: Command declares which outputs are captured, so pnpm's added-file set could be declared directly. Claim removed. The residual mismatch is narrower and now stated as such: REAPI outputs are purely additive, with no representation for the deleted paths a side-effects map carries.

6c — concurrent RPCs are not serialized round trips. Correct, and this one mattered most, since round-trip count was the stated basis for the decision. Raw round-trip count is no longer claimed as decisive; it moves to Unresolved Questions as something to benchmark, including at small candidate counts where one request may not pay for itself.

The REAPI decision stands, on different grounds

Point 3 turns out to strengthen the case rather than weaken it. GetActionResult answers "is there a result for precisely this digest" — it structurally cannot answer "give me the best artifact compatible with this ordered tag set", which is the question a floor-based model must ask. Emulating it means enumerating one candidate digest per supported tag and looking each up, multiplying request count by tag-set size, in a service with no batched read at all. That objection survives the concurrency correction; the round-trip one did not.

Three narrower mismatches now stated instead of the withdrawn ones: the synthesized Command whose canonicalization becomes an unintended compatibility surface, the missing deletion representation, and server-dependent range matching, which one operator's cluster can absorb but a format served by many independent registries cannot.

Your hybrid suggestion — REAPI CAS and action cache plus a small batched-lookup extension — is now named in the RFC as the most likely route back to this alternative, alongside a batched ActionCache appearing upstream.


Written by an agent (Claude Code, claude-opus-5).

Second review round. Five findings, all accepted.

Source visibility cannot determine artifact scope. Reusing pnpr's
public/private route classification directly was wrong: a public source
package built by one organization still yields an artifact that can reveal
builder identity, embed organization-specific output, or differ from
another organization's build of the same package. Artifacts are namespaced
by tenant and signing trust domain, and publishing beyond that namespace
is an explicit decision rather than a consequence of the tarball having
been public.

Signer trust was filed as bikeshedding and is actually load-bearing. A
registry that signs its own artifacts defends against nothing it is not
already trusted for, so the signing key must be independent of the
registry; V1 is one organization-configured CI key per registry, held
outside the server, with identifiers, rotation, and revocation. Several
builders producing entries for one input key is normal, so builder
identity participates in variant identity: the registry stores variants
and the client selects one signed by a key it trusts. The response shape
changes accordingly, to a set of variants per candidate.

Blob verification is normative again — it was lost in the previous
rewrite. Clients MUST recompute every blob digest before it reaches CAFS
or the importer, with mismatches quarantined so a poisoned entry is not
re-fetched every install.

Sandboxing alone does not close the input set; only a hermetic sandbox
with declared inputs does, and 13772 leaves network policy open. States
network and CPU feature detection as the residual gaps, and folds in the
container-image builder profile: image digest plus architecture baseline
plus an environment allowlist, with the explicit caveat that it is a
producer-side claim, not a verifiable property. Adds that sharing needs
behavioural equivalence, not bit-identity.

Prunes the REAPI decline to its one sufficient objection. Deletions are
not an obstacle, since a manifest carrying its own deleted list can be one
opaque ActionResult output blob, and server-dependent Platform matching
governs execution placement rather than cache lookup.

Wording: candidates are packages marked requiresBuild, which also covers
implicit node-gyp via binding.gyp and .hooks, plus patched packages; and
artifacts are regenerable derived data rather than reproducible.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

All five accepted, plus both wording points. Pushed e133aee.

1 — source visibility cannot determine artifact scope. Correct, and a real design error rather than an imprecision: I said side-effects entries "reuse RoutePolicy directly", but that classifies whether the source fetch is public, which says nothing about whether a team-built artifact should be anonymous and global. Now separated — the HMAC namespacing mechanism carries over, its input does not. Artifacts are namespaced by tenant and signing trust domain (the same boundary in this design), and publishing beyond that namespace is an explicit decision, never a consequence of the tarball having been public.

2 — signer trust and multi-builder selection. Agreed on both halves, and agreed they were misfiled as bikeshedding. A registry signing its own artifacts defends against nothing it isn't already trusted for and degrades the signature to a transport checksum — that's now stated as the load-bearing constraint in the trust section, with your V1 adopted verbatim: one organization-configured CI signing key per registry, held outside the registry server, with key IDs, rotation, and revocation defined up front.

The multi-builder point had a consequence I hadn't seen: it changes the response shape. A response now carries a set of variants per candidate, and the client selects on compatibility and signer trust. That also composes with the tag model — selection was already client-side, so signer trust is a second axis on the same step rather than new machinery.

3 — blob verification. Correct, and it was a regression: v1 had "verified against their digests exactly as store content is today" and I dropped it while restructuring. Now normative — clients MUST recompute every blob digest before it reaches CAFS or the importer, since the signed manifest attests the digests but nothing attests the served bytes match them. Mismatch is a miss plus a diagnostic, with the entry quarantined so the same poisoned artifact isn't re-fetched on every install.

4 — hermetic, not merely sandboxed. Correct. Now "a hermetic sandbox with declared inputs", with the reason spelled out: 13772 is about restricting authority and leaves network policy open, and a sandbox permitting network closes nothing. Network is named as the largest residual gap (node-gyp headers, prebuild-install binaries), CPU feature detection as the second.

This also let me fold in the container-profile question that prompted the round: profile = image digest + architecture baseline + environment allowlist, since CC/CXX/CFLAGS/npm_config_* still reach a build an image alone doesn't constrain. Stated with the caveat that it is a producer-side claim, not a verifiable property — which is precisely why your point 2 is load-bearing. Also added explicitly that sharing requires behavioural equivalence, not bit-identity, which is what makes this tractable where full reproducibility isn't.

5 — REAPI objections. Both concessions correct. Deletions dissolve, since a manifest carrying its own deleted list can be a single opaque ActionResult output blob — CAS contents are uninterpreted. And Platform matching governs execution placement, not cache lookup, so it was misapplied. Pruned to the one sufficient objection: exact-digest lookup versus compatibility selection, now stated as "compatible with this ordered tag set, signed by a key I trust", which your point 2 makes strictly harder for an exact-digest cache to answer. The synthesized-Command mismatch is retained but explicitly marked not load-bearing.

Wording. requiresBuild adopted — confirmed against pkgRequiresBuild, which is scripts or binding.gyp or .hooks/, so "install scripts" did undercount; patched packages added alongside, since they take the same build path. And "regenerable derived data" adopted over "reproducible".

One new unresolved question falls out of the container profile: an image digest may be too fine. A base-image security patch changes it and invalidates every artifact built under it, though almost nothing relevant to the build changed. Some notion of profile equivalence or lineage may be needed for hit rates to stay usable. Also recorded: whether pnpr ever builds, since that would make it a build service — remote execution, a declared non-goal, and REAPI's actual domain.


Written by an agent (Claude Code, claude-opus-5).

Putting the builder profile in the input key was over-engineering
inherited from a scope this RFC had already declined. It fragments the
cache along an axis that mostly does not affect correctness: a routine
base-image security patch changes the digest and invalidates every
artifact built under it, in exactly the CI scenario the feature exists to
serve. It is also a producer-side claim rather than a verifiable
property, so it could never have carried the weight of a correctness
gate.

Turborepo, Nx, and moon model the build environment not at all. They can
omit it because their caches are team-scoped and their artifacts
first-party, so the organization already owns correctness and nobody is
defending against another organization having built something wrong.
This RFC had already committed to the same scope and should have taken
the same consequence.

The profile becomes signed provenance, kept for debugging, incident
response, and optional policy. Correctness moves entirely to the
compatibility tags, which express floors and therefore stay valid when a
builder's image is patched. The input key is the dependency graph and
patches; platform identity leaves it for the tags.

Reframes the section from closing the input set to bounding it, via three
means: a trust domain that owns the residual risk, compatibility
constraints as the hard gate, and package eligibility for what is
inherently unshareable. Hermetic sandboxing stays as the thing that would
let the trust domain widen later, not as a prerequisite.

Resolves the profile-granularity question rather than answering it.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Pushed 73cbdf0. The builder profile moves out of the cache key and becomes signed provenance; correctness moves entirely to the compatibility tags.

Why. Putting the profile in the input key was over-engineering inherited from a scope this RFC had already declined. It fragments the cache along an axis that mostly does not affect correctness — a routine base-image security patch changes the digest and invalidates every artifact built under it, in exactly the CI scenario the feature exists for. And it is a producer-side claim, not a verifiable property, so it could never have carried the weight of a correctness gate anyway.

The precedent is the three tools in Prior Art: none of them models the build environment at all. They can omit it because their caches are team-scoped and their artifacts first-party — the organization already owns correctness and nobody is defending against another organization having built something wrong. This RFC had already committed to that same scope in declining a public cross-organization cache, and should have taken the same consequence.

What changed:

  • The section is reframed from closing the input set to bounding it, by three means: a trust domain that owns the residual risk, compatibility constraints as the hard gate, and package eligibility for what is inherently unshareable.
  • The input key is now the dependency graph and patches. Platform identity leaves it entirely for the tags, which express floors — so an artifact built against an older libc stays valid on newer systems, including when the builder's own image is patched. That is the same mechanism resolving the granularity problem, rather than a workaround for it.
  • The profile still travels in the signed block for debugging, incident response, and optional organizational policy.
  • Hermetic sandboxing is repositioned as what would let the trust domain widen later, not as a prerequisite.

This resolves the profile-granularity question from the last round rather than answering it — that entry is removed from Unresolved Questions.

The security work from rounds one and two is untouched: independent signing key, tenant and signing-trust-domain namespacing, multi-builder variant selection, normative blob verification, manifest validation, and the preserved allowBuild gate all stand. The build-input analysis also stands; it is now a scoping argument for eligibility and trust domain rather than something enforced by fragmenting the key.


Written by an agent (Claude Code, claude-opus-5).

Third review round. Four findings, all accepted.

A verified artifact must not become indistinguishable from a locally
built one once stored. The store is global and shared across every
project on a machine, so an unlabelled remote entry would be reused after
its signing key was revoked, after remote artifacts were disabled, by a
different project or tenant sharing the store, or for a package that now
resolves through a different registry — the last defeating the tenant
namespacing this design depends on. sideEffectsMaps carries only added
and deleted, so remote entries cannot be fed into it and forgotten. They
retain origin metadata (trust domain, registry, signer key id, builder
profile, signed envelope, verification status) and trust policy is
applied at every reuse rather than only at download. The implementation
section no longer claims nothing downstream changes, because it does.

The profile binds environment values, not names: CFLAGS=-march=x86-64 and
CFLAGS=-march=native pass the same name allowlist and are not
interchangeable. It is a canonical name-to-value map with undeclared
variables denied, and secret-bearing variables denied rather than
recorded, since provenance is served to clients. A flag like
-march=native makes the artifact's real requirements unknowable from the
environment, which is a compatibility-tag problem: such a build declares
a narrower architecture tag or is ineligible.

Patch-only packages are no longer candidates. They conflicted with the
allowBuild requirement, which has nothing to approve for a package with
no build; the patch hash rides the input key when a requiresBuild package
is also patched, and patch overlays are cheap to apply locally.

Hardening: the signature now covers the tenant/trust-domain identifier so
an artifact cannot be replayed across domains, and the client caps
variants per candidate and total response size, since the response is
untrusted input parsed before any signature is checked.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

All four accepted, pushed in 66fe1c7. Checked each against the current draft first — the builder-profile change in 73cbdf0 landed after this round was written, and it alters one of them rather than voiding it.

1 — remote provenance must survive local caching. Fully valid, entirely unaddressed by the intervening change, and the sharpest finding across the three rounds. The store is global and shared across every project on a machine, so the fourth case listed is the worst: an unlabelled remote entry reused for a package that now resolves through a different registry defeats the tenant namespacing that the previous round established as the whole point.

The RFC now has a Remote artifacts stay labelled once stored section: origin metadata persists (trust domain, registry, signer key ID, builder profile, signed envelope, verification status), and trust policy is applied at every reuse, not only at download — so a revoked key, a registry no longer opted in, or a mismatched trust domain causes the artifact to be discarded and the package built locally. Implementation choice between fields-on-entries and a separate remote index is left open, since the requirement is the invariant, not the layout.

This also retires a claim I had been leaning on. The implementation section said remote artifacts feed the existing sideEffectsMaps path "so nothing downstream changes" — that was the appeal of the design and it is not true. Corrected to say the type carries only added/deleted and both the storage and the reuse path need real work.

2 — profile must bind values, not names. Valid, and 73cbdf0 changes its consequence rather than removing it. The profile is no longer in the input key, so -march=native versus -march=x86-64 can't fragment the cache — but it can produce an artifact whose real requirements aren't knowable from the environment, which is now a compatibility-tag problem: such a build declares a correspondingly narrow architecture tag or is ineligible. The profile is a canonical name-to-value map with undeclared variables denied.

Your point about sensitive variables gets stronger under the new design, not weaker: there's no key to hash them into, but provenance is served to clients, so a name allowlist that merely permits them would leak them. They're now denied rather than recorded.

3 — patched-only candidates. Correct, and it was my inconsistency: I added patched packages in round two from the requiresBuild || patch != null build path, then in the same round required allowBuild for every remote artifact — which has nothing to approve for a package with no build. Adopted your fix: candidates are requiresBuild only, patch hash rides the input key when such a package is also patched, and patch-only overlays stay local since applying them is cheap.

4 — hardening. Both taken. The signature now covers the tenant/trust-domain identifier, which closes cross-domain replay and is the natural completion of the previous round's namespacing finding. Variants per candidate and total response size are capped, with the reason stated: the response is untrusted input parsed before any signature is checked.


Written by an agent (Claude Code, claude-opus-5).

Compatibility tags were described as making an artifact safe to use. They
are a signed producer assertion constraining where it may be used; safety
rests on the trusted builder and the eligibility contract.

A trust-policy rejection now says the artifact is not reused for this
install rather than discarded. The store is global, so another project may
still legitimately trust the same artifact, and deletion would be wrong.
This is distinct from the digest-mismatch path, where quarantine remains
correct because the served bytes did not match the signed manifest.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Both taken, pushed in 200c1d4.

Tags constrain use, they don't confer safety. Correct, and the previous phrasing quietly undercut the section it sits in — the whole point of "bounding rather than closing" is that safety rests on the trusted builder and the eligibility contract. Now: the signature makes the claim attributable; the tags are a signed producer assertion constraining where an artifact may be used.

"Not reused for this install" rather than "discarded." Correct, and the more consequential of the two given the store is global — the old wording would have licensed an implementation that deletes an artifact another project still legitimately trusts, turning a per-project policy decision into machine-wide collateral damage.

Worth noting the distinction this creates, which the RFC now keeps explicit: trust-policy rejection is scoped non-reuse, while the digest-mismatch path stays quarantine-and-evict. Those are different failures — one is "valid artifact, wrong context for me", the other is "the bytes served did not match the signed manifest", and only the second is evidence of something poisoned.


Written by an agent (Claude Code, claude-opus-5).

The exclusion of a public cross-organization cache was too broad. It
rested on there being no party who owns correctness, which is false when
the package's own publisher is the one publishing its built forms — the
wheels arrangement, where PyPI serves sdists and wheels under one
identity.

That case is more defensible than the team cache, not less. A consumer
who installs a package already executes that publisher's install script,
so accepting a prebuilt artifact signed by the same identity is a smaller
grant than arbitrary code execution during install. It also needs no new
trust root, since the identity authenticating the artifact is the one
already authenticating the tarball. And the ecosystem already does this
through prebuild-install and node-pre-gyp, without integrity pinning,
signatures, or offline support, which is both evidence the demand is real
and an argument that a first-class mechanism would replace something
worse rather than add surface.

It stays the second step for two reasons that are about sequencing rather
than design: it depends on registry adoption pnpm does not control, and
it demands exact compatibility tags where a team cache can be approximate
and self-correct.

The V1 decision that keeps the door open is the signer becoming a
pluggable trust root. The organizational CI key is now the only kind V1
defines rather than an assumption baked into the protocol shape, since
baking it in would force a breaking change to add publisher identity
later.

What remains declined is the anonymous multi-builder cache, where anyone
may contribute an artifact for anyone's package and the residual risk has
no owner.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Pushed 9c868db. The public-cache exclusion was too broad and is now scoped to the case it was actually about.

What changed. The RFC said a public cross-organization cache is out of scope because "the residual risk has nowhere to land." That is false when the package's own publisher publishes its built forms — the publisher is precisely the owner. It is the wheels arrangement the RFC already cites for the tag design, now applied to the trust model too.

Four edits:

  1. The exclusion is narrowed to an anonymous, multi-builder public cache, where anyone may contribute an artifact for anyone's package. That case genuinely has no risk owner. A public cache with a named owner is now pointed forward rather than ruled out.
  2. New section: "Publisher-published artifacts: the intended second step." It argues the case is more defensible than the team cache, not less — a consumer who installs a package already executes that publisher's install script, so accepting a prebuilt artifact signed by the same identity is a smaller grant than arbitrary code execution at install time. It also needs no new trust root, since npm provenance and OIDC trusted publishing already authenticate that identity for the tarball.
  3. The signer becomes a pluggable trust root. This is the one change with a deadline, so it is made now rather than deferred: the organizational CI key is the only kind V1 defines, not an assumption baked into the protocol shape. Baking it in would force a breaking change to add publisher identity later.
  4. Prior Art gains prebuild-install / node-pre-gyp / prebuildify, which are the closest prior art of all — npm already has a publisher-published binary cache, implemented as postinstall scripts fetching from GitHub Releases or S3. Ubiquitous enough to prove the demand, and with no lockfile integrity pinning, no signatures, a hard network requirement at install time, and failure under --ignore-scripts and offline installs. A first-class mechanism would replace it rather than add surface.

Why it is still the second step, stated as sequencing rather than design objection: it depends on registry.npmjs.org accepting and serving artifacts, which pnpm does not control, whereas the team cache ships with pnpm and pnpr alone. And a publisher targets every consumer, so tag floors must be right the first time — manylinux's problem at full difficulty — where a team cache can be approximate and corrected in an afternoon.

New unresolved question, which is the real open decision: how much of the publisher path to design against now. The trust root is pluggable, so identity is safe to defer — but whether the tag format and eligibility assertion should be designed against the harder publisher case from the start, or shaped by the team cache and generalized later at the risk of a format that does not stretch, is not settled.


Written by an agent (Claude Code, claude-opus-5).

Organization-signed and publisher-signed artifacts are both in scope. They
are the same protocol with a different signer and a different party
asserting eligibility, so the RFC now specifies both rather than treating
the publisher case as a later extension.

The sequencing argument for deferring it was weaker than stated. Publisher
mode was said to depend on registry.npmjs.org accepting and serving
artifacts, but with publisher signing the distribution channel does not
have to be trusted: the signature is the authority, so a mirror, a
proxying pnpr, or the publisher's existing distribution point can serve an
artifact the client accepts. What actually remains unspecified is
discovery — how a client learns where a publisher's artifacts live — which
replaces registry adoption in the open questions.

Consequences settled here rather than deferred: the pluggable trust root
defines organization keys and publisher identity as peers, neither
privileged; and the compatibility tag format is designed against publisher
mode, since a publisher targets every consumer and must get floors right
the first time, where a team cache can be approximate and corrected in an
afternoon. A format shaped by the easier case would not stretch.

Reframes the trust-domain argument as a named owner, which both modes
supply, and separates the registry as default channel from the signature
as authority. Organization mode is still expected to ship first, as a
rollout order rather than a scope boundary.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Pushed c8b84d9. Both modes are now specified, rather than one plus a future direction. Description updated to match.

The sequencing argument I gave for deferring publisher mode was weaker than I wrote. I claimed it depends on registry.npmjs.org accepting and serving artifacts. But with publisher signing the distribution channel does not have to be trusted — the signature is the authority, so a mirror, a proxying pnpr, or the publisher's existing distribution point (which for prebuild-install users is already GitHub Releases) can serve an artifact the client accepts. That is how Nix substituters and distribution mirrors work.

So npm adoption is a convenience dependency, not a possibility one. What replaces it as the real gap is discovery — how a client learns where a publisher's artifacts live — now the main unspecified piece of publisher mode in Unresolved Questions.

Two consequences are settled here rather than deferred:

  • Both trust roots are peers. Organization keys and publisher identity are defined together, neither privileged in the protocol.
  • The tag format is designed against publisher mode. This is the one with a deadline. A publisher targets every consumer and must get floors right the first time; a team cache can be approximate and corrected in an afternoon. A format shaped by the easier case would not stretch, so the harder case sets the requirements and organization mode simply uses something more precise than it strictly needs.

Two reframings fell out, both of which I think improve the document independently:

  • The bounding argument is now "a named owner who accepts the residual risk", which both modes supply, rather than "a trust domain", which only described organization mode.
  • The registry is the default channel; the signature is the authority. These were conflated. Registry attachment is what makes auth, certificates, proxies, and the registry-qualified lockfile keys work unchanged — but acceptance turning on the signature is what lets an artifact arrive from a mirror or a publisher's own distribution point. Separating them is what makes publisher mode work without a second transport.

Organization mode is still expected to ship first, since it is entirely within pnpm and pnpr and is a smaller surface on which to prove the protocol. That is now stated as a rollout order rather than a scope boundary.

What remains out of scope is the anonymous, multi-builder cache, where anyone may contribute an artifact for anyone's package and the residual risk has no owner.


Written by an agent (Claude Code, claude-opus-5).

Fourth review round. The verdict was that c8b84d9 specified organization
machinery plus publisher intent, which was accurate.

Owner scope is now an explicit tagged union rather than a tenant
identifier. An organization artifact is scoped to a trust domain and
never shared across domains; a publisher artifact is scoped to a package
identity and shared with every consumer that trusts the publisher.
Collapsing both into one tenant field, as the previous draft did, cannot
represent a publisher artifact at all. The signed envelope binds the
owner scope, storage namespaces by it, and local origin metadata records
it. The section defining it moves ahead of Trust, Fetching, and local
storage, all of which now depend on it.

Adds the immutability asymmetry, which was missing entirely. A tarball is
immutable and lockfile-pinned, but an artifact can be published long
afterwards, so a credential compromised in 2027 can attach new binaries
to a version published in 2026 whose recorded integrity still verifies.
Resolved in layers: pin the accepted manifest digest in the lockfile on
first use, which restores immutability for anything a project has
actually used, and acknowledge the unpinned case as mutable authority
that opting in grants — the same property PyPI has for wheels. An
artifact index digest in immutable release metadata would be stronger but
forecloses adding a platform after release, so it is noted as the format's
room to grow rather than proposed.

Corrects the claim that publisher mode needs no new trust root. It reuses
an existing identity anchor, but trusted publishing authenticates CI to
the registry and provenance attests a package build, so artifacts need a
new attestation subject and an authorization policy. Added as an
unresolved question.

Generalizes transport language that still assumed a registry: the opt-in
attaches to the owner whose signature is trusted rather than to the host
serving bytes, handshake is per channel with publisher channels needing
none, and pnpr storage namespaces by owner scope and may serve
publisher-owned artifacts it did not accept for publication.

Two corrections. Prebuilt artifacts are not "inert data" — they are not
executed during installation, but native binaries and generated
JavaScript run later. And prebuildify bundles binaries inside the
published tarball rather than downloading them, which makes it evidence
for the opposite point: it buys correctness by shipping every platform to
every consumer, which is the trade a signed artifact channel removes.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Verdict accepted — "organization machinery plus publisher intent" was exactly right. Pushed 76099bc.

Owner scope is now a tagged union, not a tenant identifier:

  • organization — scoped to a trust domain, never shared across domains.
  • publisher — scoped to a package identity, shared with every consumer that trusts the publisher, explicitly not tenant-scoped.

The two members behave oppositely on purpose: an organization artifact escaping its domain is a bug, a publisher artifact reaching every tenant is the point. The signed envelope binds owner scope, storage namespaces by it, and local origin metadata records it.

I also moved the modes section ahead of Trust, Fetching, and local storage, since all three now depend on the union. Defining it after them was part of why the draft read as one mode with the other bolted on.

Immutability was missing entirely, and it is the most serious of the four. A tarball is immutable and lockfile-pinned; an artifact can be published long afterwards, so a credential compromised in 2027 attaches new binaries to a version published in 2026 whose recorded integrity still verifies perfectly. Nothing in the lockfile changes and the signature is valid.

Taken in layers rather than picking one of your two options:

  • Pin on first use. The accepted manifest digest goes in the lockfile alongside tarball integrity, so frozen installs require that artifact and a later substitute is rejected without re-litigating trust. Restores immutability for everything a project has actually used.
  • Explicit trust for the unpinned case. A platform not yet seen has no pinned digest — a contributor on a new OS, or a publisher legitimately adding a target after release. Accepted on signature alone, which is mutable authority, and the RFC says so rather than implying otherwise. Same property PyPI has for wheels.

Your artifact-index-digest option is stronger than both, and I have recorded why it is not proposed: it forecloses adding a platform after release, which publishers legitimately need. It is noted as the room the format should leave rather than as a rejected idea.

"No new trust root" corrected. It reuses an identity anchor; trusted publishing authenticates CI to the registry and provenance attests a package build, so artifacts need their own attestation subject and an authorization policy for who may attest on a package's behalf. Now stated in place and added to Unresolved Questions.

Stale transport language swept. The opt-in attaches to the owner whose signature is trusted rather than the host serving bytes; handshake is per channel, with publisher channels needing none since acceptance is by signature; pnpr namespaces storage by owner scope and may serve publisher-owned artifacts it did not accept for publication.

Both corrections taken. "Inert data" was wrong — replaced with "not executed during installation", noting native binaries and generated JS run later. And prebuildify was misdescribed; verified against its README, it bundles binaries in the published tarball. That makes it evidence for the opposite point, so it now stands separately: it buys correctness by shipping every platform's binaries to every consumer, and that trade — bundle everything or fetch unverifiably at install time — is precisely what a signed artifact channel removes. A better argument for the design than the one it replaced.


Written by an agent (Claude Code, claude-opus-5).

Caching a workspace's own task outputs, in the manner of Turborepo, Nx,
and moon, is the obvious later use of this machinery. It is not proposed
and nothing here depends on it, but most of what this RFC specifies is
general and a few decisions would foreclose the reuse if made carelessly.

Transfers unchanged: the added/deleted manifest over CAS blobs, where a
task output is the degenerate case of a file set with no base tree; the
batched per-channel lookup and its folding into an existing round trip;
signed provenance; owner scope, where a task cache is always organization
and the publisher arm goes unused; trust policy at every reuse; blob
verification; and manifest path validation, which matters more there
because task outputs land in a working tree. The build-input argument is
the same one Turborepo's inputs and env declarations answer.

New is only the key: this RFC derives one from the lockfile, while a task
key is source content, task config, environment, and upstream task keys.
That is a different key producer feeding the same storage and transport.

Three constraints follow, cheap now and breaking later, so they are stated
as decisions: entry keys are opaque strings and must not embed package
identity in the request or response shape; compatibility tags are
optional, with absence meaning no constraint rather than no information;
and the endpoint is named for the mechanism rather than for this
application. The first two are also stated where the protocol is
described, not only in the note.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Added a Forward compatibility with a workspace task cache section in 352a8d1, plus the two constraints it implies stated inline where the protocol is described rather than only in the note.

Context: caching a workspace's own task outputs — the Turborepo/Nx/moon job — is the obvious later use of this machinery. It is not proposed here and nothing depends on it, but most of what this RFC specifies is general, and a few decisions would foreclose the reuse if made carelessly. Recording them as decisions rather than leaving them to accident.

What would transfer unchanged, which is most of it: the {added, deleted} manifest over CAS blobs — a task output is the degenerate case, a file set with no base tree; the batched per-channel lookup and its folding into an existing round trip; signed provenance; owner scope, where a task cache is always organization and the publisher arm goes unused; trust policy re-evaluated at every reuse; blob verification and quarantine; and manifest path validation, which matters more there since task outputs land in a working tree rather than the store. The build-input argument is also the same one Turborepo's inputs/env declarations exist to answer.

What is new is only the key. This RFC derives one from the lockfile; a task key is source content, task config, environment, and upstream task keys. That is a different key producer feeding the same storage and transport — additive, not a change to anything specified here.

Three constraints, cheap now and breaking later:

  1. Entry keys are opaque strings. The protocol must not embed package identity in the request or response key shape — a request assuming (package, version) cannot carry (project, task, hash). This is the one that would actually block reuse, so it is now stated in the Fetching section too.
  2. Compatibility tags are optional. Load-bearing for a native module, vacuous for a platform-independent build. Absence means "no constraint", not "no information" — also now stated where tags are defined.
  3. The endpoint is named for the mechanism, not this use. side-effects names one application of a general artifact cache; either the name generalises or a task cache adds a second endpoint doing the same thing for no reason.

One byproduct worth knowing, recorded as such rather than as a goal: because pnpm's store is content-addressed at file granularity, a task cache built on it would deduplicate identical files across tasks, packages, and dependency artifacts in a single store. Turborepo archives each task output separately, so a file present in ten outputs is stored ten times. That advantage comes from infrastructure pnpm already has, not from anything designed here.


Written by an agent (Claude Code, claude-opus-5).

The pin covered only the manifest digest, which leaves the artifact's
claims about itself unprotected. Compatibility tags, owner scope, and
provenance are signed alongside the manifest rather than being part of it,
so a compromised signer could re-sign identical bytes with broadened
compatibility — an artifact honestly tagged glibc >= 2.31 reissued as
glibc >= 2.17, and thereafter selected on systems where it fails. The pin
is now the digest of the complete signed envelope, keyed by input key and
owner. Because a lockfile is shared across platforms, it is a set: one
variant per platform in use, accepted only if its envelope digest is
among them.

Answers the two questions the pin left open. Pins accrue on
lockfile-writing installs; a frozen install records nothing and accepts an
unpinned platform on signature alone, since refusing would disable the
feature exactly where it is most valuable. An unavailable pinned artifact
falls back to a local build with a diagnostic rather than failing, with
the caveat that a consumer who cannot build at all still fails, later and
less clearly.

Resolves a contradiction between owner-based trust and channel-based
rejection. Reuse turns on the owner: the channel determines where a client
looks and the signature determines what it accepts, so an artifact does
not become untrustworthy because the registry it arrived through is no
longer configured. The channel is retained for diagnostics only.

Withdraws the standalone-cache rejection. Once channels are
non-authoritative, a dedicated cache service is simply another channel and
the design already permits arbitrary ones; the earlier objection that it
introduced a new principal does not survive signature-based acceptance.
What remains is a question of default, not of permission.

Also drops the stale claim that the design stays inside a single trust
domain, which publisher mode ended.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

The blocking issue is fixed and the three clarifications answered, in 4b0e23b. (Review was against 76099bc; the intervening commit only added the forward-compatibility section, so everything still applied.)

The pin is now the digest of the complete signed envelope, keyed by input key and owner. The attack was real and I had missed it: compatibility tags, owner scope, and provenance are signed alongside the manifest rather than being part of it, so pinning the manifest digest alone leaves a compromised signer free to re-sign identical bytes with broadened claims — an artifact honestly tagged glibc >= 2.31 reissued as glibc >= 2.17 and thereafter selected on systems where it will fail. The pin has to fix what an artifact claims about itself, not only what it contains.

Your "multiple platform variants allowed" point is in as well, and it matters more than it first looks: a lockfile is shared across platforms, so the pin is a set — one variant per platform any contributor or runner has used, accepted only if its envelope digest is among them. A single-valued pin would have made the lockfile platform-specific.

Frozen install with no pin. Pins accrue on lockfile-writing installs; a frozen install records nothing and accepts an unpinned platform on signature alone. So pins for the platforms a team develops on are committed by ordinary installs and protect CI thereafter, while a platform only ever seen in CI stays unpinned. I considered refusing unpinned artifacts on frozen installs and rejected it in the text: it would disable the feature exactly where it is most valuable.

Unavailable pinned artifact. Falls back to a local build with a diagnostic rather than failing — an artifact is an optimization over building, and the package is still buildable. Stated with the honest caveat that a consumer who cannot build at all, such as a container with no toolchain, still fails, only later and with a worse message than a dedicated error would give.

Owner versus channel. Real contradiction, resolved toward the owner. Reuse turns on who signed it; the channel determines where a client looks and the signature determines what it accepts, so an artifact does not become untrustworthy because the registry it arrived through is no longer configured. Channel is retained in origin metadata for diagnostics only, and that is now said explicitly where the metadata is listed.

Both stale passages. The single-trust-domain claim in Prior Art is replaced with the named-owner framing that publisher mode requires.

The standalone-cache rejection is withdrawn rather than updated, which I think is the right consequence of channels being non-authoritative: a dedicated cache service is simply another channel, and the design already permits arbitrary ones. The old objection — that it introduces a new principal permitted to write into node_modules — does not survive signature-based acceptance, and the RFC now says so. What remains is a question of default rather than permission: registry attachment gets tokens, certificates, proxies, and lockfile-recorded origins for free, so it is the better default, and a standalone service is what a user configures when they have one.


Written by an agent (Claude Code, claude-opus-5).

… union

A flat set of envelope digests cannot distinguish a platform that was
never pinned, where an unpinned artifact should be accepted, from a
platform whose pinned artifact was replaced, where the substitute must be
rejected. Both look like "this digest is not in the set". The pin is now a
map from a canonical consumer platform fingerprint to an envelope digest.
The fingerprint is computed from the consumer's own platform identity
rather than from an artifact's tags, since tags are floors and several
artifacts may match one consumer, so only the consumer's identity is a
stable key.

Compatibility constraints become an explicit union of universal and
tagged. The previous "tags are optional, absence asserts no constraint"
made missing data mean universal compatibility, which is absence of
information becoming permission. Absent, malformed, or unknown-vocabulary
constraints are now a miss.

Opaque entry keys gain domain separation by artifact kind and version, and
the signed envelope binds the kind, so a signature issued for one kind
cannot be replayed as another. Without both, a party able to publish one
kind could target the other's keyspace.

Records that a future task cache needs declared output-root enforcement:
path validation is sufficient for dependency artifacts, which land in a
directory pnpm owns, but not for task outputs, which land in the user's
working tree.

Also drops the stale claim that V1 is scoped to one trust domain, which
publisher mode ended.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

All of it applied in 9f38471, including the two blocking items.

Platform-aware pin mapping. The flat set was genuinely broken and the counterexample is exact: a never-pinned macOS and a Linux whose pin was replaced both present as "this digest is not in the set", and they must resolve oppositely. The pin is now a map from a canonical consumer platform fingerprint to an envelope digest.

The part worth calling out: the fingerprint is computed from the consumer's own platform identity, not from an artifact's advertised tags. Tags are floors, so several artifacts can match one consumer and the tags are not a stable key — only the consumer's identity is. Lookup then has three unambiguous outcomes: fingerprint present pins that platform and rejects any other envelope; fingerprint absent means unpinned and the unpinned rules apply. Two consumers legitimately pinning the same digest under different fingerprints costs an entry and buys exactly the distinction that was missing. The canonical form is now called out in Implementation as part of the lockfile format, stable across both stacks.

Compatibility as an explicit union — and this was a bug I introduced. The forward-compatibility commit wrote "tags are optional; an artifact with none asserts no constraint", which makes missing data mean universal compatibility. That is absence of information becoming permission, in the one field that decides whether an artifact may run on a machine. Now universal | tagged, with a positive assertion required for the platform-independent case, and absent, malformed, or unknown-vocabulary constraints treated as a miss. Thank you for catching that — it was worse than the thing it was trying to fix.

Domain separation and kind binding. Keys are prefixed dependency-side-effects:v1:…, and the signed envelope binds the artifact kind, so a signature issued for one kind cannot be replayed as another. Stated both at the protocol description and in the forward-compatibility constraints, since the reason only becomes visible once a second kind exists.

Declared output roots recorded as a task-cache constraint, with the reason: dependency artifacts land in a directory pnpm owns, task outputs land in the user's working tree, so well-formed relative paths are necessary but not sufficient there.

Stale V1 single-domain claim in Prior Art replaced with the named-owner framing.

The three remaining unresolved questions — attestation subject and authorization policy for publisher mode, artifact discovery, key distribution and revocation — are unchanged and still honestly scoped as needing their own rounds.


Written by an agent (Claude Code, claude-opus-5).

Three follow-ups from review.

The forward-compatibility section still said compatibility tags are
optional and that a missing field means no constraint, contradicting the
explicit union added in the previous commit. Platform-independent output
asserts universal; omission is never unconstrained.

Defines the platform fingerprint as a hash over the consumer's
canonically ordered supported-tag set plus the tag-schema version, rather
than an informal OS/architecture tuple that would omit Node ABI, libc,
CPU baseline, and any dimension added later. Defining it over the tag
vocabulary means the fingerprint gains dimensions exactly when the
vocabulary does, and the schema version turns a vocabulary change into a
visible repin instead of a silent collision. The canonicalization is part
of the lockfile format and must be byte-identical across both stacks.

Adds the missing repin workflow. A pinned fingerprint rejects every other
envelope, which left no way to accept a legitimately different artifact
after signer rotation, cache eviction, or a corrected build. Three paths
now exist: the input key changes and a fresh pin is recorded with no user
action; the tag-schema version changes and repins as a visible diff; or
the user asks explicitly through a lockfile-writing operation that drops
the selected pins. Silent automatic repinning stays excluded, since a pin
any install may rewrite protects nothing. A revoked signer needs no repin
to be safe — trust policy rejects its artifacts before the pin is
consulted — so repinning restores the cache rather than safety.

Written by an agent (Claude Code, claude-opus-5) at the request of the
repository owner.
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

All three in f605874. Thanks for the review — six rounds is what turned this from a sketch into something specifiable.

The contradiction. Fixed. The forward-compatibility bullet still carried the old "optional tags" phrasing I had already replaced at the protocol definition — the exact hazard of stating a rule twice. It now says platform-independent output asserts universal, and explicitly that a task cache must not reintroduce a third implicit state.

Fingerprint definition. Taken as specified: a hash over the consumer's canonically ordered supported-tag set plus the tag-schema version. Your reasoning for it is the part worth preserving, so it is in the text — an informal OS/arch tuple silently omits Node ABI, libc, CPU baseline, and anything added later. Defining the fingerprint over the tag vocabulary means it gains dimensions exactly when the vocabulary does, and the schema version turns a vocabulary change into a visible repin rather than a silent collision. Implementation now notes the canonicalization must be byte-identical across both stacks, since divergence would have the two produce different pins for the same machine.

Repinning. A real gap — the pin had no exit. Three paths now, and only the third is a repin proper:

  • the input key changes (dependency update, new patch) and a fresh pin is recorded with no user action, which covers the ordinary case;
  • the tag-schema version changes, repinning everything as a visible diff;
  • the user asks, via a lockfile-writing pnpm install --refresh-artifact-pins, optionally filtered, that drops the selected pins so the next acceptance records new ones.

Silent automatic repinning is excluded in the text with the reason: a pin any ordinary install may rewrite protects nothing. And one clarification that fell out while writing it — a revoked signer needs no repin to be safe, since trust policy rejects its artifacts before the pin is consulted and the package builds locally. Repinning restores the cache, not safety. Worth stating, because the opposite reading would make revocation look like it requires an urgent lockfile change.

The three unresolved questions — attestation subject and authorization policy for publisher mode, artifact discovery, key distribution and revocation — are unchanged, and each needs its own round before this is implementable.


Written by an agent (Claude Code, claude-opus-5).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant