feat(conformance): remote-run foundation, Steps 1-4a (S08 remote, batch 1a/12)

Summary

Related to #47 (closed).

Band A foundation batch per the S08 remote goal-run's annex §7 MR clusters ("Band A foundation"). Lands Steps 1, 2, 3 and 4a: the run-ID widening, the RepositoryKind config surface and its validation, the runner's remote-run policy surface, and the three CLI flags.

Targets main directly.

This is the first half of annex §7's cluster 1, not the whole cluster. The annex plans cluster 1 as one MR covering Steps 1, 2, 3, 4a, 4b, 5 and 5a, and its note permits a split only on the size guardrail. The split here is for a different reason, so it is worth stating: the Maven band (Steps 6-14) has been reassigned to another engineer, and that band's critical path runs through Band A. Step 7 depends on Steps 2 and 3; Step 8 depends on Steps 2 and 4a. Holding all seven Band A steps in one MR keeps both blocked behind Steps 4b, 5 and 5a, which neither of them reads.

So Band A ships as two stacked MRs:

  • this MR: Steps 1, 2, 3, 4a, which unblocks Maven Steps 7 and 8;
  • a follow-up: Steps 4b, 5, 5a, which unblocks Maven Step 9 onward.

Note the seam differs from the one the annex's note suggests, which would leave 4a in the second MR. That keeps Step 8 blocked an extra review cycle for no dependency reason, since 4a needs only Steps 2 and 3 and both are in this MR anyway. Recorded in validation/deviations.md D4.

Release impact

This MR releases as a major. 6413ab8 carries a BREAKING CHANGE: footer for Step 1's run-ID widening: GenerateRunID returns 16 lowercase hex characters instead of 8, so anything matching the shape of a generated run ID must accept both widths (the Run ID: stdout line, the JUnit run_id attribute). Fixtures already written under the 8-character shape are never cleaned up, so both widths coexist in a target registry indefinitely. S04 §Run-ID resolution already specified the 16-character shape, so a consumer written against the spec rather than against observed output is unaffected.

The /v2 module-path rename Go requires for a v2+ import path is deliberately not here, so go get on the current path keeps resolving v1 until a follow-up renames it.

Size

28 files, 6,155 insertions. Of that, 1,094 lines are production Go and 5,029 are test Go, so ~82% of the diff is table-driven test volume coupled to the production code it exercises. Flagged up front rather than left for a reviewer to discover. Splitting further would not help the reassigned band, which needs Steps 2, 3 and 4a together regardless.

Steps

  • Step 1: run-ID widening to 64 bits. pkg/conformance/runid.go, with pkg/conformance/{runid,run}_test.go and cmd/conformance/main_test.go. GenerateRunID moves from 4 to 8 crypto/rand bytes, so a generated ID is 16 lowercase hex characters. Covers S04 AC #25 (closed). No catalog rows.

    The test widening is not mechanical in one respect worth review attention: distinctReader had to move from uint32 to uint64 with a 64-bit odd multiplier, because binary.BigEndian.PutUint32 against an 8-byte buffer leaves the high four bytes zero while still satisfying io.ReadFull, which would have silently defeated this step's own "varies all eight bytes" acceptance. assertEveryByteVaries now asserts that property directly rather than leaving it implied by distinctness.

  • Step 2: RepositoryKind, the three Config fields, and their validation. pkg/conformance/{types,config,validate}.go, with {config,validate}_test.go and testdata_test.go. Covers AC #10 (closed) and the library half of AC #3 (closed), #4 (closed), #6 (closed), #7 (closed), #8 (closed), #25 (closed). No catalog rows.

    Every rule reads a resolved kind through resolveRepositoryKind, never set-ness, so a zero-valued RepositoryKind cannot carry an upstream that would be silently ignored. URL equality normalizes lowercased scheme, lowercased host with the scheme's own default port removed, and the path with at most one trailing / trimmed, covering all four spellings AC #4 (closed) enumerates. Accept paths are asserted alongside the rejections, because a rule that over-rejects passes every rejection row: an http --registry-url with an http --upstream-url validates per AC #7 (closed).

    All eight Reason strings are static literals. S04 §Error Cases pins that the upstream-url Reason never carries the URL bytes, and the tests assert it over all six reasons rather than the userinfo one alone. Two guards cover different paths: the pre-existing TestValidate_NoFmtInterpolation bans fmt from validate.go, and a new marker table covers the string-concatenation path that ban does not reach.

  • Step 3: the runner's remote-run policy surface. pkg/conformance/{run,types,module,config}.go, with five new test files and internal/report/remote_seeding_test.go. Covers AC #26 (closed), AC #36 (closed), and the runner half of AC #33. No catalog rows.

    Adds TestDescriptor.NeedsUpstream, UpstreamEnv, TestCase.SetupFailure, SkipWithDetail, the exported SelectDescriptors, and ErrNothingEstablished with its two S04-pinned wrappings. No format sets NeedsUpstream or SetupFailure yet, so no existing behavior changes.

    Three things a reviewer may want to look at specifically. AC #36 (closed)'s qualifier keys on the narrowing rather than on the selected set being empty, so the two branches (narrowing removed every seeding row, versus the catalog offers none at all) are asserted apart from each other with their own wrappings. The upstream question is answered from UpstreamEnv.HasUpstream() alone and never from Config.UpstreamURL, and both plausible wrong implementations are tested explicitly. And SelectDescriptors never validates its argument, because Step 4b sizes the derived --timeout with a selection-only Config that Config.Validate would reject.

    internal/report/remote_seeding_test.go is outside the plan's Step 3 file list, deliberately: the step's Acceptance requires renderer assertions ("which every renderer still treats as a skip", "the failing row survives in the JUnit <failure>") and those are internal/report's behavior. Test-only; no internal/report production code changed.

    run_remote_verdict_pairing_test.go pins an implementation choice rather than a spec row, and it is worth a look because of how it was justified: nothingEstablished pairs a selected descriptor with the case it produced by position, not by name. A name-keyed implementation passes all 19 of the other tests (verified by mutation), and its failure mode is a green run reported as exit 2 with a diagnosis blaming an upstream that answered fine.

  • Step 4a: the three CLI flags and list --repository-kind. internal/cli/url.go (new), with internal/cli/{flags,run,list}.go and their tests. Covers the S04 §Error Cases Input-layer rows for the three flags, the S08-owned userinfo row, and AC #3 (closed), #4 (closed), #5 (closed), #6 (closed), #7 (closed), #8 (closed), #11 (closed), #25 (closed). No catalog rows.

    --timeout is untouched (Value: 2 * time.Minute intact); the derived value is Step 4b's.

    The part worth review attention is the echo-safe handling, because --upstream-url needed its own on both paths and they fail at different sites. It is optional and not a requiredStringFlag, so it inherits neither --registry-url's registryURLWrapMiddle cut in OnUsageError nor requiredStringFlag.PostParse's env-source strip, and REGISTRY_CONFORMANCE_UPSTREAM_URL is CI-variable reachable. So url.go carries an upstreamURLWrapMiddle cut for argv and an upstreamURLFlag wrapper whose PostParse strips the env wrap. The env strip is stripEnvFlagWrap(name, err), extracted from requiredStringFlag.PostParse and keyed on the caller's flag name so the two surfaces cannot drift apart later. Both paths assert the URL is absent from the message.

    validateRepositoryKind is a switch over conformance.RepositoryKindHosted and RepositoryKindRemote directly, with no package-level allow-list slice mirroring validFormats. validFormats exists only because Config.Format is a bare string with no constants to reference; Step 2 supplies two, so a slice would buy nothing and docs/dev/go-style.md §Avoid globals bans the package-level state.

    --repository-kind is registered with Value: string(conformance.RepositoryKindHosted), so both run --help and list --help print (default: "hosted"). S04's §Flag inventory Default column for the flag is hosted and §Validation rules says "Empty resolves to the default hosted", which both readings satisfy; the choice and what it does not change are recorded in validation/decisions.md D6. Briefly: Step 2's zero-valued-kind rules stay live, and with the flag registered their audience narrows to programmatic callers, which is the audience the plan already assigns them.

    One behavior-order note for the record. buildConfig, extracted from runAction as the plan's Files list directs, now parses --priority and --allow-redirect-host before parseCredential, where the old body parsed the credential first. Not observable through the CLI, because both flags carry those parsers as their Validator and are rejected at the flag boundary before the Action runs; TestRunCommand_RegistryURLErrorPrecedesFilterError still passes, so the reported field for conflicting errors did not move. The extraction lands here rather than in Step 4b because 4b's reorder moves calls around that helper, and doing both at once would make the reorder unreviewable.

