feat(conformance): seed-and-settle foundation, Steps 4b-5a (S08 remote, batch 1b/12)

Summary

Related to #47 (closed).

Band A foundation batch per the S08 remote goal-run's annex §7 MR clusters ("Band A foundation"), second half. Lands Steps 4b, 5 and 5a: the derived --timeout, the remotefake caching-proxy double, and the shared seed-and-settle helper. This completes Band A.

!258 (merged) has merged, so this targets main directly and the diff is just Steps 4b, 5 and 5a: 16 files, 7,879 insertions.

Brought up to date by merge, not rebase (4cd274e). !261 (merged)'s sshen/npm-remote-preflight-batch branches off 9481baa in this branch's history, so a rebase would orphan its base. The ten-file conflict came from !258 (merged) being squash-merged: main carries its last ten commits as one commit git cannot relate to the Band A commits already merged here. 4cd274e's message has the per-file resolution. An earlier revision of this section said the branch would rebase cleanly onto main; it did not.

Annex §7 plans cluster 1 as a single MR covering all seven Band A steps. It ships as two because the Maven band (Steps 6-14) was reassigned and that band's critical path runs through Band A: Step 7 depends on Steps 2 and 3, Step 8 on Steps 2 and 4a, so !258 (merged) unblocks both a review cycle earlier than a seven-step MR would. This MR is what unblocks Maven Step 9 onward, which needs Steps 5 and 5a. Recorded in validation/deviations.md D4.

Size

16 files, 7,879 insertions: 2,377 lines of production Go against 5,332 test Go, so ~69% test volume. Stated up front rather than left for a reviewer to find.

The three steps are batched for sequencing, not because they cannot separate. They share no file, no import edge and no test edge in this MR: nothing imports pkg/conformance/internal/remotefake, seed_test.go runs entirely off stub SeedCallbacks with no format and no HTTP server, and Step 4b's internal/cli code references neither SeedAndSettle nor remotefake. The coupling the batch is named for begins at Steps 9, 19 and 30, which call the helper and drive the double together. What batching buys here is one review cycle instead of three on a critical path three bands are waiting on. An earlier revision of this section claimed the steps could not separate further, which the import graph does not support.

Steps

  • Step 4b: the derived --timeout. internal/cli/{flags,run,stderr}.go and README.md, with three test files. Covers AC #30 (closed), #31 (closed), #32, #34 and AC #2 (closed)'s exit-code half. No catalog rows.

    Drops newTimeoutFlag's registered 2m, derives 2n × d + 5m under a resolved remote kind, reorders runAction so selection and the default resolution precede context.WithTimeout (which still precedes parseCredential), and renders S08 §Seeding's four expiry messages. All four strings are taken verbatim from §Seeding; the plan reproduces none of them on purpose.

    README.md's --timeout row said "Default 2m", which is wrong for a remote run by a factor of eight on OCI. Dropping the registered default removes the only other channel an operator sizing a CI job's wall clock could read it from.

  • Step 5: remotefake, the caching-proxy double. pkg/conformance/internal/remotefake/. No catalog rows.

    A format-agnostic origin/remote pair behind seven Adapter seams. Four independent guards over the mutable state, with no lock held across an adapter call, an origin fill, or a ResponseWriter write; the Acceptance's concurrent two-read test is what that exists for, and it runs under -race.

  • Step 5a: the shared seed-and-settle helper. pkg/conformance/seed.go. Covers AC #14 (closed), #15 (closed), #24 (closed), #33, #37 and §Preflight's cause table. No catalog rows.

    The helper classifies from no format literal: Stored is the callback's verdict, so there is no 2xx check and no 202 case. The resume trigger is one sticky condition rather than a list of the four shapes that reach it, which is what makes Preexisting on the first attempt content-checked with no re-attempt.

Three defects the test-first cycle caught, worth a reviewer's eye

Each is its own commit, so the sequence stays legible.

  1. bb098eb: the expiry message fired on any cancellation, not just a deadline. Step 4b implemented the condition the plan pinned, ctx.Err() != nil, which context.Canceled also satisfies. An operator pressing Ctrl-C on a remote run with n above zero was told run cancelled after 8m0s: 3 rows seed against --upstream-url ... so this run needs --timeout >= 6m0s: advice about a deadline that never fired.

    AC #31 (closed) and §Seeding are both worded over expiry, and §Error Cases calls its row the root-context-expiry row, so the plan's mechanic was broader than the spec it implements. errors.Is(ctx.Err(), context.DeadlineExceeded) satisfies both concerns: reading the CLI's own context rather than runErr is still load-bearing, because the library client's per-request timeout also wraps DeadlineExceeded and would misreport a stalled seeding write. Neither half alone is enough, and the test pins the pair.

  2. 4ef7056 and 669ea07: two assertions that could never pass, and the linter loop behind them. Both compared the media type application/json with require.JSONEq, which unmarshals its expected argument and so rejected it as invalid JSON before comparing anything.

    require.Equal is what they meant, but that re-triggers testifylint's encoded-compare rule, which is what pushed the original code to JSONEq. Renaming the constant did not clear it, so the heuristic keys on the value; a nolint with the reasoning is the honest resolution, and the repo already uses that pattern at 224 sites.

  3. 259648d: an assertion no correct implementation could satisfy. One preflight-message row expected authorization: Bearer [REDACTED], but ScanContent's value class is [^\r\n]* from the separator run, so Bearer is consumed. redact_test.go:165 pins exactly that. Decisively, the same test file already contradicted itself: assertBothScrubPasses asserts the output contains "authorization: " + Sentinel over the same fixture. And AC #24 (closed) pins the composition as RedactURLUserinfo(ScanContent(...)), so producing Bearer [REDACTED] would require not calling ScanContent.

