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-unpublish

Reaching 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.9 fails the row, but it rewrites the versions key and dist-tags.latest in 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-read needed a new proxy scope. That row compares two responses to each other, so a mutation applied to both leaves them identical and proves nothing. mutproxy gained -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-relay compares the whole dist-tags map, not latest alone. The plan said latest; S08's row says the document "agrees with the packument's dist-tags". A latest-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.
  • urlUnderBase normalizes the scheme's default port. It compared u.Host literally, so a registry advertising https://host:443/… while the run reached it at https://host/… was reported non-conformant. That is a correct registry failing a row. pkg/conformance's normalizeBaseURL already 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-files passes.
  • 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 51455c5 predicted 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.
Edited by Sylvia Shen

Merge request reports

Loading
Loading