Plan and spec

docs/plans/2026-08-21-remote-conformance.md gains two entries under §Spec-amendment candidates, both raised while authoring Step 3 and both verified against the spec and the code before filing:

  1. S04 §Entry points' SelectDescriptors doc comment lists two of the three error types the function returns. The third, the *ConfigError{Field: "filter"} that filterByPattern raises on path.ErrBadPattern, is reachable on an operator-visible path, because SelectDescriptors never validates its argument and Step 4b's --timeout sizing passes an unvalidated selection-only Config. This MR implements the superset.
  2. S04 is silent on whether a runner-synthesized skip carries StartedAt: AC #54 scopes its rule to cases produced through runOne, and an upstream-gate skip never reaches it. The silence is not neutral in effect, because internal/report/junit.go's formatJUnitTime has no zero-value branch, so such a case renders as 0001-01-01T00:00:00Z. This MR asserts nothing either way and adds no zero-value branch.

Two small cleanups the steps surfaced, each its own commit:

  • d81c85e rewrites four S04 §843 line-number citations to S04 §Run-ID resolution. Line 843 now holds unrelated content, and a line number drifts the moment a section above it is edited.
  • d987b78 points run.go's CheckRedirect scheme check at the schemeHTTP/schemeHTTPS constants Step 2 introduced, which had made the literals a second copy in the same package.

Test plan

  • go test ./... green.
  • 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, pkg/conformance/maven/publish_snapshot.go, pkg/conformance/npm/module.go). That is pre-existing drift on main, no pre-commit hook flags it, and it is untouched here.

Spec coverage

One row per acceptance criterion, with the step that owns it. The four test(...) commits carry the exhaustive per-step tables, reproduced verbatim in the collapsed blocks below.

Two conventions in the Tests column: a criterion whose assertion is not in this MR says so and names the step that owns it, and a criterion split across layers lists the layer each test covers.

S08 acceptance criteria