Plan corrections and open items

docs/plans/2026-08-21-remote-conformance.md gains three corrections and one new spec-amendment candidate, all verified against S08 before filing:

  • Step 4b's Acceptance said no derived---timeout rendering may name a bound the expired value is already above. Literally false: a derived --timeout is 2n × d + 5m, above the 2n × d the base fragment names. §Seeding says the at-or-above-floor branch "appends" and that "Neither arm names the floor", where the arms are the two moves. Scoped to the move.
  • Step 4b's Acceptance claimed the absent---repository-kind run is the only invocation catching a != RepositoryKindHosted branch. Registering the flag with Value: hosted per S04's Default column (this MR's parent, !258 (merged)) makes the flag read hosted whether or not it is on argv, so the discriminator is the programmatic path. Both rows are asserted.
  • Step 5a's Acceptance said the upstream-failure-status read is "asserted here". Not implementable: that read goes through env.Client() against the repository under test, and SeedAndSettle only reads the upstream. Reassigned to Steps 9, 19 and 30.
  • New candidate: S08 §Seeding pins the base expiry fragment and the at-or-above-floor branch's appended fragment, but not the separator between them. One byte the spec leaves free in an operator-facing contract string. "; " chosen and asserted exactly, so a divergence fails loudly.

Three design gaps are recorded rather than worked around, each parked at the step that will have the evidence to decide it:

  • remotefake has no absence-body seam. AC #17 (closed) requires the not-found status and error shape; RefusedBody is refusal-scoped. Surfaces at Steps 13, 23 and 35.
  • The remotefake origin models no OCI upload session (POST blobs/uploads/PATCHPUT ?digest=), storing at Coord.Key on any write verb. Surfaces at Step 30, whose callback reads BlobUploadResult.Method.
  • SeedAndSettle's two returns cannot signal a cancellation. With no *SeedError produced, a cancelled call returns nil alongside whatever SeedResult the last write or read-back observed, which is typically non-zero and is indistinguishable from success unless the caller re-checks ctx.Err(). It is not a zero SeedResult, so res == SeedResult{} is not a discriminator in either direction. The signature is deliberately not widened here: three bands code against it in parallel and Band A is what they wait on. SeedAndSettle's godoc states the re-check as a caller requirement so the rule reaches a band author reading go doc; the first preflight caller decides whether to widen.

.gitignore gains **/testdata/rapid/. pgregory.net/rapid writes a repro file per property failure, and during a test-first step the properties fail by design and regenerate on every run, so nothing should be able to smuggle them into a commit.

Test plan

  • go test ./... green.
  • go test -race ./pkg/conformance/... green, no data races.
  • golangci-lint run ./... (2.13.1, the pinned version): 0 issues repo-wide.
  • gofmt and goimports clean on every changed file.
  • No catalog rows in this batch, so no catalog Status flips and no reference-validation layer applies. Cluster 1 carries 0 rows per annex §7.

Three files flag under a bare gofmt -l (pkg/conformance/maven/module.go, maven/publish_snapshot.go, npm/module.go). Pre-existing drift on main, no pre-commit hook flags it, untouched here.

Process

Each step followed the repo's test-first authorship contract: a test(...) commit establishing the failing floor, then the implementation. The commits are separately visible and the branch is not squashed. The --no-verify exception is used once per step, on the test-author commit only; on a batched branch that is three commits here, which is expected rather than a defect and is reasoned out in validation/decisions.md D7.

Deferred findings are logged in validation/decisions.md (D1-D11) rather than fixed silently or dropped, and departures from the plan are in validation/deviations.md. This batch came from a goal run whose annex §7 defines its scope; that run also produced a reference environment and eight measured findings about the real Artifact Registry, which shaped two things here even though this batch has no catalog rows: remotefake's StateHeaders seam had to stay expressive enough for four different cold-to-warm transitions rather than one, and Step 5a's tests had to avoid assuming any second write is refused, because a retried Maven seed writes byte-identical content and returns 200.

Review round

Five commits on top of the batch, from a full-branch review. Each carries its own reasoning; the headline is that NewSeedFailureCase wrote an untruncated response body into HTTPDetail.ResponseBody, which S04 §Report schema bounds at 4 KiB and explicitly does not let the renderer re-truncate. On AC #37's mismatched settle that body is a successful read-back's served artifact, up to 32 MiB for an OCI manifest, and it reached TestCase.Message and the JUnit <skipped message> attribute at full size.

  • 11a4ecd the 4 KiB bound, the one-line skip reason S04 AC #65 pins, and a duplicate-refusal verdict the cause sort could not see when the callback reported it without an error.
  • 011ae64 the expiry wrap was shadowing *FilterNoMatchError and *PriorityNoMatchError, printing both through their Error() renderings rather than the ones S04 AC #70 and #71 pin.
  • ea24a75 four plan claims the Band A edits left stale, including Step 4b still prescribing in bold the ctx.Err() != nil mechanic bb098eb removed.
  • aeef5ed self-review: a nil guard on NewSeedFailureCase and two doc claims narrowed to what the code does.
  • 8de8061 merge with !258 (merged), whose own review round moved Config.Validate ahead of the credential read. The two reorders of runAction are reconciled with validation first, and TestRunCommand_ValidationPrecedesTheSizing pins that order, which nothing did before.

Each fix was mutation-checked: reverting it alone fails the suite.

Edited by Sylvia Shen

Merge request reports

Loading
Loading