feat(config): carry auth.gitlab_api into the runtime config
Step 1 of the GitLab API service-auth plan: the auth.gitlab_api config block — a ServiceTokenConfig proto message carrying the service_token / next_service_token SecretRef pair, the loader change that carries it unresolved into config.AuthConfig.GitlabAPI (the iam.service_token pattern), and the example + configuration-reference entries.
No behavior change: nothing consumes the block; the plan's Step 2 mounts the /api/gitlab/v1 service-token guard that reads it. A present block rides alongside whichever of bootstrap_token/token_exchange is set and does not satisfy the loader's exactly-one rule; a token-less block fails protovalidate at load (service_token is schema-required when the block is present, the iam.service_token precedent).
Plan amendment followup: !1672 (merged)
Diffstat note: 167 reviewable LOC of 513 total — 346 lines are regenerated gen/.../config.pb.go.
E2E scenario impact: none. The catalogs under docs/testing/e2e/ cover the client-facing format surfaces; this step changes no request path.
Spec coverage (from the test-author commit)
Spec coverage
Spec: docs/specs/S33-gitlab-api.md
Step 1 of the plan is schema and loader only: no request path changes. Every spec row about request behavior is either already covered by the merged S33 Phase 1 handlers or lands with Step 2's middleware swap.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | POST /namespaces valid slug, unused anchor: 201 + row + resource |
Merged S33 Phase 1 (internal/gitlabapi/create_test.go). Unchanged here. |
| AC-2 | Same-anchor replay: 200, unaltered row |
Merged S33 Phase 1 (internal/gitlabapi/create_test.go). Unchanged here. |
| AC-3 | Same-anchor replay with disagreeing body: 409 |
Merged S33 Phase 1 (internal/gitlabapi/create_test.go). Unchanged here. |
| AC-4 | Brand-List Validator unreachable: 503, replay precedence |
Merged S33 Phase 1 (internal/gitlabapi/create_test.go). Unchanged here. |
| AC-5 | Slug syntactic/reserved/brand rejection: 422, replay precedence |
Merged S33 Phase 1 (internal/gitlabapi/create_test.go). Unchanged here. |
| AC-6 | Slug taken by another namespace: 409 |
Merged S33 Phase 1 (internal/gitlabapi/create_test.go). Unchanged here. |
| AC-7 | GET /namespaces/:uuid: anchor, slug, status; 404/400 |
Merged S33 Phase 1 (internal/gitlabapi/detail_test.go). Unchanged here. |
| AC-8 | Six condition endpoints set/clear their own column | Merged S33 Phase 1 (internal/gitlabapi/conditions_test.go). Unchanged here. |
| AC-9 | Serviceability gating on client surfaces | Merged S33 Phase 1. Unchanged here. |
| AC-10 | Serviceability gating on management surfaces | Merged S33 Phase 1. Unchanged here. |
| AC-11 | POST .../repositories/verifications: 204/422/400 |
Merged S33 Phase 1 (internal/gitlabapi/verifications_test.go). Unchanged here. |
| AC-12 | Resolution/conditions/verifications work in every state | Merged S33 Phase 1. Unchanged here. |
| AC-13 | OpenAPI defines every endpoint and validates in CI | Merged S33 Phase 1 (internal/gitlabapi/contract_test.go) + CI. Step 2 amends the security scheme. |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Missing or invalid service token: 401 unauthorized |
Step 2 (internal/auth/servicetoken/servicetoken_test.go). This step lands only the config block the guard reads: TestLoad_Auth_GitlabAPI pins the schema and loader contract, no request path. |
| E-2 | Malformed JSON body: 400 |
Merged S33 Phase 1. Unchanged here. |
| E-3 | Provision slug validation failure, anchor free: 422 |
Merged S33 Phase 1. Unchanged here. |
| E-4 | Provision slug taken: 409 |
Merged S33 Phase 1. Unchanged here. |
| E-5 | Provision replay body disagrees: 409 |
Merged S33 Phase 1. Unchanged here. |
| E-6 | Brand-List Validator unreachable, anchor free: 503 |
Merged S33 Phase 1. Unchanged here. |
| E-7 | :uuid not canonical: 400 |
Merged S33 Phase 1. Unchanged here. |
| E-8 | Unknown namespace UUID: 404 |
Merged S33 Phase 1. Unchanged here. |
| E-9 | Unknown <action> segment: 404 |
Merged S33 Phase 1. Unchanged here. |
| E-10 | Body over the request size cap: 413 |
Merged S01/S33 Phase 1. Unchanged here. |
| E-11 | Verifications empty/oversized/non-canonical: 400 |
Merged S33 Phase 1. Unchanged here. |
| E-12 | Verifications id unknown or outside namespace: 422 |
Merged S33 Phase 1. Unchanged here. |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Internet-facing, service token the only guard, opaque 401 |
Guard behavior is Step 2 (internal/auth/servicetoken/servicetoken_test.go). This step pins the secret-handling half: TestLoad_Auth_GitlabAPI asserts the loader carries SecretRef references (env-var names), never a token value, so no plaintext enters the config surface. |
| S-2 | Verifications 422 names failing ids but never the reason |
Merged S33 Phase 1. Unchanged here. |
| S-3 | No organization data leaves the registry | Merged S33 Phase 1. Unchanged here. |
| S-4 | Slug input crossing into SQL and the Brand-List Validator | Merged S33 Phase 1 + namespaces CHECK constraints. Unchanged here. |
Plan Step 1 acceptance
| Clause | Test |
|---|---|
| A config file with the new block loads and exposes the SecretRefs | TestLoad_Auth_GitlabAPI/alongside_bootstrap_token_with_both_refs, TestLoad_Auth_GitlabAPI/alongside_token_exchange_without_the_rotation_ref |
A block with only gitlab_api still fails the exactly-one rule |
TestLoad_Auth_GitlabAPI/without_either_validator |
Omitting gitlab_api loads clean |
TestLoad_Auth_GitlabAPI/omitted |
```markdown
## Spec coverage
Spec: [docs/specs/S17-rest-management-api.md](docs/specs/S17-rest-management-api.md)
Plan: [docs/plans/2026-08-10-s17-phase4-artifact-writes.md](docs/plans/2026-08-10-s17-phase4-artifact-writes.md), Step 12
Scope: the `internal/datastore` composer. Acceptance criteria are numbered by
position; the Phase 4 block opens at 30.
### Acceptance criteria
| # | Criterion | Tests |
|---|-----------|-------|
| AC-32 | Each delete removes the full subtree once its removal has run, and the parent row goes last: an npm version delete removes the dist-tags pointing at it | `TestNpmVersionManagementDeleter_DeleteVersion_Subtree/marks the version and its files, removes its dist-tag, and leaves the sibling version whole` (version and both files marked, dist-tag row gone, sibling version's rows untouched) |
| AC-36 | An npm version delete expires the package's `npm_metadata_files` cache in the transaction that marks the rows, so a packument read after the delete does not list the removed version | `TestNpmVersionManagementDeleter_DeleteVersion_ExpiresThePackumentCache` (both subtests, all three cache kinds asserted as a hit before and a miss after through `NpmMetadataFileByPackageAndKind`, the freshness-filtered read). The npm **file** delete half is Step 13's; the bulk-job half is Step 36's |
| AC-37 | Deleting an npm package's last active version removes the package | `TestNpmVersionManagementDeleter_DeleteVersion_OrphanRule` (both subtests: last active version marks the package, one of several does not). The `delete_all` half is Step 36's; the Maven negative is Step 14's |
| AC-31 | `DELETE` returns `202`, the target is absent from its list and `404` on detail, a missing target returns `404` | Handler step (Step 24). The composer's share of the missing-target arm: `TestNpmVersionManagementDeleter_DeleteVersion_UnresolvedTargetWritesNothing` (an unresolved target is a benign no-op the handler maps, not an error) |
| AC-33 | An interrupted reap leaves the target marked and the next pass completes the subtree | S20-A's purger. No test here, per the plan's Dependencies |
| AC-38 | Whatever removes a row removes its `blob_storage_attachments` row in the same step; a marked but unreaped artifact still holds its attachment | Not this step's write. A version delete marks `npm_versions` and `npm_files` and removes only `npm_tags`, which owns no attachment, so the composer performs no attachment delete. The cache rows and their attachments surviving is asserted in `..._Subtree` (row count unchanged) and `..._ExpiresThePackumentCache`; the marked-row-keeps-its-attachment half is Step 10's |
| AC-54 | A management delete leaves `versions_count` and `tags_count` equal to what the equivalent protocol operation would leave | The composer supplies the caller's inputs and writes no counter itself: `..._Subtree` asserts `tagsRemoved == 1` with a sibling tag present and both counter columns unchanged across the delete, `..._OrphanRule` pins `packageDeleted` both ways, `..._Idempotency` pins `(0, false)` on a repeat. The dispatch that applies them is a later managementapi step |
| AC-55 | One `artifact_registry_artifact_deleted` per named artifact | Handler step (Step 24). The composer emits nothing |
| AC-56 | Bulk delete applies its entries in a job | Worker steps (Steps 35-37) |
| AC-1 to AC-30, AC-34, AC-35, AC-39 to AC-53, AC-57 to AC-70 | Phase 1 and Phase 3 surface, the container and Maven families, the contract documents, and every route-level, bulk, and tag-upsert criterion | Not this step. No route, contract, or container surface changes here |
### Behavior obligations from the spec prose
| # | Obligation | Tests |
|---|------------|-------|
| B-1 | "What a delete removes", npm version row: `npm_tags` pointing at that version, `npm_files` for it, then the version | `..._Subtree` (both files, not just the first; the dist-tag row removed outright because `npm_tags` carries no marker column) |
| B-2 | "What a delete removes": `npm_metadata_files` references the package only, so it survives a version delete | `..._Subtree` (row count unchanged), `..._ExpiresThePackumentCache` (rows present and expired, not removed) |
| B-3 | "Removal is deferred behind a marker": an npm version delete writes what the unpublish path writes, and row removal waits for the purger | `..._Subtree` (version and files marked rather than removed), `..._OrphanRule` (the package marked, not removed) |
| B-4 | The rotation runs first in the transaction, and its predicate is a silent no-op once the package row is marked (documented on `RotatePackumentRebuildTokenTx`, carried into the plan's Research Findings) | `..._RotatesBeforeMarkingThePackage` (primes a live fence, asserts the committed token is neither the fence nor NULL, on the orphan path where the ordering is observable) |
| B-5 | The probe precedes the rotation, so a call that resolves nothing invalidates no cache | `..._UnresolvedTargetWritesNothing` (the addressed package's `packument_rebuild_token` equals the primed fence) |
### Plan obligations (Step 12)
| # | Obligation | Tests |
|---|------------|-------|
| P-1 | One `READ COMMITTED` transaction with the unpublish deleter's envelope | The isolation level and the rollback path are not observable from a sequential test. The statement order it carries is pinned by `..._RotatesBeforeMarkingThePackage` and `..._UnresolvedTargetWritesNothing`; the all-or-nothing property is left to review against `NpmVersionUnpublishDeleter.unpublishVersionTx` |
| P-2 | Dist-tags pointing at the version are hard-deleted with an accurate `tagsRemoved` | `..._Subtree` (1 with a sibling dist-tag present, so a per-package count fails), `..._OrphanRule` (1), `..._Idempotency` (0 on the repeat) |
| P-3 | The orphan rule, both ways | `..._OrphanRule` |
| P-4 | Expiry visibility through a packument read | `..._ExpiresThePackumentCache` (through `NpmMetadataFileByPackageAndKind`, which carries `expires_at > NOW()`) |
| P-5 | Idempotent on an already-marked target | `..._Idempotency` (both the surviving-sibling and the package-already-marked arms) |
| P-6 | Rotate-first pin | `..._RotatesBeforeMarkingThePackage` |
| P-7 | The composer writes no counter column; `tagsRemoved` and `packageDeleted` exist for the caller | `..._Subtree` (both counter columns unchanged across the delete) |
| P-8 | Constructor panics on a nil client, matching the sibling composers | `TestNewNpmVersionManagementDeleter_PanicsOnNilClient` |
### Error cases
| # | Condition | Tests |
|---|-----------|-------|
| E-1 | A malformed call: nil context, or a zero namespace, package, or version id | `TestNpmVersionManagementDeleter_DeleteVersion_Guards` (four cases, each on its own sentinel). Driven through a zero-value struct literal, so a guard that ran after the transaction opened panics instead of returning, which `require.NotPanics` fails |
| E-2 | A target outside the addressed parent chain | Not an error by design: `..._UnresolvedTargetWritesNothing` requires no error, zero writes, and a control delete on the pairing that does resolve, so a composer that was simply inert fails |
| E-3 | The spec's Error Cases rows (`400`, `404`, `409`, `413`, `422`, `503`, `405`) | Route-level. They belong to the handler and bulk steps; this composer has no request surface |
### Security considerations
| # | Concern | Tests |
|---|---------|-------|
| S-1 | Tenant isolation: every query is scoped by the resolved `namespace_id`, and an artifact id from outside the URL's parent chain resolves to nothing even when the UUID is guessed correctly | `..._UnresolvedTargetWritesNothing` (a version id belonging to another package of the same namespace writes nothing, and the neighbour package is intact afterwards) |
| S-2 | Existence hiding on writes: an artifact outside the parent chain is a no-op that states nothing about whether it exists | `..._UnresolvedTargetWritesNothing` (the same return value for a version that exists elsewhere and one that exists nowhere) |
| S-3 | Injection: all queries use the Jet builder with bound parameters | Owned by the stores the composer calls. It issues no SQL of its own |
| S-4 | Authentication, authorization, echoed input, bounded blast radius, attribution exposure | Handler and bulk steps. No request surface here |## Spec coverage
Spec: [docs/specs/S17-rest-management-api.md](docs/specs/S17-rest-management-api.md)
Plan: [docs/plans/2026-08-10-s17-phase4-artifact-writes.md](docs/plans/2026-08-10-s17-phase4-artifact-writes.md), Step 15
### Acceptance criteria
| # | Criterion | Tests |
|-------|-----------|-------|
| AC-25 | A soft-deleted artifact is absent from lists and returns `404` on detail (Maven file slice) | `TestMavenFileDeleter_DeleteFile_UnaddressableTargets/a_marked_file_row` (a marked row is not deletable either). The read side is Phase 3's. |
| AC-26 | An artifact `id` from another repository or slug returns `404` (namespace and version slices) | `.../a_file_of_another_namespace`, `.../a_file_of_another_version_of_the_same_package`. The repository-and-package chain half is the handler's resolve step (Step 22). |
| AC-31 | `DELETE` on a file returns `202`, the target is absent once removal has run, and a missing target returns `404` | `TestMavenFileDeleter_DeleteFile_RemovesRowAndAttachment` (`deleted=true`, row gone), `TestMavenFileDeleter_DeleteFile_UnaddressableTargets` (5 subtests, `deleted=false`), `TestMavenFileDeleter_DeleteFile_IsIdempotent`. The status mapping is Step 22's. |
| AC-32 | Each delete removes the full subtree and the parent row goes last; a Maven file's subtree is the file row only | `TestMavenFileDeleter_DeleteFile_RemovesRowAndAttachment` (sibling, other version's file, version-less row, version row, and package row all survive) |
| AC-33 | An interrupted reap leaves the target marked and the next pass completes it | S20-A's purger, per the plan's Dependencies. No step here asserts it. |
| AC-37 | Deleting a Maven package's last version leaves the Maven package in place | Owned by the Maven marker steps (Step 14, Steps 20-21). The file-level analogue is asserted: `.../RemovesRowAndAttachment` leaves the version and the package. |
| AC-38 | Whatever removes a row removes its `blob_storage_attachments` row in the same step, and a marked but unreaped artifact still holds its attachment | `.../RemovesRowAndAttachment` (pair gone, CAS blob survives), `TestMavenFileDeleter_DeleteFile_KeepsAttachmentSharedWithAnotherFile`, `.../a_marked_file_row` |
| AC-39 | No delete of a non-empty artifact returns `409` except an indexed manifest | The composer has no conflict path: every non-hit returns `deleted=false` with a nil error (`.../UnaddressableTargets`, 5 subtests). |
| AC-49 | A management delete leaves the counts the equivalent protocol operation would leave | Not applicable: Maven files carry no counter column. The npm counters are the npm composer steps'. |
| AC-50 | Each committed delete emits one `artifact_registry_artifact_deleted` | Handler step (Step 22) and the emit wiring (Step 38). The composer emits nothing. |
| AC-51 | A single delete leaves no readable row of its subtree behind, and no stored row once the reap has run | `.../RemovesRowAndAttachment` (the file's whole subtree is the row, removed in the request; no reap involved) |
| AC-67 | A Phase 4 route whose `:format` segment differs, or a path ID that is not a valid UUID, returns `404` | Handler step (Step 22). The composer takes typed `uuid.UUID` arguments. |
### Behavioral rules from the spec body
| # | Rule | Tests |
|-----|------|-------|
| B-1 | What a delete removes: a Maven file target removes the file row only | `.../RemovesRowAndAttachment` |
| B-2 | `maven_files.maven_version_id` is nullable and the version-less rows hang off the package, which keeps them unaddressable by id; their removal rides the package's own removal | `.../a_version-less_package-level_row` (the real id of a live `maven-metadata.xml` row, paired with a live version id, is refused), plus the `IS NOT NULL` and version-equality assertions in `TestDeleteMavenFileStmt_SQL` |
| B-3 | Removal is deferred behind a marker, except for the file: the row and its attachment are hard-deleted in the request | `.../RemovesRowAndAttachment`, `TestMavenFileDeleter_DeleteFile_CancelledContextRemovesNothing` (the transaction envelope leaves nothing behind on failure) |
| B-4 | ADR-010 collects a blob once it has zero attachments, so an attachment left behind makes the blob permanently uncollectable; reclaiming the blob itself is not implemented | `.../RemovesRowAndAttachment` asserts the attachment goes with the row and the `blob_storage_blobs` row stays |
| B-5 | The reverse-reference check counts every referencing table, remote-cache rows included | Owned by `BlobStorageAttachmentStore.DeleteIfUnreferenced` (Step 10). This composer's use of the guarded call rather than the unconditional one is pinned by `.../KeepsAttachmentSharedWithAnotherFile`. |
| B-6 | A marking write removes nothing, so a marked row still holds its attachment | `.../a_marked_file_row` (row and attachment both survive) |
| B-7 | The purger removes a marked subtree children-first through the existing per-format deleters | `TestMavenFileDeleter_DeleteFile_MarkedParentsDoNotBlockTheFile`: the composer filters the file's own marker only, so a live file under a marked version and package still deletes. |
### Plan Step 15 requirements
| # | Requirement | Tests |
|-----|-------------|-------|
| P-1 | Hard delete of the file row plus its attachment through the reference check, one transaction | `.../RemovesRowAndAttachment`, `.../KeepsAttachmentSharedWithAnotherFile`, `.../CancelledContextRemovesNothing` |
| P-2 | `maven_version_id IS NOT NULL` enforced so package-level metadata rows stay unaddressable | `TestDeleteMavenFileStmt_SQL`, `.../a_version-less_package-level_row` |
| P-3 | Siblings and the version survive | `.../RemovesRowAndAttachment` |
| P-4 | Idempotency and miss behavior | `.../IsIdempotent`, `.../an_unknown_file_id` |
| P-5 | `(deleted bool, err error)` gives the handler its 404-vs-202 split | Every case asserts `deleted` alongside the row state, so a composer that reported the wrong half fails even when it touched the right rows. |
### Error cases
| # | Condition | Tests |
|-----|-----------|-------|
| E-1 | Artifact delete: target missing, or outside the URL's parent chain, `404` | `.../UnaddressableTargets` (5 subtests) and `.../IsIdempotent` all return `deleted=false` with a nil error, which is what the handler maps to `404`. A returned error would render `500` on an ordinary miss. |
| E-2 | All: unexpected server failure, `500` | `.../CancelledContextRemovesNothing` (a database failure surfaces as an error with `deleted=false` and no rows removed, never as a silent miss). The message wording is the implementation's to choose; the test pins the outcome. |
| E-3 | Artifact routes: format mismatch, non-UUID path ID, or non-hosted `kind`, `404` | Handler step (Step 22). |
| E-4 | The `400`, `401`, `403`, `405`, `409`, `413`, `422`, and `503` rows | Not applicable to this composer. They belong to the handler, tag-upsert, and bulk steps. |
| E-5 | Argument-guard rejections (no spec row: these are wiring bugs, not request outcomes) | `TestMavenFileDeleter_DeleteFile_Guards` (nil context, zero-UUID namespace, version, and file id), `TestNewMavenFileDeleter_NilClient` |
### Security considerations
| # | Concern | Tests |
|-----|---------|-------|
| S-1 | Tenant isolation: every query is scoped by the resolved `namespace_id`, the partition key | `.../a_file_of_another_namespace` (the foreign row survives a delete issued under another namespace), plus the namespace conjunct assertion in `TestDeleteMavenFileStmt_SQL` |
| S-2 | Existence hiding on writes: an artifact outside the URL's parent chain returns `404` | `.../UnaddressableTargets` returns one indistinguishable `deleted=false` for every refused target, so no caller can tell a foreign row from an absent one. |
| S-3 | Injection: all queries use the Jet builder with bound parameters | `TestDeleteMavenFileStmt_SQL` asserts the three ids arrive as bound arguments, in the order the predicate names them. |
| S-4 | Authentication, authorization, and write authorization (`delete_artifact`) | S08, S09, and ADR-021 at the handler steps. This composer has no request surface. |
| S-5 | Echoed input, bounded blast radius, destructive routes behind the auth stubs, attribution exposure | Handler and bulk steps. |
### Query-pattern pins (not spec rows)
| Pin | Tests |
|-----|-------|
| The DELETE carries `namespace_id`, the `maven_files` hash partition key, so it prunes to one partition | `TestDeleteMavenFileStmt_SQL` |
| `RETURNING` carries `blob_sha256`, the attachment's partition key, so the attachment delete prunes to one partition rather than scanning all of them | `TestDeleteMavenFileStmt_SQL` |
| The statement is version-keyed and carries no `maven_package_id` predicate | `TestDeleteMavenFileStmt_SQL` |
| Distinct fixture rows carry distinct content digests, so an attachment probe cannot read the wrong row's state | `requireDistinctMavenDigests`, run by every integration fixture |## Spec coverage
Spec: [docs/specs/S17-rest-management-api.md](docs/specs/S17-rest-management-api.md)
Plan: [docs/plans/2026-08-10-s17-phase4-artifact-writes.md](docs/plans/2026-08-10-s17-phase4-artifact-writes.md), Step 10
### Acceptance criteria
| # | Criterion | Tests |
|-------|--------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AC-38 | Whatever removes a row removes its attachment row in the same step, so a blob whose last artifact row is gone has zero attachments | `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_EveryReferencingTable` (prune half, all nine tables). The "same step" transaction composition is owned by the composer steps that call this. |
| AC-38 | A marked but unreaped artifact still holds its attachment, so the blob is not yet collectable | `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_MarkedRowStillCounts`, `TestDeleteUnreferencedAttachmentStmt_CarriesNoDeletionMarkerFilter` |
| B-1 | Every table referencing `blob_storage_attachments` is counted, the three remote-cache tables included, or the check frees an attachment a cached row is using | `TestDeleteUnreferencedAttachmentStmt_GuardsEveryReferencingTable` (set equality against the schema dump), `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_EveryReferencingTable` (nine survivor subtests) |
| B-2 | ADR-010 collects a blob once it has zero attachments, so a delete that strands an attachment makes the blob permanently uncollectable | Motivates B-1 and AC-38 rather than being separately assertable. Blob collection is not implemented (see B-5). |
| B-3 | A marking write removes nothing | `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_MarkedRowStillCounts` |
| B-4 | The reverse-reference check is the one place the deletion-marker predicate must not go | `TestDeleteUnreferencedAttachmentStmt_CarriesNoDeletionMarkerFilter`, `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_MarkedRowStillCounts` |
| B-5 | Reclaiming the blob itself is not implemented, so a delete frees rows and no storage | Pre-existing `TestBlobStorageAttachmentStore_DeleteIfUnreferenced/prunes_an_orphaned_attachment_link_and_leaves_the_CAS_blob` |
| P-1 | Plan Step 10: an attachment referenced only by a marked, unreaped row survives | `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_MarkedRowStillCounts` |
| P-2 | Plan Step 10: an attachment referenced by any referencing table survives | `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_EveryReferencingTable` (survivor half, nine subtests) |
| P-3 | Plan Step 10: an attachment referenced by none deletes | `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_EveryReferencingTable` (prune half), pre-existing `.../prunes_an_orphaned_attachment_link_and_leaves_the_CAS_blob` |
| P-4 | Plan Step 10: the existing container manifest-delete behavior is unchanged | Pre-existing `TestBlobStorageAttachmentStore_DeleteIfUnreferenced` (3 subtests, all still passing), plus the `container_manifests` subtest of the new table (passing today) |
| P-5 | Plan Step 10 Tests: a unit test pinning the table list against the FK inventory, so a further referencing table fails a test rather than leaking | `TestDeleteUnreferencedAttachmentStmt_GuardsEveryReferencingTable` (missing and extra both fail) |
### Error cases
| # | Condition | Tests |
|-----|-------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| E-1 | A referencing row in a table the guard does not count: the FK is `NO ACTION`, so the DELETE raises SQLSTATE 23503 and aborts the caller's transaction instead of reporting a no-op | `TestBlobStorageAttachmentStore_DeleteIfUnreferenced_EveryReferencingTable` requires no error and 0 rows on every table, which is what fails today |
| E-2 | A duplicate clause reads as coverage while counting nothing new | `TestDeleteUnreferencedAttachmentStmt_EnumeratesEachTableOnce` |
| E-3 | No S17 Error Cases entry names the reverse-reference check | Not applicable to this step. The `409`, `400`, and `404` rows belong to the handler steps. |
### Security considerations
| # | Concern | Tests |
|-----|--------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| S-1 | Tenant isolation: every query is scoped by `namespace_id`, the partition key | `TestDeleteUnreferencedAttachmentStmt_CorrelatesOnNamespaceAndAttachmentID` (per-clause, so one uncorrelated clause among nine reddens) |
| S-2 | Injection: queries bind their parameters | The statement is a compile-time constant and the three inputs are bound. `TestDeleteUnreferencedAttachmentStmt_OuterDeleteCarriesThePartitionKey` pins its shape. |
| S-3 | Authentication, authorization, existence hiding, echoed input, blast radius | Owned by the handler and bulk steps. This store method has no request surface. |
### Query-pattern pins (not spec rows)
| Pin | Tests |
|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
| The outer DELETE keeps `sha256`, the `blob_storage_attachments` partition key, so it prunes to one partition | `TestDeleteUnreferencedAttachmentStmt_OuterDeleteCarriesThePartitionKey`, pre-existing `.../sha256_predicate_is_load-bearing:_a_mismatched_sha256_prunes_nothing` |
| Each subquery correlates on `namespace_id` so it prunes to one hash partition of the referencing table | `TestDeleteUnreferencedAttachmentStmt_CorrelatesOnNamespaceAndAttachmentID` |
</details>
Related to https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/356
Plan amendment followup: https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/1672