AC Criterion Step Tests
#2 (closed) An absent --repository-kind behaves exactly as hosted 2, 3, 4a TestConfig_Validate_RepositoryKindEnum/empty-resolves-to-hosted, TestConfig_Validate_UpstreamFreeOnlyRules/false-under-zero-kind (library); TestTestDescriptor_NeedsUpstreamIsZeroValuedForHostedRows (descriptor set); TestRunCommand_RemoteConfigFieldMapping no-remote-flags row via assertKindResolvesHosted (CLI)
#3 (closed) --upstream-url under a resolved hosted kind is rejected, naming upstream-url 2, 4a TestConfig_Validate_UpstreamURLCrossField/hosted-explicit-with-upstream, /zero-kind-with-upstream; TestRunCommand_RemoteCrossFlagRejections (implicit- and explicit-hosted rows, exit 2, no case executes)
#4 (closed) --upstream-url equal to --registry-url under §Configuration's normalization is rejected, all four spellings 2, 4a TestConfig_Validate_UpstreamURLEquality (exact pair, trailing slash ×3, host case ×2, explicit default port ×2, scheme case, combined); TestRunCommand_RemoteCrossFlagRejections (4 rows). Accept side: TestRunCommand_RemoteConfigFieldMapping path and non-default-port rows
#5 (closed) --repository-kind outside {hosted, remote} exits 2 with the pinned string, case-sensitive 4a TestValidateRepositoryKind (7 rows), TestRunFlags_InputLayerErrorCases (2 wrapped rows incl. HOSTED), TestListCommand_RepositoryKindAllowList. Accept side: TestRunFlags_RepositoryKindAccepted, TestListCommand_RepositoryKindAccepted
#6 (closed) --upstream-url shape and no-userinfo, with the URL never echoed 2, 4a TestConfig_Validate_UpstreamURLShape (7 rows); TestValidateUpstreamURL (4 shape + 3 userinfo), TestRunFlags_InputLayerErrorCases, TestRunFlags_UpstreamURLNoUserInfoLeak (argv), ..._FromEnv (env source), TestOnUsageError_StripsUpstreamURLWrap (the cut alone), TestOnUsageError_CraftedMarkerDoesNotEchoValue (crafted marker, both spellings)
#7 (closed) https registry with http upstream rejected; both on http accepted 2, 4a TestConfig_Validate_UpstreamURLCrossField (4 scheme rows); TestRunCommand_RemoteCrossFlagRejections (downgrade row), TestRunCommand_RemoteConfigFieldMapping (http/http accept row)
#8 (closed) --upstream-free-only under a hosted kind, or alongside an upstream, names upstream-free-only 2, 4a TestConfig_Validate_UpstreamFreeOnlyRules (3 rows); TestRunCommand_RemoteCrossFlagRejections (2 rows)
#9 (closed) All three flags resolve from REGISTRY_CONFORMANCE_*, and an explicit flag wins 4a TestRunFlags_EnvVarBinding, TestListFlags_EnvVarBinding, TestRunFlags_RemoteFlagsResolveFromEnv (6 rows)
#10 (closed) Config.Validate returns the right *ConfigError.Field per rule, no I/O 2 TestConfig_Validate_RepositoryKindEnum, ..._UpstreamURLShape, ..._UpstreamURLCrossField/zero-kind-with-upstream, ..._UpstreamFreeOnlyRules, ..._UpstreamURLReasonNeverEchoesURL. CLI surfacing: assertConfigRejection
#11 (closed) list --format=<f> --repository-kind=<k> prints exactly the names run executes 4a TestPrintCatalog_ForwardsConfigToBothEnumerators, TestListConfig_MapsBothFlags (the flag-to-field wiring), TestListCommand_RepositoryKindAccepted, TestListCommand_NoNetworkIO (remote row). Set-changing half is not observable until a format branches; re-verified at Steps 8, 18, 28
#12 (closed) list --upstream-url and list --upstream-free-only each exit 2 with unknown flag: --<name> 4a TestListCommand_RejectsRemoteRunOnlyFlags, TestListFlags_Inventory
#15 (closed) SkipWithDetail yields a StatusSkip case carrying the refused write's response; every renderer still treats it as a skip 3 TestSkipWithDetail_ShapeAndFields, ..._HTTPDetailRoundTrips, ..._PanicsWhenBothBranchesPopulated, TestRunModule_SkipWithDetailPanicBecomesFailingCase, report.TestRenderStdout_SkipCarryingADetailStaysASkip, report.TestWriteJUnit_SkipCarryingADetailStaysASkip. The producer-side scrub is Step 5a's
#24 (closed) <format>.remote.preflight ordered first Owned by plan Steps 9+. Not in this MR. Referenced by gatedSkip's StartedAt rationale
#25 (closed) Remote kind with neither flag is rejected; the --upstream-free-only invocation validates and exits 0 2, 4a TestConfig_Validate_UpstreamURLCrossField/remote-with-neither, TestConfig_Validate_UpstreamFreeOnlyRules/free-only-under-remote-kind-without-upstream; TestRunCommand_RemoteCrossFlagRejections (neither-flag row), TestRunCommand_UpstreamFreeOnlyRemoteRunExitsZero, assertUpstreamFreeCases
#26 (closed) Every NeedsUpstream descriptor reports StatusSkip with no upstream available; its Fn is not called 3, 4a TestRunModule_NeedsUpstreamRowSkipsUnderBothStubShapes, ..._UpstreamGateLeavesUpstreamFreeRowsRunning, ..._NeedsUpstreamRowRunsWhenEnvExposesUpstream, TestRunModule_GateSkipReasonIsRunnerOwned (one row per format prefix), TestRunModule_GateSkipStampsStartedAtWithinTheRunWindow; end-to-end via assertGateSkippedCase / assertExecutedCase
#30 (closed) The derived --timeout default under a remote kind Owned by plan Step 4b. This MR pins the opposite: TestRunCommand_RemoteRunKeepsTwoMinuteTimeoutDefault
#31 (closed), #32 The settle-aware and upstream-free cancellation messages Owned by plan Step 4b. Not in this MR
#33 A non-nil TestCase.SetupFailure ends the run, carrying that row's own error value into both the return and Report.Interrupted 3 TestRunModule_SetupFailureStopsLoopAndCarriesTheRowsError (via assertSameError, which compares by value identity), ..._SetupFailureAbsentDoesNotStopLoop (fail / skip / pass). The cause sort is Step 5a's
#34 A defined-but-empty REGISTRY_CONFORMANCE_UPSTREAM_FREE_ONLY leaves a hosted run as the undefined case 4a TestRunFlags_UpstreamFreeOnlyEmptyEnvIsFalse (flag layer), TestRunCommand_UpstreamFreeOnlyEmptyEnvStaysHosted (Config layer), TestRunCommand_EmptyTimeoutEnvKeepsDefault
#35 NeedsUpstream assigned across ## Remote rows Owned by the per-format inventory steps (8, 18, 28). Not in this MR
#36 (closed) Under a resolved remote kind with an upstream Env and no seeding row completed: exit 2, ErrNothingEstablished wrapped with the branch that applied 3, 4a TestRunModule_NothingEstablishedWhenEverySelectedSeedingRowSkipped, ..._WhenCatalogHoldsNoSeedingRow, ..._DoesNotFire (7 rows), ..._ReadsTheEnvNotTheConfig, ..._SkipsAHostedRunWithAnUpstreamEnv, ..._SetupFailureShortCircuitPreemptsNothingEstablished, TestErrNothingEstablished_MessageIsCauseFree, run_remote_verdict_pairing_test.go (position pairing), report.TestWriteJUnit_FailingCaseSurvivesAnInterruptedRun; silence on an upstream-free run via TestRunCommand_UpstreamFreeOnlyRemoteRunExitsZero

S04 acceptance criteria and sections

Row Criterion Step Tests
AC #25 (closed) Empty cfg.RunID yields a RunID matching ^[a-f0-9]{16}$ 1 TestGenerateRunID_Shape, TestGenerateRunID_CollisionSanity (shape, distinctness, per-byte variance via assertEveryByteVaries), TestGenerateRunID_ReadError/source_exhausted_at_the_pre-widening_four_bytes, TestRunModule_GeneratesRunIDWhenEmpty. The CI_JOB_ID fallback is out of scope by the AC's own text; internal/cli's TestRunCommand_RunIDPriority owns it
AC #54 The run-window ordering on cases produced through runOne 3 Scoped by its own text to runOne, which the gate skip bypasses. TestRunModule_GateSkipStampsStartedAtWithinTheRunWindow asserts the property anyway; recorded as §Spec-amendment candidate Open 2
§Entry points SelectDescriptors returns the executed set in execution order, calls no Fn, and never validates its argument 3 TestSelectDescriptors_ReturnsExecutionOrderAndCallsNoFn, ..._CarriesTheNeedsUpstreamFlagThrough, ..._MatchesTheSetRunModuleIterates, ..._NeverValidatesItsArgument (3 distinct Validate rejections)
§Flag inventory The three flags registered on run in the documented order with their documented env keys; only --repository-kind on list 4a TestRunFlags_Inventory, TestListFlags_Inventory, TestRunCommand_Construction, TestRunFlags_EnvVarBinding, TestListFlags_EnvVarBinding, TestMain's scrub list
§Help text Each new flag's --help description is non-empty and single-line 4a TestRunFlags_HelpDescriptions (3 added rows)
§Where validated values go Each flag lands in its Config field 4a TestRunCommand_RemoteConfigFieldMapping, TestListConfig_MapsBothFlags
§TestDescriptor, TestCase, Status, Detail SetupFailure is additive and never replaces Status 3 TestTestCase_SetupFailureNeverReplacesStatus
§Upstream gate UpstreamEnv is an opt-in capability reached by type assertion 3 TestUpstreamEnv_StubSatisfiesEnvAndUpstreamEnv, TestUpstreamEnv_PlainEnvDoesNotSatisfyIt
§Config.Validate Rule ordering is deterministic when a Config violates more than one rule 2 TestConfig_Validate_RemoteRuleOrdering (4 boundaries), TestConfig_Validate_UpstreamURLShapePrecedesHostedForbids (the one boundary needing a Reason-level assertion, since both rules report upstream-url). The Credential-vs-remote-block boundary is deliberately unasserted; see §Spec-amendment candidates
§Error Cases Conflicting validation errors report the first 4a TestRunCommand_RegistryURLErrorPrecedesFilterError

