docs(specs): S16 states what the credential read does and answers

What

S16's credential_read stage arrived with the metric domain and nowhere else. This MR carries it through the rest of the spec: the design narrative a reader meets first, the client-facing status in Error Cases, the matching acceptance criteria, and the leg the observability prose books.

The code side is already on main. The S04-A per-format credential columns Step 7 MR merged as 883a317ad and shipped both the read leg, assertCredentialsReadable in internal/format/oci/remote_auth.go, and its credential_read booking. So the spec is the lagging side here, and every disagreement this MR removes is live on main rather than pending a merge.

The stage domain

gitlab_artifact_registry_oci_remote_auth_discovery_failures_total declared stage as four values; the credential read books a fifth. The family's description widens with it. Four of the stages sit inside challenge discovery, and this one precedes them, because the unknown-verdict arm reads the stored credential before it sends a discovery HEAD. A refused read therefore ends the acquisition with no upstream contact at all.

Two counting arguments in the same section follow the new value: four of the five legs attempt no write, and all five would move no oci_remote_* metric without this family.

Why the value is not on the exchange families

Booking the unknown arm's refusal on oci_remote_token_exchanges_total would also put a leg that reached no exchange into oci_remote_token_exchange_duration_seconds, because one helper observes both families. That contradicts the histogram's own rule, which is that it observes the outcome an exchange reached.

The bearer arm is the other half of that, and the section now says so. A row already memoized bearer sends no discovery HEAD and reads the credential inside the exchange, so a refusal there is a local refusal that reached no endpoint and books internal_error on both exchange families. The split is by leg, not by error class, so it does not follow the client-facing status. The 404 re-discovery path reads the same way, and a still-cached token books nothing at all.

The same passage loses a false quantifier. "the one stage an operator must not read as an upstream fault" is a claim over all five stages and fails on write_failed, which is this service's own storage, and on transport, which covers a HEAD this service could not send.

The design narrative

Auth-challenge and token-exchange opened the unknown-verdict arm with the unauthenticated HEAD, in the prose and in the sequence diagram alike, so a reader following the design section rather than the metrics appendix had no way to learn the step exists. Challenge discovery. now states the read and why it runs there: S13's fail-closed rule applied ahead of the verdict write, whose own table puts bearer discovery over a credential-bearing row on the failing arm. A row holding no credential unit reads as anonymous rather than refusing, which is what keeps an anonymous remote on this path.

Token exchange. states its own read: per exchange rather than once when the authenticator was built, the only read on the bearer path, nothing read on a token-cache hit, and a refusal failing before the GET is built. The diagram gains the three reads that match, plus one note for what a refusal does wherever it lands.

The client-facing status

Error Cases said nothing about what a client receives when the read refuses, so the zero-status UpstreamAuthError arm answers cache-or-503 for a permanent refusal and a transient read error alike. Two rows now split them.

A refusal by policy answers 500 INTERNAL, with no cached copy and no Retry-After. That is the reasoning three rows of this table already give. The half-set credential row answers 500 because the fault never clears on retry and no request left this service, so S13's cache-fallback definition does not reach it, and the kind=2-with-no-row and manifest read-back rows cite it for the same status. A single cache-or-503 row would have sat in a table that argues against it.

A read that failed transiently keeps the cache-or-503 UNAVAILABLE route with Retry-After, justified on its own ground rather than borrowed. The half-set row's second reason, no request leaving this service, holds for a datastore read error too, so it cannot be what admits the cache. What admits it is that the next read can reach the upstream again.

The discriminator is remote.ErrCredentialsRefused, not datastore.ErrEncryptionNotConfigured alone. That sentinel carries the marker, and so do the permanent decrypt verdicts, which are just as permanent; keying on the one sentinel would have left them on the answer these rows reject. The Resolutions entry records that widening against the narrower reading the review started from.

Acceptance Criteria gains two criteria: the status split under Error mapping, and one asserting that a refused read on the unknown arm issues no upstream call at all and memoizes no verdict.

The obligation this creates

The 500 row is a claim about a mapping that nothing makes yet. isCredentialConfigurationFault in internal/format/oci/remote_errors.go answers ahead of the zero-status handshake arm, and at origin/main 9b2540d92 it lists remote.ErrIncompleteCredentials and ErrCredentialUsernameColon only. That sha is after 883a317ad, which left remote_errors.go untouched.

While the predicate lists those two only, a refusal by policy takes the transient answer. Once it matches remote.ErrCredentialsRefused, the 500 row is what a client receives, and the transient row keeps its own answer either side of that change. Both rows are written that way so they read correctly whichever change closes the gap.

Follow-ups names the obligation, says no open MR carries it, and gives #417 (closed) as the owner. A note on #417 (closed) records the residual. No new issue is filed, and no new sentinel is needed: the marker is already internal/remote's own discriminator for a refusal by policy rather than a transient failure.

Merge order

None is required. The code side is merged, the spec change is docs-only, and nothing downstream waits on it.

git merge-tree against origin/main at 9b2540d92 reports no conflict. Two entries added to the Retry-After enumerations were dropped again before this landed: 628e76758 rewrote both lists from exhaustive into "Among them:" forms, so an entry per new condition is no longer owed at either site, and the retry hint for a transiently failed read is stated on its own row and in its own criterion instead.

Related to #417 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading