feat(maven): upstream assembly and the fail-closed gates (S30 plan: 10c/25)

Summary

The upstream assembly for Maven virtual repositories: it turns the stored upstream list into one resolution's ordered position list, and it is where the fail-closed gates live.

Three things this MR establishes:

  • One fixed cost per read, scoped to the load and Phase 1. Assemble runs its batched hosted and remote lookups once per kind present, before any position is built, so the statement count over a 2-upstream and a 20-upstream repository is identical. Each remote position's health verdict rides the list join's columns, so reading it costs no statement at all. The per-position reads the resolver's later phases add are named rather than folded in: one credential read per probed position, and one cache-store read per consulted remote position when an outage reaches the fallback.
  • The drops and gates that decide eligibility, before any position exists. A flagged soft-deleted association is dropped up front and its record rides the result's dropped-set field to Step 19b's wide event as skipped_soft_deleted. A row whose kind is not recognized fails closed as virtual.NewUnexpectedKindUpstream; a row whose repository format is not maven becomes a data-error position through the wrong-format mark rather than an absence, so a wrong-format upstream withholds the 404 its miss would otherwise establish.
  • No credential is materialized by position construction. Each remote position's fetch flight is resolved lazily (Step 10a's fetcher), and the credential read stays inside the prober's BuildRequest, one statement per probed position. A denied or unhealthy position never calls its prober, so it costs no statement either. The open seam is measured rather than assumed: the credential counter is asserted apart from the fixed statement count.

Scope

One step of 25 in the S30 plan (Step 10c). It ships the assembly and its sidecar contract doc; it wires nothing. MavenVirtualUpstreamAssembly has no production caller yet — the resolution facade (Step 11b) drives it and the composition root (Step 15) composes it — so no route serves a virtual read as of this commit.

  • internal/format/maven/virtual_assembly.go (533 lines) — the ordered position list and the per-kind constructors: hosted and remote positions over Steps 5 and 6's adapters, the missing-binding and unexpected-kind orphan shapes, and the wrong-format mark from Step 10b.
  • internal/format/maven/virtual_assembly_contract.md (223 lines) — the sidecar, carrying the projection rule (the URL rides the list join, no credential is projected), the fixed-count regression surface, and the gates.
  • internal/format/maven/export_test.go (+19) and four test files (1,956 lines).

Reviewable size. 2,731 insertions across 7 files: 756 production and sidecar (virtual_assembly.go 533, the sidecar 223), 1,956 lines across four test files, and 19 in export_test.go. Past the 500-line guideline deliberately. The step's own content does not split into coherent review units — the fixed-cost proof, each fail-closed gate, and both list sizes need their own fixtures, and the step exists because bundling it with Step 10b overran the source ceiling (see that step's plan entry). A split here would separate the gates from the positions they gate.

Spec coverage

