docs(spec): define HEAD on npm's read routes, Step 15 (S08 remote, batch 5/12)
Summary
Related to #47 (closed).
S06 HEAD amendment per the S08 remote goal-run's annex §7 MR clusters
("S06 HEAD amendment (spec MR)"). Lands Step 15. Specification only, no code
and no tests.
Targets main directly. Independent of the Band A MRs (!258 (merged), !259 (merged)): the
plan gives Step 15 Depends on: nothing. It is a prerequisite for Step 16 and
therefore for every npm remote row, so it lands before the npm band's first code
MR.
Annex §7 keeps this a separate MR under otherwise aggressive batching, and the reason is worth restating: folding a spec amendment into the code that relies on it means the amendment gets reviewed as an implementation detail of that code.
The gap
S06 defined no HEAD operation for npm at all. Meanwhile
S08 §Read surface per format lists HEAD
as available on both npm read routes, and npm.remote.write-refused-publish
asserts the refused write carries Allow: GET, HEAD. That Allow cited a
contract S06 did not have, so there was nothing for the assertion to be checked
against.
What changed in S06
- §HTTP surface: the Method slot of Operation: Packument fetch and
Operation: Tarball download widens from
GETtoGET,HEAD. Operation: Tarball download's prose is scoped to theGET, because theHEAD's URL is suite-built rather than adist.tarballvalue. - §HTTP surface gains a
HEADon the read routes subsection defining the semantics: resolves as the correspondingGETwith the body omitted, same path template, same request headers, same success and error statuses. On a success response the headers asserted areContent-Typeand, when the response declares one,Content-Length; parity beyond those two is deliberately not asserted, because RFC 9110 §9.3.2 states it as a SHOULD and lets a server omit fields whose value is determined only while generating the content. On an error response no header parity is asserted at all, only the status and AC #7 (closed)'s emptyResponseBody. On the tarball route the one-hop redirect replay preserves the method, so the probe stays aHEAD. Availability and scope are kept apart: RFC 9110 §9.1 makesHEADavailable on every route, and this spec asserts it on these two only, claiming nothing either way about the rest. Source: protocol-mandated; RFC 9110 §9.1 for availability and §9.3.2 for response semantics, corroborated per route by AR S15 (§Tarball proxy for the tarball half, the publishPUT'sAllow: GET, HEADfor the packument half). - §Tarball-URL SSRF: the allow-list is re-keyed on the URL's provenance
rather than on the route or the verb. It polices any request against a
packument-supplied URL and the
Locationof any hop such a request follows. It does not cover the vetted-URL pair, whose URL the suite builds itself from a configured base. - §Redirect scheme downgrade on the vetted-URL pair (new): the normative
rule that stands in the allow-list's place. A hop whose target scheme is
http, where the request it came from was issued overhttps, MUST be refused, before any host consideration and whether or not the hop stays on the same host. Carries its error shape (*HTTPError,StatusCode: 0), a fourth §Client error cause, and AC #15 (closed). The text is sourced from #49 (closed)'s plan Step 16 rather than re-derived. It also records the residual risk plainly: the hop's host is unvalidated, andGetTarballis not the protected comparison, becauseGetTarballapplies the allow-list but compares no schemes. - §
pkg/client/npmmethod surface: declaresHeadPackument(ctx, name, accept)andHeadTarballAtVettedURL(ctx, tarballURL), both returning(*HeadResponse, error), plusGetTarballAtVettedURL(ctx, tarballURL). TheAtVettedURLname carries the caller's precondition in the identifier rather than in a doc comment, matching the naming table in #49 (closed)'s plan, which previously named only theGEThalf that way. All three land in Step 16, not here. - §Client types: adds
HeadResponse{StatusCode, ContentType, ContentLength}, followingmaven.ArtifactResponseandoci.ManifestResponse.StatusCodeis what makes AC #14 (closed)'s same-status clause assertable at all: §Status-code policy admits any2xx, so without it a registry answering200to theGETand204to theHEADpassed. - §Status-code policy:
HeadTarballAtVettedURLandGetTarballAtVettedURLjoinGetTarballandAuditBulkas operations that override the caller's host-lockedCheckRedirectat the method level, so the count goes from two to four. - §Acceptance Criteria gains AC #14 (closed) and AC #15 (closed), both stating plainly that
no test asserts them today. AC #14 (closed) also states what "the same" is measured
against per route: the tarball route has a
Content-Lengthcomparand (len(BuildTarball(...)), a length the suite knows independently), and the packument route has none, which the criterion records rather than narrows. The AC preamble records the exception to "verifiable by at least one unit or integration test", and AC #2 (closed), AC #3 (closed) and AC #7 (closed) each say that the part of them ranging over the twoHead…methods lands with that band. - §Open Questions (was
None.) gains two:--allow-redirect-hostdoes not reach the manual one-hop paths, which is a pre-existing client gap reachingGetTarballtoo and is being filed as a follow-up; and the packument route's missingContent-Lengthcomparand, which would take wideningGetPackument's return shape. - §Resolutions records why the hosted contract widens with no hosted row asserting it, and the alternative that was rejected.
§Out of Scope is deliberately untouched: main already carries the split, with
a Remote-registry bullet naming npm.remote.cache-ttl and
npm.remote.cache-invalidation as S08's. Verified rather than assumed.
Why not narrow S08 instead
The cheaper-looking fix was to narrow npm.remote.write-refused-publish's
Allow to GET and leave S06 alone. Rejected, and the reason is recorded in
S06 §Resolutions: the Allow value is not the suite's to choose. It reports
what the repository under test serves, and a remote npm repository that answers
HEAD on its read routes carries HEAD in that header whatever this spec says.
Narrowing the assertion would have made the suite fail a correct implementation,
which is the failure mode the whole project exists to avoid.
Defining the operation in S06 rather than S08 follows from the same reasoning: a remote repository is a proxy of the same protocol surface, so it cannot define an operation its hosted counterpart lacks. The definition belongs where the protocol is defined, even though the assertion consuming it lives in S08 and no hosted row asserts it.
What changed in S08
Two prose sentences in §Read surface per format stated the missing-HEAD gap in
the present tense, and this MR is what makes them false; both now read as
closed, and the AR S15 corroboration there is split by route the same way S06's
is. The §Downstream amendments row keeps its imperative present tense, per that
table's own preamble rule that a row's tense says nothing about whether its edit
has landed; its Owner cell moves from Per-format child to !260, and its
anchor and Edit cell widen to cover what the amendment actually ships (the
client surface, the downgrade rule, AC #15 (closed)). The preamble gains one sentence
placing !260 under conventions.md's same-MR check, since it is a per-format
child MR carrying an S08 edit in this diff and the existing two-way split
covered neither case.
That is wider than the plan's Files list for this step, which names only the
Owner cell. Leaving a spec asserting something this same MR falsifies seemed the
worse trade. The row is de-staled rather than preserved because
docs/dev/conventions.md §Amendment tables'
supersession rule covers an earlier row that a later MR revises, not the row
under review.
What changed in the plan
#49 (closed)'s plan is kept in step with the spec, not extended: Step 16's file list
gains HeadResponse and the (*HeadResponse, error) returns, Step 15's
Acceptance names both new criteria instead of one, and the naming table's
HeadTarball becomes HeadTarballAtVettedURL so the row agrees with the
convention it states in its own Notes cell.
Test plan
-
pre-commit run --files docs/specs/S06-npm-contracts.md docs/specs/S08-remote-contracts.md docs/plans/2026-08-21-remote-conformance.md: every hook passes. - Every string quoted from RFC 9110 checked verbatim against the published
RFC text, after review found three quotations that did not appear in it
and one that rendered §9.3.2's
SHOULDas aMUST. - AR S15 checked on
mainfor theHEADcorroboration: its endpoint table gives the packument pathGETalone andHEADappears nowhere in §Packument proxy, so the tarball and packument halves are cited to different S15 sections. - The three client facts the downgrade rule rests on checked against
pkg/client/npm:newRequestapplies the credential unconditionally,redirectClient()replacesCheckRedirectwithErrUseLastResponse, andvalidateRedirectTargetcompares no schemes. - Enumerated every
HEAD,HeadTarball,Tarball-URL SSRFanddowngradeoccurrence across S06, S08 and the plan, per this project's "enumerate, don't eyeball" rule for spec edits, rather than reviewing the diff in isolation. - No code, no tests, no catalog rows.
Tests: none; specification onlyper the plan.
Process
This MR came from a goal run whose annex §7 defines its scope and keeps this
amendment separate from the code that depends on it. Judgement calls are
recorded in validation/decisions.md rather than resolved silently.