Security considerations

Concern Step Tests
An embedded user:pass@ in UpstreamURL reaches the upstream on every seeding write 2 TestConfig_Validate_UpstreamURLShape/userinfo-*, asserted on Config.Validate with no CLI in the path
A validation message must not echo a credential-bearing URL 2, 4a TestConfig_Validate_UpstreamURLReasonNeverEchoesURL (one row per reason), TestValidate_NoFmtInterpolation, TestRunFlags_UpstreamURLNoUserInfoLeak, ..._FromEnv, TestOnUsageError_CraftedMarkerDoesNotEchoValue
The run credential must not reach the upstream in cleartext under an https registry 2, 4a TestConfig_Validate_UpstreamURLCrossField/https-registry-with-http-upstream, TestRunCommand_RemoteCrossFlagRejections (downgrade row, with the http/http accept row keeping the rule from over-reaching)
A CI variable reaches the flag, so the env source needs the same treatment as argv 4a TestRunFlags_UpstreamURLNoUserInfoLeak_FromEnv, TestRunFlags_RemoteFlagsResolveFromEnv, TestRunFlags_RepositoryKindEnvErrorIsBareSentinel
The gate skip's reason cannot interpolate a URL or a credential 3 TestRunModule_GateSkipReasonIsRunnerOwned pins the whole string, leaving no room for interpolated bytes
A SkipWithDetail envelope must not put a registry-supplied body on the operator's terminal 3 report.TestRenderStdout_SkipCarryingADetailStaysASkip (no response_body: line under a skip)
--upstream-url is part of the SSRF perimeter 4a TestValidateUpstreamURL (scheme, host, absolute-URL, userinfo), TestRunFlags_InputLayerErrorCases
An error message must not carry a control rune that splits the single-line stderr contract 4a assertNoUpstreamURLEcho, assertConfigRejection, TestListCommand_RejectsRemoteRunOnlyFlags (all check unicode.IsControl)
The SetupFailure detail scrub at construction Owned by plan Step 5a. Nothing populates the field in this MR; the field's doc comment names Step 5a as the only sanctioned producer and states that the exit-2 stderr write applies no redaction of its own
The credential reaches both base URLs (operator-facing warning); minimally-scoped credential; realm perimeter; nothing cleans up the upstream Guidance in README.md §Usage, landed in !231 (merged). In-binary half: TestRunFlags_UpstreamURLUsageNamesCredentialReach
No new secret material; redirect policy unchanged No code path configures the remote repository's own upstream credential, and the flag inventory tests pin that no flag was added for one. No client is constructed here; --allow-redirect-host's tests are unchanged
Step 1 per-step table, verbatim from defe393

Spec: docs/specs/S04-contracts.md, amended by docs/specs/S08-remote-contracts.md §S04 amendments

Step 1's spec surface is one AC. The other S04 and S08 rows on this branch belong to Steps 2, 3, 4a, 4b, 5 and 5a.

Acceptance criteria

# Criterion Tests
AC #25 (closed) RunModule with empty cfg.RunID returns a Report with RunID matching ^[a-f0-9]{16}$ TestGenerateRunID_Shape; TestGenerateRunID_CollisionSanity (shape, distinctness, per-byte variance); TestGenerateRunID_ReadError/source_exhausted_at_the_pre-widening_four_bytes; TestRunModule_GeneratesRunIDWhenEmpty (the RunModule half)
AC #25 (closed), second sentence CI-aware fallback to CI_JOB_ID is asserted by internal/cli tests, not by pkg/conformance tests Out of scope by the AC's own text. Owned by internal/cli: TestRunCommand_RunIDPriority, already merged and unaffected by the width

Error cases

Condition Tests
GenerateRunID fails: wrapped with fmt.Errorf("generate run id: %w", err) TestGenerateRunID_ReadError for the inner "read random bytes" layer; TestRunModule_GenerateRunIDErrorIsWrapped for the outer prefix, already merged and unaffected by the width
cfg.RunID non-empty but fails ^[A-Za-z0-9._-]{1,64}$: *ConfigError{Field: "run-id"} Already merged and unaffected: a 16-character generated ID satisfies the pattern, and the rule governs operator-supplied IDs. internal/cli's TestValidateRunID_* and TestRunFlags_RunID* own it

Security considerations

Concern Tests
Run-ID collision odds, per S04 §Run-ID resolution's 64-bit rationale and S08 §Fixture seeding model TestGenerateRunID_CollisionSanity. S04 §Security Considerations carries no run-ID row; the collision hazard is stated in §Run-ID resolution and its remote consequence in S08
The widened entropy surviving to the OCI repository coordinate pkg/conformance/oci's TestNormalizeRunID and TestNewEnv_WiresClientAndNormalizesRunIDSegment, already merged. normalizeRunID has no length cap, so the widened ID is not truncated

Resolved spec ambiguities: none in the spec. S04 §Run-ID resolution and AC #25 (closed) both state 16 characters from 8 bytes. One plan-acceptance reading was resolved without an operator call: "TestGenerateRunID_CollisionSanity varies all eight bytes" admits both a statement about the fixture and a requirement for an assertion, and assertEveryByteVaries satisfies both readings.

Step 2 per-step table, verbatim from 48f54bf

Spec: docs/specs/S08-remote-contracts.md, layering on docs/specs/S04-contracts.md. Scope: Step 2, the library half only. The CLI/stderr half of each AC below belongs to Steps 4a and 4b; the runner half of #25 (closed) to Step 3.

Acceptance criteria

# Criterion (library half) Tests
AC-3 Resolved hosted kind with UpstreamURL set is rejected, naming upstream-url TestConfig_Validate_UpstreamURLCrossField/hosted-explicit-with-upstream, /zero-kind-with-upstream
AC-4 UpstreamURL equal to RegistryURL under normalization is rejected, all four spellings TestConfig_Validate_UpstreamURLEquality (exact pair; trailing-slash-on-upstream, -on-registry, -at-root; host-case-only, host-mixed-case-only; explicit-default-port-https, -http; plus scheme-case-only and the combined row)
AC-6 UpstreamURL not absolute http/https with non-empty host, or carrying userinfo, is rejected TestConfig_Validate_UpstreamURLShape (unparseable, bad-scheme, missing-host, not-absolute, userinfo-user-and-password, userinfo-user-only, userinfo-empty-password)
AC-7 https registry with http upstream rejected; both on http accepted TestConfig_Validate_UpstreamURLCrossField/https-registry-with-http-upstream, /http-registry-with-http-upstream, /http-registry-with-https-upstream, /https-registry-with-https-upstream
AC-8 UpstreamFreeOnly under a resolved hosted kind, or alongside an upstream, names upstream-free-only TestConfig_Validate_UpstreamFreeOnlyRules/free-only-under-explicit-hosted-kind, /free-only-under-zero-kind, /free-only-alongside-upstream-under-remote-kind
AC-10 Validate returns the right *ConfigError.Field per rule with no I/O, verified with a zero-valued kind and a non-empty upstream, and with the userinfo Reason not carrying the URL TestConfig_Validate_RepositoryKindEnum, TestConfig_Validate_UpstreamURLShape, TestConfig_Validate_UpstreamURLCrossField/zero-kind-with-upstream, TestConfig_Validate_UpstreamFreeOnlyRules, TestConfig_Validate_UpstreamURLReasonNeverEchoesURL
AC-25 Remote kind with neither UpstreamURL nor UpstreamFreeOnly is rejected, naming upstream-url; the --upstream-free-only config validates TestConfig_Validate_UpstreamURLCrossField/remote-with-neither, TestConfig_Validate_UpstreamFreeOnlyRules/free-only-under-remote-kind-without-upstream
AC-2 An absent --repository-kind behaves exactly as hosted Partial, library half only: TestConfig_Validate_RepositoryKindEnum/empty-resolves-to-hosted, TestConfig_Validate_UpstreamFreeOnlyRules/false-under-zero-kind. The descriptor-set and exit-code halves are Step 3's and Step 4a's.