Spec: docs/specs/S30-maven-virtual.md (### Maven virtual resolution, and ### Acceptance Criteria → ### Resolution). Plan: docs/plans/2026-09-10-s30-maven-virtual.md, ### Step 10c.

The HTTP verdict each criterion feeds — 500 for an all-ineligible list, 404 for NoUpstreams — belongs to Step 11a's mapping and Step 11b's facade; the rows below assert the assembly's half.

Acceptance criteria

# Criterion Tests
AC-1 "An upstream of an unexpected kind fails closed" — ineligible, recorded as a data error rather than a policy denial TestMavenVirtualUpstreamAssembly_UnexpectedKindFailsClosedAlone (also asserts the verdict is never ErrFileNotFoundOnUpstreams), TestMavenVirtualUpstreamAssembly_ClassifiesEachKind/out-of-range_kind
AC-2 "An upstream of the wrong format fails closed too", with the 404 its miss would establish asserted not returned TestMavenVirtualUpstreamAssembly_WrongFormatDoesNotAnswerThe404ItsMissWouldEstablish, TestMavenVirtualUpstreamAssembly_ClassifiesEachKind/wrong_format
AC-3 "A data error withholds the not-found even where an absence was established" — the pair that separates a data error from a policy denial, with the cached-copy half owned by Step 17 TestMavenVirtualUpstreamAssembly_WrongFormatVetoesAnAbsence, TestMavenVirtualUpstreamAssembly_WellFormedDenyReturnsThe404TheDataErrorWithheld
AC-4 "A soft-deleted upstream cannot supply the fallback" — the drop this step owns; the fallback ruling itself is the resolver's TestMavenVirtualUpstreamAssembly_SoftDeletedUpstreamIsSkipped, TestMavenVirtualUpstreamAssembly_ExcludedRowsAreNamedToNoBatchedRead
AC-5 "An all-soft-deleted list is answered the same way" — the drop records and the ErrNoUpstreams verdict TestMavenVirtualUpstreamAssembly_AllSoftDeletedResolvesAsNoUpstreams
AC-6 "No upstreams configured" — the empty-list half TestMavenVirtualUpstreamAssembly_EmptyListAssemblesNoPositions
AC-7 "Per-read statement count is independent of upstream count" — over the load and Phase 1, with the per-probe credential read asserted apart and the outage-path fallback reads named in the sidecar on all three of serveOutage's paths (#289 (closed)) TestMavenVirtualUpstreamAssembly_StatementCountIsIndependentOfUpstreamCount (identical total at 2 and at 20, and the total itself), TestMavenVirtualUpstreamAssembly_ColdReadPaysOneCredentialPerProbedPosition
AC-8 "The health status costs no statement" TestMavenVirtualUpstreamAssembly_HealthCostsNoStatement, TestMavenVirtualUpstreamAssembly_TheCallersContextPaysOneHealthReadPerRemotePosition

The cached-copy half of AC-3 and AC-4 is not reachable from this branch's unit fixture: the assembly composes a real *datastore.MavenRemoteCacheStore, and the fixture's backing is a zero value whose first read panics, so no case here serves a cached copy. That half is Step 17's per the plan, whose entry carries both the [corrupt_a, hosted_b] pair and the soft-deleted fallback over a real cache row; Step 11b's entry carries neither.

Error cases

# Condition Tests
E-1 "Virtual repository has no upstreams, or every association was skipped as soft-deleted" → 404 mapping NoUpstreams The assembly half: TestMavenVirtualUpstreamAssembly_AllSoftDeletedResolvesAsNoUpstreams, TestMavenVirtualUpstreamAssembly_EmptyListAssemblesNoPositions. The response mapping is Step 11a's and Step 11b's.

Security considerations

# Concern Tests
S-1 "The recursion guard is defended twice" — the read-side half beside the association write's candidate check, with an unrecognized kind never recursed into TestMavenVirtualUpstreamAssembly_UnexpectedKindFailsClosedAlone, TestMavenVirtualUpstreamAssembly_ClassifiesEachKind/out-of-range_kind
S-2 "No credentials of its own" — no position's credential is materialized by construction, and a position that is never probed costs no credential read TestMavenVirtualUpstreamAssembly_ConstructionIssuesNoCredentialRead, TestMavenVirtualUpstreamAssembly_SkippedPositionsCostNoCredential, TestMavenVirtualUpstreamAssembly_ColdReadPaysOneCredentialPerProbedPosition

Mirroring npm's assembly suites

npm's three suites are the original: 25 top-level tests (virtual_assembly_test.go 17, virtual_assembly_refusal_test.go 7, virtual_assembly_property_test.go 1). 22 have Maven counterparts, most under the same name; AllThreeOrphanShapesInOneList is AllExcludedShapesInOneList, and ClassifiesEachKind gains a sixth case (wrong format) rather than losing one. Every enumerated value the npm suite covers as a positive case is covered here: both eligible kinds, both missing-binding shapes, and the out-of-range kind.

Three npm subtests have no counterpart by construction: RefusesAnUnsetPublicRegistryURL, UnsetPublicRegistryURLPassesAHostedOnlyList, UnsetPublicRegistryURLFailsAMixedList — npm's hosted binding carries a public registry URL and Maven's does not. Nothing else is dropped.

The Maven suite adds ten: UnexpectedKindFailsClosedAlone, WrongFormatDoesNotAnswerThe404ItsMissWouldEstablish, WrongFormatVetoesAnAbsence, WellFormedDenyReturnsThe404TheDataErrorWithheld, ConstructionIssuesNoCredentialRead, SkippedPositionsCostNoCredential, TheCallersContextPaysOneHealthReadPerRemotePosition, ExcludedRowsAreNamedToNoBatchedRead, AllSoftDeletedResolvesAsNoUpstreams, ReportsEachCompositionFault.

Where it sits now

S32's Step 10a (!2831 (merged)) and this plan's Step 10b (!2848 (merged)) have both merged to main; Step 10b landed as the squash c1641ea4b. The branch is rebased onto main, so the diff is this step's seven files and nothing else, and the pipeline runs against main. The retarget is done; nothing is owed but review.

Commits

Kept side by side so the authorship passes can be read separately: test(...) (the test floor, the one commit on this branch that carries --no-verify, because a panic-skeleton floor fails the go-test hook by design), feat(...) (the implementation), and refactor: simplify ... (the simplification pass). After those: the round-2 review commits (the composition-fault arms, the CHECK-admitted position shape, the Step 19b naming, and the two docs passes), the first review round's CI and review fixes (the position wait timeout the resolver requires, the batched-read key set, the remote document class taken from the layout, the two lookup key sets preallocated, the memo-less context's health read, and the cache seam's accounting), and the second review round's fixes (the compose faults naming their row, the rules-read log carrying the association ids, both recursion-guard halves at the kind arm, and the sidecar's compose-fault, plan-step, and fixed-count notes).

Governing ADRs

  • ADR-007 (database schema) — no schema, migration, or query change. The step consumes Step 4's reads and reads the columns off the list join; the new file contributes no SQL.
  • ADR-008 (content-addressable storage) — each per-position cache store is composed from the row the join already returned, and it is the read seam: a fill's upsert runs through the shared flight's own cache store, which writes the same cache row the standalone Maven route serves, so upstream content stays keyed by content hash. No new storage path.
  • ADR-021 (authorization) — no authorization decision is made here and no upstream credential is projected: the projection rule is stated in the sidecar and the token read stays inside the prober's BuildRequest, per probe. Authorization on the addressed repository is Step 9's.
  • ADR-023 (code structure and enforcement) — the new files sit in internal/format/maven/ per the package-per-feature layout, and golangci-lint (including the depguard boundary rules) passes on the package.

Testing

  • go test ./internal/format/maven/ green, and the full go test ./... suite green on the head. The test(...) floor commit is the branch's single documented --no-verify; every later commit ran the hook. The pre-fix commits fail the package suite in isolation only because the rebase moved them onto a main that requires PositionWaitTimeout, which the position-wait-timeout fixture commit sets.
  • 34 top-level tests in its three test files (virtual_assembly_test.go 26, virtual_assembly_refusal_test.go 7, virtual_assembly_property_test.go 1) over the fixtures in virtual_assembly_testsupport_test.go, mirroring npm's landed assembly suites and adapted to Maven's shapes rather than copied; the dropped-subtest audit is under Spec coverage above.
  • The two claims the step's acceptance names are asserted directly rather than inferred: the statement count is identical at 2 and at 20 upstreams, and the wrong-format read does not answer the 404 its miss would establish. The per-probe credential read is the one per-position statement a probing resolution still pays, and the suite pins it at one per probed position while the fixed count holds.
  • A composition-fault arm and a CHECK-admitted position shape were pinned in round 2, after review, and the compose faults naming their row and the rules-read log carrying the association ids in round 3.

e2e scenarios

No scenario added or affected: the assembly has no production caller until the composition root (Step 15) constructs it, the same reason Step 10a's MR gives for its adapters. docs/testing/ is unchanged.

User documentation

No user-facing surface changes. The assembly is an internal seam on a resolution path no route serves yet, so docs/user/** is unaffected by this MR.

Open calls

The review's unsettled calls are recorded on #289 (closed): the malformed-URL data-error mark, AC-7's fallback exemption, and the cached-copy test owner across Steps 11a/11b/17.

Related to #289 (closed)

Edited by Moaz Khalifa

Merge request reports

Loading
Loading