feat(virtual): data-error positions in the foundation (S32 plan: 10a/20)
What
Three data-error causes reach internal/virtual: an upstream whose format
is outside the container family, one whose stored url will not parse, and
one whose auth_status sits outside the taxonomy. Each row now becomes a
position the resolver classifies, rather than a row a slice drops. A dropped
row leaves the resolver free to answer 404 off a sibling's miss, which the
spec's fail-open rule forbids.
One constructor, NewDataErrorUpstream, serves all three. The reason records the cause, and the constructor validates the kind each cause permits. Each Reason is declared once in internal/virtual, and
each audit reason once in internal/remote, so no cause links cleanly and then
reports the wrong defect.
The eight per-position data-error log arms collapse into one Error line per
resolution. It carries path and namespace_id once, then one sub-record per
refused position holding position, the cause, upstream_repository_id,
upstream_kind, and exclusion_reason where the arm has one. The field set is
the union of what the arms carried, not the intersection.
No container code. Step 10b produces these positions; this step teaches the foundation to carry them.
Merge order
The blocking link to !2830 (merged) enforces the remaining wait. It holds this MR at
merge_request_blocked until !2830 (merged) merges.
- !2830 (merged) must merge before this MR merges. It extends the S32 spec's
data-error cause list. Until it lands,
ReasonMalformedUpstreamURLandReasonUnrecognizedAuthStatusdeclare causes the spec does not carry:docs/specs/S32-container-virtual.mdstill reads "Three things make a position a data error ... exactly these three". The plan's Step 10a gate asks for that amendment by name. - !2744 (merged) merged into
mainon 2026-09-21. This MR now targetsmain, so this wait is satisfied.
Scope beyond the step's planned files
The plan's Step 10a Files entry names internal/virtual/upstream.go,
rules.go, resolve.go, internal/remote/audit.go, and their test files.
Five further paths changed, each forced by the change rather than chosen:
internal/virtual/collect.goandcollect_internal_test.go— the virtual-collection-mode plan owns them. TheirexcludedOutcomeswitch overReasonisexhaustive-linted, so the three new members break the build until they are mapped. Each takes the arm the four existing data-error reasons take.internal/virtual/orphan_upstream_shapes.md— the sidecar the constructor, the resolver, and the observability row all cite for the shapes, the field union, and the record volume.internal/virtual/decision.go— a doc comment onfailedthat enumerated row defects and would otherwise go stale.internal/format/npm/virtual_operations_test.go— a test constant naming a log message the collapse retires.internal/remote/operations_test.go— theUpstreamExclusionReasonstringer table lives here, not besideaudit.go.
docs/dev/observability.md
The plan gives Step 17 sole writership of this file and Step 12 a row in it.
This MR adds the catalog's first record of the field, including its five retired message names so a future alert author's search reaches the current record. The plan amendment recording that goes to the batched docs(plans) MR, never to this branch.
Size
1476 reviewable LOC, about 2.9x the 500 ceiling in
docs/dev/development-model.md. Split: Go source 274, tests 990, docs 212.
A split would not help. The plan's Seam interrogation asked and answered
negative: a constructor with no Reason value produces a spec nothing
classifies, a Reason value with no excludePosition arm sets no
dataError, and an arm with no audit reason excludes a position silently.
Any cut lands a half that cannot be asserted. The three causes do not cut
apart either — they share one const block and one classification switch.
Testing
Resolver-level tests drive the whole withhold-the-404 property through the
package's existing doubles, per reason: the classification, the audit event,
and the composed resolution outcome. The matching-shape control is a
well-formed deny rule, which does answer 404; that pair pins the
difference between a data error and a policy denial. The collapsed line is
asserted against a resolution excluding positions under arms with different
field sets, because a collapse to the intersection passes any single-arm check.
No e2e scenario changes. docs/testing/ catalogs customer-visible request
flows, and this step adds no route, status, or response shape — the withheld
404 becomes visible to a client only when Step 11 maps the verdict.
No user documentation changes. Nothing here is customer-callable or customer-configurable: the reasons are internal classifications and the record is a server-side log line.
ADR freshness
scripts/adr-freshness.sh exits 1 on six unsynced handbook commits touching
ADR-007, 009, 012, 020, and 021. All six were read at the source; none governs
virtual resolution, exclusion reasons, or log shape. One internal ADR never
mirrors locally and stayed unverified.
Related to #291 (closed)