Error cases

# Condition (S04 §Error Cases, Library layer) Tests
E-1 cfg.RepositoryKind non-empty and not RepositoryKindHosted/RepositoryKindRemote TestConfig_Validate_RepositoryKindEnum/out-of-range, /wrong-case-hosted, /wrong-case-remote, /whitespace-padded
E-2 cfg.UpstreamURL malformed, userinfo, hosted-kind, equal, downgrade, or empty under remote TestConfig_Validate_UpstreamURLShape, TestConfig_Validate_UpstreamURLCrossField, TestConfig_Validate_UpstreamURLEquality
E-3 ... and the rendered Reason never carries the URL bytes, across all six reasons TestConfig_Validate_UpstreamURLReasonNeverEchoesURL (one row per reason, plus the zero-kind spelling of reason 3)
E-4 cfg.UpstreamFreeOnly true under a resolved hosted kind, or with a non-empty cfg.UpstreamURL TestConfig_Validate_UpstreamFreeOnlyRules
E-5 Returned before any I/O Structural: Config.Validate takes no context and no client, so no row can perform I/O. Not separately asserted.
E-6 Rule ordering is deterministic TestConfig_Validate_RemoteRuleOrdering (four rows). The Credential-vs-remote-block boundary is deliberately unasserted; see Spec-amendment candidates.
E-7 Flag-boundary rows (--repository-kind enum, --upstream-url malformed and userinfo stderr strings, the two list rows) Not in this MR. Steps 4a and 4b own internal/cli.

Security considerations

# Concern Tests
S-1 An embedded user:pass@ in UpstreamURL reaches the upstream on every seeding write TestConfig_Validate_UpstreamURLShape/userinfo-*, asserted on Config.Validate with no CLI in the path
S-2 A validation message must not echo a credential-bearing URL TestConfig_Validate_UpstreamURLReasonNeverEchoesURL, layered over the pre-existing TestValidate_NoFmtInterpolation
S-3 The run credential must not reach the upstream in cleartext under an https registry TestConfig_Validate_UpstreamURLCrossField/https-registry-with-http-upstream
S-4 The credential reaches both base URLs (operator-facing warning) Documentation-only in S08 §Security Considerations. No library behavior to assert.

Spec-amendment candidates, neither blocking and neither baked into an
assertion:

1. S04 §Error Cases' Library-layer `cfg.UpstreamURL` row says the
   rendered `Reason` "never carries the URL bytes", and the plan restates
   it as "no substring of `cfg.UpstreamURL` or `cfg.RegistryURL`". The
   literal every-substring reading is unsatisfiable: every string
   contains the empty string, and a `Reason` as plain as "must not be
   empty" shares single characters with any URL. The tests read it as the
   URL value and its distinctive components (host, port, path segment,
   userinfo), which is the strongest satisfiable form, and say so at
   `TestConfig_Validate_UpstreamURLReasonNeverEchoesURL`.
2. S04 places the remote rules after `Credential` in §Config.Validate's
   Rules list, but before the credential rows in §Error Cases'
   Library-layer table. `TestConfig_Validate_RemoteRuleOrdering` asserts
   only what both sections agree on (the remote block runs after
   `AllowRedirectHosts`) and leaves the credential boundary free, so
   either reading passes.

Refs docs/plans/2026-08-21-remote-conformance.md Step 2.

</details>

<details>
<summary>Step 3 per-step table, verbatim from <code>3c7e861</code></summary>

Spec: [docs/specs/S08-remote-contracts.md](docs/specs/S08-remote-contracts.md),
layering on [docs/specs/S04-contracts.md](docs/specs/S04-contracts.md).
Step 3 of [docs/plans/2026-08-21-remote-conformance.md](docs/plans/2026-08-21-remote-conformance.md).

**Acceptance criteria**

