docs(specs): S13 takes rule scoping, kind eligibility, and Retry-After

What

Three S13 amendments, each driven by a virtual slice reaching a reading the foundation had not written down.

1. target_field scoping moves into the foundation. A request does not always carry every field its format defines — an npm packument request carries no version, a container request addressed by digest carries no tag. Rule evaluation is scoped to the fields the request does address, and a new Scoping to the fields a request addresses subsection states that the foundation performs the scoping: a slice supplies its format's field set together with the values the request carries, and this layer decides which rules that leaves in play.

2. The Retry-After value is fixed at health_check.scheduled_interval rounded up to the next whole second, 300 under the default configuration, for every 503 mapped from UpstreamUnavailable, from AllUpstreamsUnhealthy, or from the blocked Fetch path on an unhealthy remote.

3. Upstream-kind eligibility becomes a resolution-time concern, and the ineligibility aggregates split. Phase 1 said a virtual upstream (recursion) "is rejected by S17 at association time and is not a resolution-time concern". No foreign key can restrict kind, and S17's association guard does not exist on every surface yet, so an upstream inserted by direct SQL reaches resolution and recurses on a client-reachable read path. Phase 1 now checks kind and fails the position closed on the corrupt-rule arm. With it, AllUpstreamsDenied narrows to the case where every ineligible position was a policy denial, and a new AllUpstreamsIneligible covers a set where any position's ineligibility was a data error. The recognition of an out-of-range kind is the slice's, since the loader is what reads the column; what it shares with target_field's division is that the row is handed to the foundation as a position-level data error, never dropped.

Also writes down the invalid-rule verdict the matcher already implements but no approved spec stated: a target_field naming no field its format defines, or an unrecognized rule_type, makes the upstream ineligible and is logged as a data error rather than audited as a policy denial. What that pairing means is stated once, in full: the position is error-logged, and the exclusion is recorded in the same audit stream a denial goes to, under a data-error reason rather than the policy-denial one. The reasons are per cause: the kind exclusion records a reason naming the kind defect, distinct from the corrupt-rule one, so an operator is not sent hunting a corrupt rule when the defect is the row's kind.

Also aligns merged S31 with the two rules this amendment sharpened after S31 merged. S31 stated health_check.scheduled_interval "in whole seconds", which agrees with the round-up at the 5m default and diverges below one second, where truncation gives the Retry-After: 0 the round-up exists to prevent; and it carried the same data-error shorthand, in the document an npm test author reads, at seven sites in all: two asserted that no policy-denial audit event is emitted, which reads as an empty audit sink, while the shipped resolver records the exclusion.

Also corrects the References entry that attributed the same-namespace composite-FK constraint to ADR-022, which does not contain it: ADR-007 states it per table, applying ADR-001's namespace scoping. Raised in !862 (merged)'s review, where the gloss had been copied from here.

Why

The scoping. !862 (merged) (S31 npm virtual) and !972 (merged) (S32 container virtual) each specified a slice-side filter over the rule set, because the matcher evaluates every rule it is handed. Two slices implementing one filter is what places it below both. A slice-side filter also cannot keep the fail-closed arm: a rule dropped before the matcher runs is a rule the matcher cannot reject, so a corrupted deny would stop denying with nothing logged — a fail-open on the exact value the range CHECK on the column exists to catch. Both slices drop their filter once this lands.

The value. S13, S15, and docs/dev/api-style.md each require the header and none states a number, so no slice could write a criterion asserting one. scheduled_interval is the earliest point at which a probe can change the status the client is retrying against, so a shorter value only buys a repeat of the same answer; deriving it from an existing knob avoids a second knob that could be configured to contradict the first.

The kind check and the split. Raised in review of !862 (merged): S31 specified the guard and the reviewer's point was the layering, since S13's standing text says the opposite. The aggregate split rides with it because a single "nothing was eligible" verdict forces a slice to choose between reporting an absence it never established and reporting an error on a decision the rules did make — and npm caches a 404 as a hard E404 it never retries, so a corrupt rule set answered that way fails every install permanently. Splitting is only possible in the foundation: the per-position reasons exist inside EligibleUpstreams and are collapsed before a slice sees them.

Consequences

internal/virtual/rules.go needs Coordinate to distinguish an unaddressed field from a corrupt target_field — today evaluate fails closed on any absent key. Coordinate has no production caller outside internal/virtual, so the change is cheap now and more expensive once npm and container both wire it.

internal/remote/errors.go needs a second sentinel next to ErrAllUpstreamsDenied, and loadAndFilter needs to pick between the two from the Reason values it already has — today it passes through whatever EligibleUpstreams returned, which is ErrAllUpstreamsDenied for both causes. A slice also needs a way to hand a kind-ineligible position to the resolver on the same arm.

internal/remote/audit.go needs a kind-defect value beside ExclusionInvalidRule in UpstreamExclusionReason, and the doc comment on UpstreamDenialEvent, which enumerates the causes the event covers, widens with it.

All three are tracked in the related work item.

Status stays Approved; this amends an approved spec rather than proposing a new one.

Test plan

Docs-only. markdownlint, Vale, and lychee pass in pre-commit. The new acceptance criteria (a rule on an unaddressed field does not decide, a corrupt target_field fails closed, an upstream of an unexpected kind fails closed, the two aggregates are distinguishable, the Retry-After value) are the testable form of the change.

Related to #609 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading