docs(openapi): add S17 Phase 3 artifact read contract

Why

S17 Phase 3 adds the read-only artifact surface beneath a hosted repository, and its consumers generate a typed REST client and mock against the schema before any handler exists. The contract lands first for that reason. Steps 10-12 validate their handlers against this document with kin-openapi, so a field missing here is a field that ships unchecked.

This is Step 1 of the merged Phase 3 plan, against the Phase 3 sections of S17.

What

Additive to the Phase 1 surface. Four spots worth a reviewer's eye:

  • The shared BadRequest, NotFound, and cursor descriptions now also cover the artifact routes. Phase 1 operations reference the same components, so each widening had to stay true for them too. cursor lost its "over the sort column and id" clause because six of the seven new lists keyset on something else.
  • Path IDs use ArtifactIdPathValue (minLength: 1, no format: uuid) while response id fields keep format: uuid. A malformed ID has to stay expressible, because the spec answers it with 404 rather than 400.
  • Package and File are oneOf unions with open branches. They discriminate because neither branch's required set is a subset of the other's, which is the invariant an additive field must preserve. The schema comment says so.
  • The two npm dist-tag paths carry a literal npm segment while their siblings template {format}. No two templates match the same request, so routing stays order-independent.

Two things the contract cannot tell the monolith consumer, both worth knowing before S05/S06 design a column around them. last_downloaded_at is only ever populated for npm today: the Maven bump is an explicit no-op stub and no writer exists for either container column. The version publish attribution (created_by, project_id, git_commit_sha) has no writer at all yet, so it reads null everywhere. Both are spec-tracked against S18 and S22 and the full auth integration, so the descriptions say "may be null" rather than encoding a status that would rot here.

Spec section Contract
API Contracts 13 GET operations, listContainerImages through getNpmDistTag
Artifact resources (Phase 3) ContainerImage, ContainerTag, ContainerManifest, MavenPackage, NpmPackage, Version, MavenFile, NpmFile, NpmDistTag
Artifact lists (Phase 3) nameSort, packageSort, manifestSort, versionSort, fileSort, orderDefaultDesc, includeReferrers
Error Cases Per-operation 400, 401, 403, 404, 500
AC #14 lint:openapi

This is 1125 reviewable LOC against the 500-LOC ceiling, none of it generated. The merged plan interrogated the seam and rejected a per-family split: the contract is one authoritative document whose shared components (error envelope, pagination parameters, Link header) are edited once, and redocly lints it whole. Splitting it would multiply the shared-component edits without shrinking what a reviewer has to hold in their head.

Test plan

Two CI jobs gate this file. lint:openapi runs redocly with struct and no-unresolved-refs as errors, and go_unittests picks it up through .changes-non-docs, so the Phase 1 contract tests load the document and run doc.Validate against it. Both pass locally, as does build-docs.

No e2e scenario catalog rows change. The catalogs under docs/testing/e2e/ cover protocol-client journeys (docker, oci), and artifact browsing is a UI journey that lands with the monolith S05 and S06 slices consuming these endpoints. Those slices own the catalog additions, which the merged plan's Testing Strategy records.

A scratch kin-openapi probe (the stack Steps 10-12 use) also built the gorillamux route table, matched all 13 operations, and response-validated sample bodies: oneOf discrimination between the Maven and npm package and file shapes, rejection of an uppercase-hex checksum, a bare digest with no sha256: prefix, a body missing a required key, and a manifest, version, or image leaking annotations, package_json, or a stray cache key. The probe is not committed, because the committed sweeps belong to Steps 10-12, whose routes do not exist yet.

Related to #312 (closed)

Context for LLM agents

Rationale

  • Response schemas stay open. Closing the nine artifact schemas was implemented, reviewed, and reverted: it buys a guard against a serializer leaking a withheld column, but docs/dev/api-style.md lists adding a response field as non-breaking, and four S17 follow-ups (the S13 cache object, an npm package.json projection, S18 download counts, S22 sizes) lean on that. The monolith is a Pact-verified consumer. The leak guard moves to the handler sweeps as explicit key-absence assertions.
  • No cache property is declared. S13 finalizes its contents, and AC #27 (closed) (hosted responses carry no cache key) is a serializer assertion the handler steps own.
  • No readOnly: true on the artifact schemas. readOnly separates response-only fields from request fields, and Phase 3 has no request body.
  • Package and File are oneOf unions rather than one merged schema with optional fields. A merged schema would let a Maven row carry npm keys and still validate.
  • Response pattern constraints appear only where a DB CHECK pins the length (digest 32 bytes, sha1 20, sha512 64, md5 16). gitlab_git_commit_sha has no length CHECK and the spec calls it opaque, so it carries no pattern.
  • Two order parameters exist because two lists default to desc. One shared parameter would document the wrong default on one set or the other.
  • versionSort states that version orders by database collation, not lexicographically. Nothing pins a collation on the version columns, so byte order is not guaranteed and mixed-case versions diverge between the two.

Non-goals

  • Registering routes or wiring handlers. Plan Steps 3 and 10-12 own those, and the contract deliberately lands ahead of them.
  • A test asserting the 13 operations exist. Two reviewers recommended one and it would pass today, but the merged plan scopes Step 1's gate to lint:openapi and assigns the sweeps to Steps 10-12. Recorded as a deliberate skip, not an omission.
  • Touching the Format enum, or composing it from new ContainerFormat and PackageFormat schemas. The composition is the better shape, but the version that actually keeps the union exhaustive has to redefine Phase 1's Format, which four Phase 1 schemas reference.
  • Reconciling S12 with its own code. S12 says tag updated_at is set on moves; the live upsert bumps it on every push, including the idempotent no-move branch. The contract states only what holds under both readings. Worth a follow-up with the S12 owner.
  • Renaming Phase 1's generic sort parameter component to repositorySort. Clearer, but outside this step's declared file scope.
Edited by Hayley Swimelar

Merge request reports

Loading
Loading