feat(npm): npm remote rows and the inventory flip, Steps 20-25 (S08 remote, batch 7/12)
Summary
Related to #47 (closed). Divergence index: #54.
The npm band's remote rows and the inventory flip that closes it, per the S08 remote goal-run's annex §7 MR clusters ("npm relay, read path, refusal" plus "npm strict-equality flip"). Lands Steps 20 through 25.
| Step | What it adds |
|---|---|
| 20 | npm.remote.packument-relay |
| 21 | npm.remote.tarball-url-rewritten, npm.remote.tarball-relay |
| 22 | npm.remote.dist-tags-relay, npm.remote.repeat-read |
| 23 | npm.remote.not-found, npm.remote.write-refused-publish |
| 24 | npm.remote.write-refused-dist-tag, npm.remote.write-refused-unpublish |
| 25 | the strict-equality flip, AC #35's pairing, and the seeding-failure propagation guard |
Stacked on !261 (merged) (sshen/npm-remote-preflight-batch), which carries Steps
16-19. Merge order: !258 (merged), !259 (merged), !261 (merged), then this. !260 (merged) should also merge before
this, for the reason !261 (merged)'s description gives: it is Step 15's S06 HEAD
amendment and defines the contract HeadPackument / HeadTarball implement.
!271 (merged) merges before this too, per the merge-order call on !263 (merged): both edit
docs/plans/2026-08-21-remote-conformance.md in the spec-amendment-candidates
region, and this branch rebases onto !271 (merged) once it lands.
With this, all ten npm.remote.* rows S08 §Per-format rows lists are
implemented.
Read this first: seven rows are red against Artifact Registry, on purpose
Against a live AR, three of the ten rows pass and seven report StatusFail, on
three distinct causes:
PASS npm.remote.preflight FAIL npm.remote.tarball-url-rewritten
PASS npm.remote.packument-relay FAIL npm.remote.tarball-relay
PASS npm.remote.dist-tags-relay FAIL npm.remote.repeat-read
FAIL npm.remote.not-found
FAIL npm.remote.write-refused-publish
FAIL npm.remote.write-refused-dist-tag
FAIL npm.remote.write-refused-unpublishReaching those verdicts at all needed the cross-host credential strip from #55 (closed),
without which npm.remote.preflight skipped on a 401 from GCS and AC #24 (closed)'s
ordering aborted the run at row one. That fix is on this branch through the
merge of main.
Four fail on the error-body shape. S06 §Error response shape requires the
error body's error field to be a string, and AR sends an object
({"error":{"code":…,"message":…,"request_id":…}}). Everything else about
not-found and the three write-refused-* rows is conformant: the 405, the
Allow presence and value, the no-trace behaviour, the 404 status.
This is not a defect in the MR and is deliberately not worked around. Two
pre-existing hosted rows, npm.error.response-shape and
npm.error.duplicate-publish-body, already fail against AR on the same fact with
no remote configuration involved, so the suite already holds the position that
the shape is non-conformant. Widening HTTPError.Code() to accept the nested
object would have made a failing implementation pass on the rows that consume it
while those two stayed red, which CLAUDE.md's first critical rule forbids and
which would leave the suite self-inconsistent.
The decision between amending S06 and changing the registry is tracked in #54 and
in the plan's §Spec-amendment candidates as Band B 7, which records the
dependency as six rows: the two hosted ones, the three write-refused-*, and
npm.remote.not-found, whose shape clause reads the same HTTPError.Code()
second result rather than decoding the body itself. Evidence, with the isolation
visible, is at validation/evidence/step23/reference-run-r16-divergence.txt.
Three fail on the advertised tarball host. tarball-url-rewritten,
tarball-relay and repeat-read fail because the relayed packument advertises
dist.tarball on AR's configured public host, which is not the host the run
addressed. urlUnderBase therefore refuses to hand a registry-chosen host to
the allow-list-free GetTarballAtVettedURL, and the row reports the refusal
rather than issuing the fetch. That is the SSRF guard working rather than a row
defect: both hosts answer 307, so addressing AR by its public host should
clear all three. This is a run-configuration question and is filed as
Band B 4, which is also where the comparison's own reading is recorded.
npm.meta.whoami and npm.meta.ping also fail in a full remote run. Those are
## Protocol baseline rows rather than npm.remote.*, and they fail on routes
AR does not serve
(AR#1130).
MR size
11,776 added lines across 33 files (production Go 3,283, test Go 8,252, docs
241, measured at f753e099), against the project's 500-line review ceiling.
Nothing is excludable: no vendor/, no generated files, no
Code generated ... DO NOT EDIT header. Of the added production Go, 1,091
lines are code and 1,852 are doc comment.
It lands whole, deliberately, for two reasons.
The band is one unit. Steps 20-25 implement all ten npm.remote.* rows S08
§Per-format rows lists plus the strict-equality flip that closes the inventory,
and Step 25's flip cannot hold until all ten rows register. The cluster is the
goal run's annex §7 unit rather than an accretion.
The one available split does not reach the ceiling. Review identified a single clean one-way cut, at Steps 22/23: Steps 23-24 consume ten symbols from Steps 20-22 and Steps 20-22 consume none of theirs, so every other cut splits a shared cluster. Both halves of even that cut stay well over 500 lines, so the ceiling is unreachable for this band either way, and re-splitting six landed steps costs a rebase for no defect-detection gain.
Reference validation
Every row was run against a locally built Artifact Registry, not only against the
in-tree double, and each step has negative controls that make its assertions fail
for their own reasons. Evidence under validation/evidence/step{20,21,22,23}/.
Three controls are worth calling out because they cost design work rather than just a mutation:
- Step 20's three controls target one packument field each. The blunt
rewrite-body=1.0.0:9.9.9fails the row, but it rewrites the versions key anddist-tags.latestin one pass, so which of the row's three assertions caught it is unknowable. Each property got its own targeted mutation, and the matrix prints the message to prove they are distinguishable. - Step 22's
repeat-readneeded a new proxy scope. That row compares two responses to each other, so a mutation applied to both leaves them identical and proves nothing.mutproxygained-only-nth N, mutex-guarded, because a racy counter would make a control non-deterministic, which is worse than no control since it would sometimes pass and be read as evidence. - Steps 23 and 24 could not be controlled at all without repair. Their rows assert the error envelope AR violates, so every case would have been red and none attributable. The matrices repair the envelope with a body rewrite so the rows' other assertions become reachable, and each matrix header states that it therefore produces no control for the envelope assertion itself. That evidence is the unrepaired reference run instead.
Step 25's propagation guard
The one part of this MR that is not a row. It drives every NeedsUpstream remote
descriptor with an injected run-attributable seeding fault and asserts the
returned case carries a non-nil SetupFailure.
Worth reviewing on its own because it is the only thing that catches a specific
silent failure: a row that builds its TestCase by hand instead of through
NewSeedFailureCase demotes a revoked credential to a plain skip. AC #33 cannot
see it because the field is unset, and AC #36 (closed) cannot see it because not every
seeding row skipped, so the run exits 0 having validated nothing. In the
step's mutation table, making one row return conformance.Skip(...) instead is
caught by this test only.
It is also guarded against passing vacuously: the descriptor count is asserted
before anything is driven, and removing the injected fault fails it, because the
unfaulted default is also a run-attributable SeedError and would satisfy a
looser assertion.
Two spec readings corrected here
Both were narrower in the plan than in S08, and both are now recorded in the plan with the measurement behind them:
dist-tags-relaycompares the wholedist-tagsmap, notlatestalone. The plan saidlatest; S08's row says the document "agrees with the packument'sdist-tags". Alatest-only check passes a proxy that drops every other tag, and this is the only row that reads that document. Adopted only after measuring that AR relays a two-tag package into both surfaces with the maps agreeing exactly, because a wider assertion that fails a conformant registry would be worse than a narrow one.urlUnderBasenormalizes the scheme's default port. It comparedu.Hostliterally, so a registry advertisinghttps://host:443/…while the run reached it athttps://host/…was reported non-conformant. That is a correct registry failing a row.pkg/conformance'snormalizeBaseURLalready normalizes the same URLs.
Spec coverage for the two commits that carry no table
817124f and 51455c5 author spec rows and acceptance criteria without an AC/EC/SC table of their own, which docs/dev/go-testing.md makes the reviewer's coverage check. The MR squashes on merge, so the tables are here rather than amended into the two commit bodies.
817124f — Step 24, npm.remote.write-refused-dist-tag and npm.remote.write-refused-unpublish
| # | Criterion | Tests |
|---|---|---|
| AC-1 | A hosted run executes no ## Remote row |
TestRemoteWriteRefusedDistTag_IsRegisteredForRemoteRunsOnlyAndNeedsUpstream, TestRemoteWriteRefusedUnpublish_IsRegisteredForRemoteRunsOnlyAndNeedsUpstream |
| AC-13 | Seed through --upstream-url, assert through --registry-url |
TestRemoteWriteRefusedDistTag_RefusesBothRoutesAgainstTheRemoteOnly, TestRemoteWriteRefusedUnpublish_RefusesAllThreeRoutesAgainstTheRemoteOnly, both over remotefake's observation log |
| AC-14 | One read-back per settle at --settle-timeout=0 |
seededRefusalStub.env sets settleTimeout zero, which every case in both files runs through |
| AC-15 | A residue-status seeding refusal skips the row alone | Not applicable to these tests: the seeding verdict is seedRemoteFixture's and is owned by remote_preflight_test.go |
| AC-18 | A write-refusal row passes only on 405 plus the route's Allow, and the coordinate is unchanged afterwards |
TestWriteOnlyRefusal_DivergenceReportsWhichPropertyBroke, TestWriteOnlyRefusal_ToleratesLegalSpellingsOfAnEmptyAllowList, TestWriteOnlyRefusal_EachNegativeDirectionGetsItsOwnMessage, TestWriteOnlyRefusal_DiffersFromThePublishRefusalOnlyAboutAllow, TestRemoteWriteRefusedDistTag_FailsWhenARefusalDivergesFromS08sContract, TestRemoteWriteRefusedDistTag_FailsWhenTheRefusedWritesLeftATrace, TestRemoteWriteRefusedUnpublish_AssertsTheRefusalOnEachOfItsThreeRoutes, TestRemoteWriteRefusedUnpublish_FailsWhenTheRefusedWritesLeftATrace, TestSeededTarballDivergence_ReportsHowTheServedBytesDifferFromTheSeeded |
| AC-22 | Every target-specific assertion is carried by a row whose Notes name the spec requiring it | TestWriteOnlyRefusalConstants_MatchTheirSpecLiterals; the docs/catalog/npm.md Notes for both rows name AR S15 |
| AC-24 | Registry-chosen values reach the message redacted | TestWriteOnlyRefusal_DivergenceRendersSecretsInTheValuesItNames |
| AC-26 | Under --upstream-free-only a NeedsUpstream row skips and its Fn is not called |
TestRemoteWriteRefusedDistTag_SkipsUnderUpstreamFreeOnly, TestRemoteWriteRefusedUnpublish_SkipsUnderUpstreamFreeOnly, both over the observation log |
| AC-29 | Each row passes when run alone under a --filter selecting only it |
TestRemoteWriteRefusedDistTag_RunsAloneUnderAFilterSelectingOnlyIt, TestRemoteWriteRefusedUnpublish_RunsAloneUnderAFilterSelectingOnlyIt |
| AC-33 | A row that cannot seed ends the run | Not exercised here; the propagation guard is 51455c5's TestInventory_remoteSeedingRowsPropagateSetupFailure |
| AC-35 | Seeding rows are registered with NeedsUpstream: true |
Both _IsRegisteredForRemoteRunsOnlyAndNeedsUpstream tests. The partition over the whole section is 51455c5's |
| AC-37 | Seeding content identity | The upstream double answers the tarball HEAD with this run's exact fixture bytes, so the settle takes the Preexisting path and AC #37's content check compares the served bytes against that fixture |
| S08 §Write refusal per format | All five write-only routes are refused, exactly once each, with Allow present and empty |
TestWriteOnlyRoutes_AreTheFiveS08RequiresRefusedExactlyOnce, TestWriteOnlyRefusalConstants_MatchTheirSpecLiterals |
| S06 §Runner-level surfacing | A canceled run skips rather than blaming the registry | TestRemoteWriteRefusedDistTag_SkipsWhenTheRunIsCanceled, TestRemoteWriteRefusedUnpublish_SkipsWhenTheRunIsCanceled |
51455c5 — Step 25, the strict-equality inventory flip
| # | Criterion | Tests |
|---|---|---|
| AC-1 | Both kinds execute the format's kind-independent rows; a remote run executes no ## Local or ## Errors row |
TestInventory_kindIndependentRowsRunUnderBothKinds, TestInventory_remoteRunRegistersOnlyKeepListSlugs |
| AC-27 | Per-kind inventory guard, now strict equality in both directions | TestInventory_remoteModuleSlugsEqualCatalog, anchored on remoteBandRows so two empty sides cannot satisfy it; negative directions in TestInventory_subsetCheckDetectsUnknownSlug and TestInventory_splitCheckDetectsMisplacedDescriptor; TestInventory_remotePrefixMatchesCatalog holds it against the live catalog |
| AC-28 | A remote run registers only the keep-list slugs, and a hosted run none of them | TestInventory_remoteRunRegistersOnlyKeepListSlugs, TestInventory_hostedRunRegistersNoRemoteSlug, TestInventory_catalogSlugsForKindSelectsItsBucket |
| AC-33 | A row that cannot seed ends the run rather than reporting a verdict | TestInventory_remoteSeedingRowsPropagateSetupFailure: without the propagation a run-attributable seeding failure reaches the report as a plain skip, which AC #33 cannot see because SetupFailure is unset |
| AC-35 | The ## Remote band partitions by NeedsUpstream, over the named pair |
TestInventory_remoteUpstreamFreeRowsNeedNoUpstream, TestInventory_remoteSeedingRowsNeedUpstream, TestInventory_remoteBandPartitionsByNeedsUpstream, TestInventory_moduleRegistersARemoteUpstreamRow |
| AC-36 | ErrNothingEstablished |
TestInventory_remoteSeedingRowsPropagateSetupFailure is the same guard from the other side: AC #36 (closed) cannot see an unpropagated failure either |
| AC-13 (S06) | Negative-auth slugs come from NegativeAuthTests and catalog slugs from TestCatalog |
TestInventory_moduleSlugsEqualCatalog, TestInventory_moduleSlugsSplitBySource |
Test plan
-
go test ./...green. -
pre-commit run golangci-lint --all-filespasses. - All ten rows run against a live Artifact Registry, with the verdicts and the three causes as §Read this first sets them out.
- Per-step negative controls on disk, with each matrix printing the emitted message rather than only a verdict. That is load-bearing: twice in this run a mutation failed a row for a different reason than the one it was aimed at, and the message is the only thing that distinguishes them.
- Every step's assertions verified by mutation. Step 24's sweep was run by the operator rather than the authoring agent, for the reason in §Process.
Process
This MR came from a goal run whose annex §7 defines its scope. Judgement calls
are in validation/decisions.md, reference divergences in
validation/reference-quirks.md and #54, departures from the plan in
validation/deviations.md.
Two deviations a reviewer should know rather than infer:
- Step 24's implementation is not independently authored. Three consecutive agent dispatches on that step stalled, the last having written only an import block. The implementation is the one the test author had written to verify its own tests could fail, restored and reviewed. That loses the independence the test-first split normally buys. What stands in for it: a nine-mutation sweep against that exact code, no survivors, table in the test commit. That is weaker evidence than an independent implementation passing tests it did not shape, and is recorded rather than left to the commit graph.
- S08's amendment-table Owner cells in
51455c5predicted this MR's number.docs/dev/conventions.md§Amendment tables bans relative labels and admits a role label only for work with no MR, so there was no truthful way to write the cell before the MR existed. The number is corrected in this branch's final commit now that the MR is open.