| #     | Criterion                                                                                                                                              | Tests                                                                                                                                                            |
|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AC-26 | Every `NeedsUpstream` descriptor reports `StatusSkip` with the reason `no upstream available` and its `Fn` is not called, under both no-upstream env shapes | `TestRunModule_NeedsUpstreamRowSkipsUnderBothStubShapes`, `TestRunModule_UpstreamGateLeavesUpstreamFreeRowsRunning`                                               |
| AC-26 | The gate does not fire when the `Env` answers `HasUpstream()` true: the `Fn` runs and its verdict is recorded                                            | `TestRunModule_NeedsUpstreamRowRunsWhenEnvExposesUpstream`                                                                                                        |
| AC-26 | The runner emits the reason, so the string is identical across all three format modules by construction rather than by convention                        | `TestRunModule_GateSkipReasonIsRunnerOwned` (one row per format prefix)                                                                                           |
| AC-33 | Runner half: a non-nil `TestCase.SetupFailure` ends the run, no further row executes, and the returned error and `Report.Interrupted` are the same value | `TestRunModule_SetupFailureStopsLoopAndCarriesTheRowsError`                                                                                                       |
| AC-33 | Runner half: a row that merely reports `StatusFail`, or `StatusSkip` with no `SetupFailure`, does not end the run                                        | `TestRunModule_SetupFailureAbsentDoesNotStopLoop` (fail / skip / pass rows)                                                                                       |
| AC-33 | Cause sort: which statuses are run-attributable or transient, on which attempt, and what resumes rather than restarts                                    | Owned by the shared seed-and-settle helper (plan Step 5a). Not tested in this MR.                                                                                 |
| AC-36 | Branch 1: rows were selected and every one skipped. `errors.Is` matches, the wrapped branch message is verbatim, and `Report.Interrupted` is that same value | `TestRunModule_NothingEstablishedWhenEverySelectedSeedingRowSkipped`                                                                                              |
| AC-36 | Branch 2, the empty-bucket case: the catalog holds no `NeedsUpstream` descriptor to select, so the run takes the verdict with its own branch message      | `TestRunModule_NothingEstablishedWhenCatalogHoldsNoSeedingRow`                                                                                                    |
| AC-36 | The sentinel's own message carries no cause                                                                                                             | `TestErrNothingEstablished_MessageIsCauseFree`                                                                                                                    |
| AC-36 | Does not fire: the `Env` answers `HasUpstream()` false; narrowing removed every `NeedsUpstream` descriptor the catalog offered; one such descriptor did not skip; a resolved hosted kind | `TestRunModule_NothingEstablishedDoesNotFire` (7 rows: both no-upstream env shapes, both narrowings, one-did-not-skip, both hosted spellings)                      |
| AC-36 | `Config.UpstreamURL` set but the `Env` answers false exits `0`: the upstream question is the `Env`'s alone                                               | `TestRunModule_NothingEstablishedReadsTheEnvNotTheConfig`                                                                                                         |
| AC-36 | A hosted `Config` with an `Env` answering true trips every other clause and must still exit `0`                                                          | `TestRunModule_NothingEstablishedSkipsAHostedRunWithAnUpstreamEnv`                                                                                                |
| AC-36 | The verdict's exit code supersedes a concurrent `StatusFail`, and the failing row survives in the report and the JUnit `<failure>`                        | `TestRunModule_NothingEstablishedWhenEverySelectedSeedingRowSkipped` (failing-row row), `report.TestWriteJUnit_FailingCaseSurvivesAnInterruptedRun`                |
| AC-36 | The verdict's exit code itself (`2`) supersedes the report's                                                                                            | Pre-existing `cli.TestExitCode` ("non-nil error wins over a failing report"). Unchanged by this MR.                                                               |
| AC-36 | A `RunModule` step-8 short-circuit returns its own error and never reaches the post-loop check                                                           | `TestRunModule_SetupFailureShortCircuitPreemptsNothingEstablished`                                                                                                |
| AC-15 | `SkipWithDetail` produces a `StatusSkip` case whose `Detail.HTTP` is populated, panics on both branches, and `runOne` turns that panic into a failing case | `TestSkipWithDetail_ShapeAndFields`, `TestSkipWithDetail_HTTPDetailRoundTrips`, `TestSkipWithDetail_PanicsWhenBothBranchesPopulated`, `TestRunModule_SkipWithDetailPanicBecomesFailingCase` |
| AC-15 | Every renderer still treats such a case as a skip                                                                                                      | `report.TestRenderStdout_SkipCarryingADetailStaysASkip`, `report.TestWriteJUnit_SkipCarryingADetailStaysASkip`                                                    |
| AC-15 | The seeding helper that populates `SetupFailure` scrubs the detail with both redaction passes at construction                                            | Owned by the shared seed-and-settle helper (plan Step 5a). Not tested in this MR.                                                                                 |
| AC-2  | A descriptor literal omitting `NeedsUpstream` keeps its current meaning, so no existing catalog entry changes behavior                                   | `TestTestDescriptor_NeedsUpstreamIsZeroValuedForHostedRows`, plus the pre-existing `pkg/conformance/...` suite staying green                                      |
| S04 §Entry points | `SelectDescriptors` returns the executed set in execution order and calls no `Fn`                                                            | `TestSelectDescriptors_ReturnsExecutionOrderAndCallsNoFn`, `TestSelectDescriptors_CarriesTheNeedsUpstreamFlagThrough`                                             |
| S04 §Entry points | It returns the same slice `RunModule`'s loop iterates, under no narrowing, `--filter`, and `--priority`                                      | `TestSelectDescriptors_MatchesTheSetRunModuleIterates`                                                                                                            |
| S04 §Entry points | It never validates its argument, which is what the derived `--timeout` keys on                                                               | `TestSelectDescriptors_NeverValidatesItsArgument` (3 distinct `Validate` rejections)                                                                              |
| S08 §Upstream gate | `UpstreamEnv` is an opt-in capability the runner reaches by type assertion                                                                  | `TestUpstreamEnv_StubSatisfiesEnvAndUpstreamEnv`, `TestUpstreamEnv_PlainEnvDoesNotSatisfyIt`                                                                      |
| S04 §`TestDescriptor`, `TestCase`, `Status`, `Detail` | `SetupFailure` is additive and never replaces `Status`, so `Counts` and `Failed` keep working                            | `TestTestCase_SetupFailureNeverReplacesStatus`                                                                                                                    |

**Error cases**

| Condition                                                                                                                          | Layer  | Tests                                                                                                            |
|------------------------------------------------------------------------------------------------------------------------------------|--------|------------------------------------------------------------------------------------------------------------------|
| `--repository-kind=remote`, no `--upstream-url`, `--upstream-free-only` set: fixture rows skip, Absence and Write-refusal rows run, exit `0` | Runner | `TestRunModule_UpstreamGateLeavesUpstreamFreeRowsRunning`                                                         |
| A row's seeding write is refused row-attributably: `StatusSkip` with the refusing status in the reason and the response as its `Detail` | Test   | `TestSkipWithDetail_ShapeAndFields` and `report.TestWriteJUnit_SkipCarryingADetailStaysASkip` cover the shape. Which statuses sort here is Step 5a's. |
| A row's seeding write is refused with `401`/`403`: exit `2` on the first attempt with that response as the detail                    | Runner | `TestRunModule_SetupFailureStopsLoopAndCarriesTheRowsError` covers the runner's half. The status sort is Step 5a's. |
| A row cannot seed for a transient reason: one more resumed attempt, then `SetupFailure` ends the run                                 | Test, then Runner | Runner half as above. The attempt-and-resume half is Step 5a's.                                        |
| Under a resolved remote kind with an upstream `Env` and no seeding row completed: exit `2`, `ErrNothingEstablished` wrapped with the branch that applies | Runner | `TestRunModule_NothingEstablishedWhenEverySelectedSeedingRowSkipped`, `TestRunModule_NothingEstablishedWhenCatalogHoldsNoSeedingRow`, `TestRunModule_NothingEstablishedDoesNotFire` |
| The five flag-boundary and `Config.Validate` rows                                                                                   | Flag boundary, Library | Landed in Step 2 (`config_test.go`, `validate_test.go`). Unchanged by this MR.                     |
| `--timeout` expires mid-run under a remote kind                                                                                     | Runner | Owned by the derived-timeout step (plan Step 4b). Not tested in this MR.                                          |

**Security considerations**

| Concern                                                                                                                       | Tests                                                                                                                                       |
|-------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| Redaction for what renders through `Detail` is unchanged; the two new surfaces (`SetupFailure` detail, the preflight `Message`) reach `Report.Interrupted`, the stdout `Interrupted:` line and the JUnit `<error message>` | Render-side userinfo pass: pre-existing `report.TestWriteJUnit_InterruptedRun_ScrubsReason`. The both-passes scrub at construction is Step 5a's. |
| The gate skip's reason is a runner-owned constant, so it cannot interpolate a URL or a credential                              | `TestRunModule_GateSkipReasonIsRunnerOwned` pins the whole string, leaving no room for interpolated bytes.                                   |
| The `SkipWithDetail` envelope reaching a skip does not put a registry-supplied response body on the operator's terminal        | `report.TestRenderStdout_SkipCarryingADetailStaysASkip` (no `response_body:` line under a skip)                                              |
| The credential reaches both base URLs; `--upstream-url` is part of the SSRF perimeter; no configured HTTPS downgrade; no new secret material; redirect policy unchanged; the upstream is written to and nothing cleans it up | Not this step's. Config validation landed in Step 2; the README warnings and the client wiring land with their own steps.                    |
Step 4a per-step table, verbatim from b9ec47c

