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, andcursordescriptions now also cover the artifact routes. Phase 1 operations reference the same components, so each widening had to stay true for them too.cursorlost its "over the sort column andid" clause because six of the seven new lists keyset on something else. - Path IDs use
ArtifactIdPathValue(minLength: 1, noformat: uuid) while responseidfields keepformat: uuid. A malformed ID has to stay expressible, because the spec answers it with404rather than400. PackageandFileareoneOfunions 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
npmsegment 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.mdlists adding a response field as non-breaking, and four S17 follow-ups (the S13cacheobject, 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
cacheproperty is declared. S13 finalizes its contents, and AC #27 (closed) (hosted responses carry nocachekey) is a serializer assertion the handler steps own. - No
readOnly: trueon the artifact schemas.readOnlyseparates response-only fields from request fields, and Phase 3 has no request body. PackageandFileareoneOfunions rather than one merged schema with optional fields. A merged schema would let a Maven row carry npm keys and still validate.- Response
patternconstraints appear only where a DB CHECK pins the length (digest 32 bytes, sha1 20, sha512 64, md5 16).gitlab_git_commit_shahas no length CHECK and the spec calls it opaque, so it carries no pattern. - Two
orderparameters exist because two lists default todesc. One shared parameter would document the wrong default on one set or the other. versionSortstates thatversionorders 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:openapiand assigns the sweeps to Steps 10-12. Recorded as a deliberate skip, not an omission. - Touching the
Formatenum, or composing it from newContainerFormatandPackageFormatschemas. The composition is the better shape, but the version that actually keeps the union exhaustive has to redefine Phase 1'sFormat, which four Phase 1 schemas reference. - Reconciling S12 with its own code. S12 says tag
updated_atis 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
sortparameter component torepositorySort. Clearer, but outside this step's declared file scope.