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.

  1. !2830 (merged) must merge before this MR merges. It extends the S32 spec's data-error cause list. Until it lands, ReasonMalformedUpstreamURL and ReasonUnrecognizedAuthStatus declare causes the spec does not carry: docs/specs/S32-container-virtual.md still reads "Three things make a position a data error ... exactly these three". The plan's Step 10a gate asks for that amendment by name.
  2. !2744 (merged) merged into main on 2026-09-21. This MR now targets main, 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.go and collect_internal_test.go — the virtual-collection-mode plan owns them. Their excludedOutcome switch over Reason is exhaustive-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 on failed that 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 — the UpstreamExclusionReason stringer table lives here, not beside audit.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)

Edited by Radamanthus Batnag

Merge request reports

Loading
Loading