Spec: docs/specs/S08-remote-contracts.md (§Configuration, §Error Cases, §Downstream amendments), layering on docs/specs/S04-contracts.md (§Error Cases' Input layer, §Validation rules, §Flag inventory). Step 4a of docs/plans/2026-08-21-remote-conformance.md.

Acceptance criteria

# Criterion Tests
AC-2 --repository-kind absent behaves exactly as hosted: no upstream, no upstream-free declaration, and a kind that resolves hosted TestRunCommand_RemoteConfigFieldMapping (the no-remote-flags row, via assertKindResolvesHosted), plus the pre-existing internal/cli suite staying green
AC-3 --upstream-url under a resolved hosted kind exits 2 naming upstream-url, and no test case executes TestRunCommand_RemoteCrossFlagRejections (implicit-hosted and explicit-hosted rows)
AC-4 --upstream-url equal to --registry-url under §Configuration's normalization exits 2. Verified for the exact pair, one trailing /, host case, and an explicit default port TestRunCommand_RemoteCrossFlagRejections (four rows, one per spelling)
AC-4 Accept side: a pair differing only after normalization validates TestRunCommand_RemoteConfigFieldMapping (path row, non-default-port row)
AC-5 --repository-kind outside {hosted, remote} exits 2 with repository-kind: must be one of hosted, remote, case-sensitive TestValidateRepositoryKind (7 rows), TestRunFlags_InputLayerErrorCases (2 wrapped rows, one of them HOSTED), TestListCommand_RepositoryKindAllowList
AC-5 Accept side: both values and an explicit empty value are accepted at the flag boundary TestRunFlags_RepositoryKindAccepted, TestListCommand_RepositoryKindAccepted
AC-6 --upstream-url that is not an absolute http/https URL with a non-empty host exits 2 with upstream-url: must be an absolute http or https URL TestValidateUpstreamURL (4 rejection rows, one per clause), TestRunFlags_InputLayerErrorCases (4 unwrapped rows)
AC-6 --upstream-url carrying userinfo exits 2 with S08 §Error Cases' string, and the message does not echo the URL. Both paths, which fail at different sites TestValidateUpstreamURL (3 userinfo spellings), TestRunFlags_InputLayerErrorCases (userinfo row), TestRunFlags_UpstreamURLNoUserInfoLeak (argv), TestRunFlags_UpstreamURLNoUserInfoLeak_FromEnv (env source), TestOnUsageError_StripsUpstreamURLWrap (the cut in isolation)
AC-7 An https --registry-url with an http --upstream-url exits 2 naming upstream-url TestRunCommand_RemoteCrossFlagRejections (downgrade row)
AC-7 Both on http is accepted TestRunCommand_RemoteConfigFieldMapping (http/http row, asserting cfg.Validate() == nil)
AC-8 --upstream-free-only under a resolved hosted kind, or alongside a non-empty --upstream-url, exits 2 naming upstream-free-only TestRunCommand_RemoteCrossFlagRejections (two rows, one per clause)
AC-9 All three flags resolve from their REGISTRY_CONFORMANCE_* variables, and an explicit flag wins TestRunFlags_EnvVarBinding / TestListFlags_EnvVarBinding (binding, off the inventories), TestRunFlags_RemoteFlagsResolveFromEnv (6 rows: env-only and flag-wins per flag)
AC-10 Config.Validate returns the three *ConfigError fields without I/O Landed in Step 2 (pkg/conformance/validate_test.go). This MR asserts only that the CLI surfaces them: assertConfigRejection checks errors.As and Field on every rejection row.
AC-11 list --format=<f> --repository-kind=<k> prints exactly the names run executes for that format and kind TestPrintCatalog_ForwardsConfigToBothEnumerators (the resolved kind reaches both enumeration methods), TestListCommand_RepositoryKindAccepted (both kinds). The set-changing half is not observable until a format branches its catalog; re-verified at plan Steps 8, 18 and 28.
AC-11 list performs no network I/O under a non-zero Config TestListCommand_NoNetworkIO (the remote row), and TestRunCommand_UpstreamFreeOnlyRemoteRunExitsZero for run under a remote kind
AC-12 list --upstream-url=<u> and list --upstream-free-only each exit 2 with unknown flag: --<name> TestListCommand_RejectsRemoteRunOnlyFlags, plus the order-sensitive TestListFlags_Inventory
AC-25 --repository-kind=remote with neither flag exits 2 with upstream-url: required when --repository-kind=remote, or pass --upstream-free-only, and no test case executes TestRunCommand_RemoteCrossFlagRejections (neither-flag row)
AC-25 The same invocation with --upstream-free-only runs, skips every fixture row, and exits 0 TestRunCommand_UpstreamFreeOnlyRemoteRunExitsZero, assertUpstreamFreeCases
AC-26 Under --upstream-free-only every NeedsUpstream descriptor reports StatusSkip with no upstream available and its Fn is not called Runner half landed in Step 3. Asserted here end to end through the CLI: assertGateSkippedCase / assertExecutedCase.
AC-30 The derived --timeout default under a remote kind Owned by plan Step 4b. Not tested in this MR. This MR pins the opposite: TestRunCommand_RemoteRunKeepsTwoMinuteTimeoutDefault asserts the registered 2m still applies, so nothing here cancels a run at zero.
AC-31 The settle-aware cancellation message Owned by plan Step 4b. Not tested in this MR.
AC-32 --upstream-free-only's unqualified cancellation message Owned by plan Step 4b. Not tested in this MR.
AC-34 A defined-but-empty REGISTRY_CONFORMANCE_UPSTREAM_FREE_ONLY leaves a hosted run exactly as the undefined case TestRunFlags_UpstreamFreeOnlyEmptyEnvIsFalse (flag layer), TestRunCommand_UpstreamFreeOnlyEmptyEnvStaysHosted (Config layer, including Validate)
AC-34 A defined-but-empty REGISTRY_CONFORMANCE_TIMEOUT likewise TestRunCommand_EmptyTimeoutEnvKeepsDefault. The "a remote run still derives its default" clause is plan Step 4b's.
AC-36 The verdict does not fire on an upstream-free run TestRunCommand_UpstreamFreeOnlyRemoteRunExitsZero (explicit errors.Is check). The firing branches landed in Step 3.
S04 §Flag inventory The three flags are registered on run in the documented order with their documented env keys, and only --repository-kind on list TestRunFlags_Inventory, TestListFlags_Inventory, TestRunCommand_Construction, TestRunFlags_EnvVarBinding, TestListFlags_EnvVarBinding, TestMain's scrub list
S04 §Help text Each new flag's --help description is non-empty and single-line TestRunFlags_HelpDescriptions (3 added rows)
S04 §Where validated values go Each flag lands in its Config field TestRunCommand_RemoteConfigFieldMapping

Error cases

Condition Layer Tests
--repository-kind outside the enum Flag boundary TestRunFlags_InputLayerErrorCases (2 wrapped rows), TestValidateRepositoryKind, TestListCommand_RepositoryKindAllowList
--upstream-url malformed Flag boundary TestRunFlags_InputLayerErrorCases (4 unwrapped rows), TestValidateUpstreamURL
--upstream-url carrying userinfo; the URL itself is not echoed Flag boundary and Config.Validate TestRunFlags_UpstreamURLNoUserInfoLeak, TestRunFlags_UpstreamURLNoUserInfoLeak_FromEnv, TestOnUsageError_StripsUpstreamURLWrap, TestValidateUpstreamURL. The Config.Validate half landed in Step 2.
--upstream-url set under a resolved hosted kind Library, surfaced as exit 2 TestRunCommand_RemoteCrossFlagRejections (2 rows)
--upstream-url equal to --registry-url Library, surfaced as exit 2 TestRunCommand_RemoteCrossFlagRejections (4 rows)
https --registry-url with http --upstream-url Library, surfaced as exit 2 TestRunCommand_RemoteCrossFlagRejections (downgrade row)
--upstream-free-only without the remote kind, or with an upstream Library, surfaced as exit 2 TestRunCommand_RemoteCrossFlagRejections (2 rows)
--upstream-url or --upstream-free-only passed to list Flag boundary TestListCommand_RejectsRemoteRunOnlyFlags
Programmatic Config with any of the above Library Landed in Step 2 (pkg/conformance/validate_test.go). Unchanged by this MR.
--repository-kind=remote with neither flag Library, surfaced as exit 2 TestRunCommand_RemoteCrossFlagRejections (neither-flag row)
--repository-kind=remote, no --upstream-url, --upstream-free-only set: fixture rows skip, Absence and Write-refusal rows run, exit 0 Runner TestRunCommand_UpstreamFreeOnlyRemoteRunExitsZero
A row's seeding write is refused row-attributably, run-attributably, or transiently Test, Runner Runner half landed in Step 3; the status sort is plan Step 5a's. Not tested in this MR.
Under a remote kind with an upstream Env and no seeding row completed: ErrNothingEstablished Runner Landed in Step 3. This MR asserts only that it stays silent on an upstream-free run.
The two URLs are not linked; the repository answers a read with an upstream-failure status Test Owned by the per-format preflight and relay rows (plan Steps 9+). Not tested in this MR.
--timeout expires mid-run under a remote kind Runner Owned by plan Step 4b. Not tested in this MR.
S04 §Error Cases: conflicting validation errors report the first Flag boundary, Library TestRunCommand_RegistryURLErrorPrecedesFilterError
S04 §Error Cases: unknown flag on run Flag boundary Pre-existing TestRunFlags_InputLayerErrorCases (unknown-flag row). Unchanged by this MR.

Security considerations

Concern Tests
The credential reaches both base URLs; the flag's one-line description names the behavior TestRunFlags_UpstreamURLUsageNamesCredentialReach pins S08 §Configuration's one-liner including the credential-reach sentence, which is the only in-binary half of the mitigation. The README warning landed in !231 (merged).
The repository under test is the least-trusted party; a minimally-scoped credential is recommended Guidance, not behavior: README.md §Usage, landed in !231 (merged). Not tested in this MR.
--upstream-url is part of the SSRF perimeter: it gets exactly the --registry-url treatment plus the no-userinfo check TestValidateUpstreamURL (scheme, host, absolute-URL, userinfo), TestRunFlags_InputLayerErrorCases. The CheckRedirect policy beyond the boundary is the library client's and is unchanged by this MR.
A CI variable reaches the flag, so the env source needs the same treatment as argv TestRunFlags_UpstreamURLNoUserInfoLeak_FromEnv, TestRunFlags_RemoteFlagsResolveFromEnv
The perimeter includes the realms the base URLs advertise, which no flag gates Documented in README.md §Usage (!231 (merged)) rather than enforced. Not tested in this MR.
No configured HTTPS downgrade TestRunCommand_RemoteCrossFlagRejections (downgrade row), with the http/http accept row keeping the rule from over-reaching
No new secret material Nothing to test: no code path configures the remote repository's own upstream credential, and the flag inventory tests pin that no flag was added for one.
Redaction is unchanged for what renders through Detail; the preflight Message and the SetupFailure detail scrub at construction Owned by plan Step 5a and the per-format preflight steps. Not tested in this MR.
Redirect policy is unchanged, and relay rows inherit its trap Not this step's: no client is constructed here. --allow-redirect-host's pre-existing tests are unchanged.
The upstream is written to and nothing cleans it up Guidance in README.md §Usage (!231 (merged)). No write happens in this step.
An error message must not carry a control rune that splits the single-line stderr contract assertNoUpstreamURLEcho, assertConfigRejection, TestListCommand_RejectsRemoteRunOnlyFlags (all check unicode.IsControl)

</details>

## 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 in the branch history and the branch is not squashed. The
`--no-verify` exception is used once per step, on the test-author commit only,
per CLAUDE.md and the `/implement-step` skill's Rules. On a batched branch that
is four commits (`defe393`, `48f54bf`, `3c7e861`, `b9ec47c`), which on a literal
reading of "the test-author commit", singular, looks like four violations. It is
not: the exception's rationale is the panic-skeleton that intentionally fails the
`go-test` hook, a property of a test-author commit rather than a per-branch
budget, and the skill's singular wording assumes one step per branch. Every
implementation, cleanup and docs commit on this branch ran the full gate.
The reasoning is the paragraph above; the rule and the scope of its
exception are in CLAUDE.md §Critical rules for changes and
`.claude/skills/implement-step/SKILL.md` §Rules.

The `validation/` ledgers are goal-run artifacts kept local and never
pushed, so the references to them here are provenance rather than links a
reviewer can open. Every reason they hold that bears on this MR is stated
inline above. Deferred findings are logged in `validation/decisions.md`
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; the run also produced a reference environment and eight
measured findings about the real target, none of which affect this batch (it has
no catalog rows) but which are ground truth for the row work in later batches.

One process note for anyone else working in this repo: `pre-commit run` is not
the whole gate. The `golangci-lint` hook lints only changed hunks, so a
whole-function linter (`funlen`, `gocognit`, `gocyclo`, `dupl`) whose subject is
the enclosing function is invisible to it. Step 3 pushed a function past
`funlen`'s budget while the hook reported clean; only running the pinned binary
over the package caught it.
Edited by Sylvia Shen

Merge request reports

Loading
Loading