---
url: /reference/ASSETS_CASE_MATRIX.md
---
# Assets Case Matrix

One row per behavioral case for the assets use cases built so far, named in
[`ASSETS_USE_CASES.md`](ASSETS_USE_CASES.md) §7 and specified stage by stage
in [`ASSETS_STAGE_SPECS.md`](ASSETS_STAGE_SPECS.md). Each case names the test
that proves it, quoted verbatim as a spec file path, then the separator "›",
then the exact test title, exactly like
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md). A case with no proving
test answers `—` and is listed under [Gaps](#gaps), with the stage that will
prove it.

**Stages A2b, A2c and A4a are proven (no migration, baseline revision 104):**
AS1, the upload intent with its presigned S3 POST, AS2, the completion that
moves the asset to `processing` and queues its job without calling the
storage, AS3, the read of one asset with its rendition addresses, and the
library of A4a: AS4, the keyset listing, AS5, the rename, and AS6, the
archival and restoration through the definer functions, over HTTP against
PostgreSQL 18. The storage is the real S3 signer with fixed test
credentials (signing is local, nothing reaches the network); a ready or
failed asset is set in SQL, because the media worker that produces them is
stage A3.
Stage A5a is proven too (no migration): AS12 and AS13, the delivery and
attachability contracts, which Catalog calls since A5b (migration `0095`,
baseline revision 105).
The media worker stages are proven too: AS7, processing an upload into a
ready asset (A3a, A3b-1, A3b-2), AS8, the retry classes, dead letters and
the operator requeue (A3c, migration `0097`), AS9, the expiry of abandoned
uploads and the usage reconciliation (A3a), and AS10, the purge (A4b,
migration `0098`). AS11 has no stage yet (no route in v1, `ASSETS_USE_CASES.md`
§7.2), so it has no row.

***

## Totals

| | |
|---|---:|
| Use cases | 12 |
| Per-case scenario rows | 75 |
| Per-case scenario rows proven | 75 |
| Cross-tenant and concurrency invariants | 6 (6 proven) |
| Gaps | 0 |

***

## Uploads

### AS1 — Request an upload

`POST /assets/uploads` with `Idempotency-Key`; needs organization-wide
`assets.media.manage` and the `assets.media` capability. 8 statements, a
replay too.

| Case | Expected answer | Proving test |
|---|---|---|
| A public JPEG of the declared size | `201`, `Cache-Control: no-store`, a `pending` asset, a POST policy bound to the exact `in/<token>` key, the declared content type and `content-length-range` 1..`byteSize`, valid 600 s; the usage row counts one more asset and reserves the bytes; one `assets.asset.upload_requested` audit with media type, size and access class as after attributes | `test/e2e/assets-uploads.e2e-spec.ts` › `issues an upload for a public image with a presigned POST bound to its exact key, content type and declared size, a pending asset and a reserved quota, and records the request once` |
| The policy, the signature and the credential | never in a log line or an audit attribute | `test/e2e/assets-uploads.e2e-spec.ts` › `issues an upload for a public image with a presigned POST bound to its exact key, content type and declared size, a pending asset and a reserved quota, and records the request once` |
| The same key and body again, after the asset was renamed | `201`, `Idempotent-Replayed: true`, the asset as it is now, the same upload and key, a fresh policy; nothing written or audited | `test/e2e/assets-uploads.e2e-spec.ts` › `replays the same Idempotency-Key and body with the asset as it is now and a fresh upload policy, and refuses the same key with another body` |
| The same key with another body | `409 · idempotency.key_reused`, nothing signed | `test/e2e/assets-uploads.e2e-spec.ts` › `replays the same Idempotency-Key and body with the asset as it is now and a fresh upload policy, and refuses the same key with another body`; `src/modules/assets/application/uploads/request-upload.use-case.spec.ts` › `refuses the same key with another request as reused and signs nothing` |
| A replay one minute before the upload expires | a policy of 60 s, never outliving the upload | `src/modules/assets/application/uploads/request-upload.use-case.spec.ts` › `replays an issued upload with the asset as it is now and a fresh policy that never outlives the upload, recording nothing` |
| A replay of a received upload, or of an issued one already expired | `201` with `upload: null` and the asset as it is now | `src/modules/assets/application/uploads/request-upload.use-case.spec.ts` › `replays an upload %s until %s with no policy` |
| SVG, HEIC, an unknown type, more than 10 MiB, `private`, `sensitive` | `422 · assets.media_type_unsupported`, `assets.file_too_large`, `assets.access_class_unavailable`; no row, no audit | `test/e2e/assets-uploads.e2e-spec.ts` › `refuses an svg, a heic and an unknown media type, a file over 10 MiB and a private or sensitive access class before writing anything` |
| A declared size of 1.5, 0, -1 or NaN reaching the use case | refused before any transaction, answered `400 · foundation.validation_failed` like the request schema | `src/modules/assets/application/uploads/request-upload.use-case.spec.ts` › `refuses a declared size of %s as invalid before opening a transaction`; `src/modules/assets/presentation/failures/to-public-assets-failure.spec.ts` › `answers a declared size that is not a positive whole number exactly as the request schema does` |
| The plan's active asset limit, then its storage limit, reached | `422 · assets.asset_limit_reached`, `422 · assets.storage_limit_reached`; nothing written | `test/e2e/assets-uploads.e2e-spec.ts` › `refuses a new upload once the plan's asset or storage limit is reached, and lets two concurrent intents at the limit admit exactly one` |
| A 21st open upload | `422 · assets.too_many_open_uploads` | `test/e2e/assets-uploads.e2e-spec.ts` › `caps the open uploads of an organization at 20` |
| The storage group not configured, for the owner and for a cashier | `503 · assets.storage_unavailable`, retryable, before authorizing: no reservation, no usage change, no audit | `test/e2e/assets-uploads.e2e-spec.ts` › `answers 503 when the storage is not configured, before authorizing and without reserving anything`; `src/modules/assets/application/uploads/request-upload.use-case.spec.ts` › `answers storage unavailable before authorizing or reserving when the signing group is absent` |
| The signer fails after the commit | `503 · assets.storage_unavailable`; the reservation stays and the same key replays with a fresh policy | `src/modules/assets/infrastructure/storage/s3-upload-policy-signer.spec.ts` › `surfaces a signing failure as a storage failure that keeps the original error as its cause`; `src/modules/assets/presentation/failures/to-public-assets-failure.spec.ts` › `answers every assets refusal of the application with its public code and never the cause` |
| The 31st intent of one client within a minute | `429` | `test/e2e/assets-uploads.e2e-spec.ts` › `answers 429 to the 31st upload intent of one client within a minute` |
| A cashier, a waiter, a manager of one business unit; then the owner after the plan lost `assets.media` | `403 · assets.action_forbidden` three times, then `403 · platform.capability_not_entitled`; each refusal audited as denied with its reason; the asset already uploaded stays readable | `test/e2e/assets-access.e2e-spec.ts` › `refuses a cashier, a waiter and a manager of one business unit the plain denial and records it, and a lapsed plan the capability refusal` |
| A refused member | the denial audit is committed and nothing is reserved | `src/modules/assets/application/uploads/request-upload.use-case.spec.ts` › `commits the audited denial and reserves nothing when the member lacks the permission` |
| Statement budget | 8 for an intent and 8 for its replay | `test/e2e/statement-budget.e2e-spec.ts` › `requests an upload in 8 statements` |

### AS2 — Complete an upload

`POST /assets/uploads/{uploadId}/completion`, no body; needs
organization-wide `assets.media.manage`, never the plan. 8 statements.

| Case | Expected answer | Proving test |
|---|---|---|
| An issued upload, then the same completion again | `202` with the asset `processing`; one `process_upload` job; the repeat answers the same body and writes nothing; no audit | `test/e2e/assets-uploads.e2e-spec.ts` › `completes an issued upload into processing, queues one processing job and answers repeats without writing` |
| An expired upload, a rejected upload | `422 · assets.upload_expired`, `422 · assets.upload_rejected`; no job, the asset stays `pending` | `test/e2e/assets-uploads.e2e-spec.ts` › `refuses completing an expired or rejected upload, and an upload of another organization like an unknown id` |
| The storage while completing | never called | `src/modules/assets/application/uploads/complete-upload.use-case.spec.ts` › `never calls the object storage while completing an upload` |
| A member without `assets.media.manage` | `403 · assets.action_forbidden` before the upload is touched | `src/modules/assets/application/uploads/complete-upload.use-case.spec.ts` › `refuses a member without assets.media.manage before touching the upload` |
| Intent, read, completion, poll | `pending`, then `processing` in the completion and in the poll, under a new tag | `test/e2e/assets-uploads.e2e-spec.ts` › `takes a photo from intent to processing with the fake storage adapter in one journey` |
| Statement budget | 8 | `test/e2e/statement-budget.e2e-spec.ts` › `completes an upload in 8 statements` |

## Library

### AS3 — Read one asset

`GET /assets/{assetId}`; needs organization-wide `assets.media.view`, never
the plan. Strong content tag, `private, no-cache`. 7 statements.

| Case | Expected answer | Proving test |
|---|---|---|
| A ready public asset | `200`, the master's width, height and size, the four `a/<token>/p1/<name>.webp` addresses under the public base URL, never `m/` or `in/`; a strong tag; `304` with no body to that tag; a rename changes the tag | `test/e2e/assets-library.e2e-spec.ts` › `reads a ready public asset with its rendition urls, never the master, under a strong tag and private no-cache, and 304 to that tag` |
| A pending, a processing and a failed asset | no address, no dimensions, the failure code of the failed one | `test/e2e/assets-library.e2e-spec.ts` › `reads a pending, processing or failed asset with no urls and its failure code` |
| Addresses only for ready and public | four links for a ready public asset; none for pending, processing, failed, private or without a public base URL | `src/modules/assets/application/assets/asset-view.spec.ts` › `links the four public renditions of profile 1 of a ready public asset and never the master`; `src/modules/assets/application/assets/asset-view.spec.ts` › `links no rendition while the public base url is not configured` |
| A member without `assets.media.view`; an unknown id | `403 · assets.action_forbidden` before any read, not audited; `404 · assets.not_found` | `src/modules/assets/application/assets/read-asset.use-case.spec.ts` › `refuses a member without assets.media.view before reading`; `src/modules/assets/application/assets/read-asset.use-case.spec.ts` › `answers an unknown asset as not found` |
| Statement budget | 7 | `test/e2e/statement-budget.e2e-spec.ts` › `reads an asset in 7 statements` |

### AS4 — List the media library

`GET /assets?status=&archived=&after=&before=&limit=`; needs
organization-wide `assets.media.view`, never the plan. Keyset
`(created_at DESC, id DESC)` on the partial indexes of A1a-1, signed cursors
bound to the organization and the filters, `private, no-cache`, no ETag. 7
statements.

| Case | Expected answer | Proving test |
|---|---|---|
| Three assets, one page at a time | newest first; `after` the first page reaches the next, `before` it comes back; `hasNextPage` and `hasPreviousPage` follow | `test/e2e/assets-library.e2e-spec.ts` › `lists the library newest first with keyset cursors, archived assets only when asked, and only the caller organization's assets` |
| An archived asset | absent from the default listing; the only item of `archived=true`, with its `archivedAt` | `test/e2e/assets-library.e2e-spec.ts` › `lists the library newest first with keyset cursors, archived assets only when asked, and only the caller organization's assets` |
| `status=ready` | only the ready asset, with only its `thumbnail` address | `test/e2e/assets-library.e2e-spec.ts` › `lists the library newest first with keyset cursors, archived assets only when asked, and only the caller organization's assets`; `src/modules/assets/presentation/dtos/asset-response.spec.ts` › `answers the asset as read with only its thumbnail rendition, and passes the strict list item schema` |
| Five assets two at a time: forward two pages, then back twice with `before` | the same pages in the same newest-first order; `hasPreviousPage` and `hasNextPage` per page; the page reached back carries the cursors it had going forward; an empty library answers both cursors `null` | `test/e2e/assets-library-paging.e2e-spec.ts` › `pages forward two pages and back with before, newest first, encoding the cursors of each page`; `src/modules/assets/infrastructure/assets/asset.repository.spec.ts` › `reads a page before a cursor position in ascending order inside and outside the variant join, and answers it newest first` |
| A cursor of `status=pending` sent with `status=ready` or no status, one of the active listing sent with `archived=true` and back, one sent by another organization | `400 · collection.cursor_incompatible`; the same filters continue | `test/e2e/assets-library-paging.e2e-spec.ts` › `refuses a cursor replayed under another status or archived filter, or by another organization, as incompatible` |
| Three ready assets with their original and four renditions each, two at a time | each listed once; two, then one | `test/e2e/assets-library-paging.e2e-spec.ts` › `lists a ready asset with its original and four renditions once, and pages three of them two at a time`; `src/modules/assets/infrastructure/assets/asset.repository.spec.ts` › `joins only the original variant, so a ready asset with its renditions is one row` |
| A cursor sent with other filters, or by another organization | `400 · collection.cursor_incompatible` | `test/e2e/assets-library.e2e-spec.ts` › `lists the library newest first with keyset cursors, archived assets only when asked, and only the caller organization's assets` |
| An unknown status, `archived=yes`, a limit of 0 or 101, both cursors, `offset` | `400` before any transaction | `src/modules/assets/presentation/dtos/asset-library.schemas.spec.ts` › `refuses an unknown status, a loose boolean, a limit out of range, both cursors and an unknown parameter`; `src/modules/assets/presentation/dtos/asset-library.schemas.spec.ts` › `lists the active assets of every status, 50 at a time, when nothing is sent` |
| One page | one statement filtered by the caller organization and the archived state, one row more than the limit, never `OFFSET` | `src/modules/assets/infrastructure/assets/asset.repository.spec.ts` › `lists one page of the caller organization newest first, fetching one row more than the limit and never with OFFSET`; `src/modules/assets/infrastructure/assets/asset.repository.spec.ts` › `lists only archived assets when asked, and continues after a cursor position` |
| The use case | the caller organization and the filters reach the repository; one transaction | `src/modules/assets/application/assets/list-assets.use-case.spec.ts` › `lists one page of the caller organization after checking assets.media.view, in one transaction and one read` |
| A member without `assets.media.view` | `403 · assets.action_forbidden` before listing | `src/modules/assets/application/assets/list-assets.use-case.spec.ts` › `refuses a member without assets.media.view before listing` |
| Statement budget | 7 | `test/e2e/statement-budget.e2e-spec.ts` › `lists the media library in 7 statements` |

### AS5 — Rename an asset

`PATCH /assets/{assetId}` `{ displayName }` with an optional `If-Match`;
needs organization-wide `assets.media.manage`, never the plan. 10
statements, 7 when the name is unchanged.

| Case | Expected answer | Proving test |
|---|---|---|
| A rename with the current tag | `200`, the trimmed name, a new strong ETag equal to the next read's, `private, no-store`; one `assets.asset.renamed` audit with the name as after attribute | `test/e2e/assets-library.e2e-spec.ts` › `renames an asset with If-Match, records it once, and answers an identical rename without writing`; `src/modules/assets/application/assets/rename-asset.use-case.spec.ts` › `renames the locked asset, records the name before and after once, and answers the asset read again` |
| A stale tag | `412 · foundation.precondition_failed`, checked under the lock; nothing written | `test/e2e/assets-library.e2e-spec.ts` › `renames an asset with If-Match, records it once, and answers an identical rename without writing`; `src/modules/assets/application/assets/rename-asset.use-case.spec.ts` › `checks If-Match against the asset as locked and refuses a stale one before writing`; `src/modules/assets/presentation/failures/to-public-assets-failure.spec.ts` › `answers every assets refusal of the application with its public code and never the cause` |
| `If-Match` absent; the current strong tag alone, in a list or `*`; a stale tag, its weak form, the bare tag | no precondition; passes; refused | `src/modules/assets/presentation/dtos/asset-entity-tag.spec.ts` › `builds no precondition when If-Match is absent`; `src/modules/assets/presentation/dtos/asset-entity-tag.spec.ts` › `passes the strong tag of the asset as GET /assets/{assetId} answers it, alone or in a list, and the * wildcard`; `src/modules/assets/presentation/dtos/asset-entity-tag.spec.ts` › `refuses a stale tag, the weak form of the current tag and a header that holds no well-formed tag` |
| A malformed `If-Match` (`not-a-tag`, an unterminated quote, `,`, `W/`) | `412 · foundation.precondition_failed` as in every module (malformed entries are dropped, so nothing matches), never `500`; nothing written | `test/e2e/assets-library-paging.e2e-spec.ts` › `refuses a malformed If-Match on a rename as a failed precondition, never a server error, and writes nothing` |
| The current name again | `200`, the same tag, nothing written or recorded | `test/e2e/assets-library.e2e-spec.ts` › `renames an asset with If-Match, records it once, and answers an identical rename without writing`; `src/modules/assets/application/assets/rename-asset.use-case.spec.ts` › `answers an identical rename with the locked asset, writing and recording nothing` |
| A blank, missing, null, NUL-bearing or 256-character name, another field | `400` | `src/modules/assets/presentation/dtos/asset-library.schemas.spec.ts` › `refuses a missing, null, blank, too long or NUL-bearing name and any other field` |
| A member without `assets.media.manage` | `403 · assets.action_forbidden` before the lock | `src/modules/assets/application/assets/rename-asset.use-case.spec.ts` › `refuses a member without assets.media.manage before locking the asset` |
| Statement budget | 10 when it writes, 7 when unchanged | `test/e2e/statement-budget.e2e-spec.ts` › `renames an asset in 10 statements, and in 7 when the name is unchanged` |

### AS6 — Archive and restore an asset

`POST /assets/{assetId}/archival` and `POST /assets/{assetId}/restoration`,
no body; needs organization-wide `assets.media.manage`, never the plan;
through `assets.archive_asset` and `assets.restore_asset` (A1b-3). 9
statements, 8 for a repeat.

| Case | Expected answer | Proving test |
|---|---|---|
| A ready asset attached to an item, on a lapsed plan | `200` with `archivedAt`; one active asset fewer; the read still answers its rendition addresses and the item keeps the image; one `assets.asset.archived` audit | `test/e2e/assets-library.e2e-spec.ts` › `archives and restores an asset on a lapsed plan, keeps an archived attached image deliverable, and refuses restoring after 30 days` |
| Archiving it again; restoring it, then again | `200` each time; the repeats write and record nothing; the restoration counts it again, one `assets.asset.restored` audit | `test/e2e/assets-library.e2e-spec.ts` › `archives and restores an asset on a lapsed plan, keeps an archived attached image deliverable, and refuses restoring after 30 days`; `src/modules/assets/application/assets/change-asset-archival.use-case.spec.ts` › `answers archiving an archived asset and restoring an active one with the asset, recording nothing` |
| Restoring 30 days and 1 minute after archival | `422 · assets.restoration_expired`; it stays archived | `test/e2e/assets-library.e2e-spec.ts` › `archives and restores an asset on a lapsed plan, keeps an archived attached image deliverable, and refuses restoring after 30 days` |
| Restoring at the plan's active asset limit | `422 · assets.asset_limit_reached`; it stays archived, the count unchanged | `test/e2e/assets-library.e2e-spec.ts` › `refuses restoring an asset when the asset limit is reached` |
| Restoring a failed asset; archiving or restoring a purging one | `422 · assets.transition_invalid`; nothing recorded | `test/e2e/assets-archival.e2e-spec.ts` › `refuses restoring a failed asset and archiving or restoring a purging one as an invalid transition, writing nothing`; `database/tests/contracts/assets-lifecycle-contracts.ts` › `refuses archiving or restoring a purging asset and never lets its status leave purging` |
| A refused transition in the use case | it reaches the failure mapping; the transaction rolls back with no audit | `src/modules/assets/application/assets/change-asset-archival.use-case.spec.ts` › `lets a refused transition reach the failure mapping, rolling back and recording nothing` |
| A member without `assets.media.manage` | `403 · assets.action_forbidden` before the definer function | `src/modules/assets/application/assets/change-asset-archival.use-case.spec.ts` › `refuses a member without assets.media.manage before calling the definer function` |
| Statement budget | 9, 8 for a repeat, on both routes | `test/e2e/statement-budget.e2e-spec.ts` › `archives and restores an asset in 9 statements, and in 8 for a repeat` |

***

## Worker cases

Stages A3a to A3c and A4b: the `media-worker` process, no HTTP route.

### AS7 — Process an upload

| Case | Expected answer | Proving test |
|---|---|---|
| A completed upload | master in the private bucket, four immutable WebP renditions in the public one, the asset `ready` with sizes, checksums and stored bytes in one transaction, then the staging object removed | `test/integration/assets-processing.integration-spec.ts` › `processes a completed upload into a ready asset whose master, renditions, sizes, checksums and stored bytes are recorded in one transaction, and removes the staging object`; `src/modules/assets/infrastructure/processing/process-upload.handler.spec.ts` › `writes the master to the private bucket and four immutable webp renditions to the public bucket, and answers ready with every variant` |
| Content checks | magic bytes, declared type and decoded format must agree; pixel limits per format | `src/modules/assets/application/processing/verify-and-master.spec.ts` › `requires the magic bytes, the declared type and the decoded format to agree`; `src/modules/assets/application/processing/verify-and-master.spec.ts` › `refuses an avif over 12 megapixels before decoding it, accepts one at exactly 12 megapixels and keeps 40 megapixels for jpeg and png` |
| A bad upload | truncated, mismatched, oversized, polyglot or wrongly sized fails for good, each reservation released once and each staging object removed | `test/integration/assets-processing.integration-spec.ts` › `fails a truncated, a mismatched, an oversized, a polyglot and a wrongly sized upload, releasing each reservation once and removing each staging object` |

### AS8 — Retry, fail and dead-letter processing

| Case | Expected answer | Proving test |
|---|---|---|
| Retry classes | a storage outage retries with capped backoff and no attempt cap; the fifth processing error dead-letters | `src/modules/assets/infrastructure/processing/process-upload.handler.spec.ts` › `retries a storage outage with capped backoff and no attempt cap, and dead-letters only the fifth processing error`; `src/modules/assets/application/processing/failure-classifier.spec.ts` › `backs storage retries off from 5 seconds by the total attempts, with a jitter of 20 percent, capped below 15 minutes` |
| A staging object not there yet | a storage error; the asset stays `processing` | `test/integration/assets-processing.integration-spec.ts` › `retries an upload whose staging object has not arrived yet as a storage error and keeps the asset processing` |
| Operator requeue (OD21) | only through the operator function, only for a dead letter of the last day, with its reservation restored | `database/tests/contracts/assets-requeue-contracts.ts` › `requeues a dead-lettered processing job of the last day only through the operator function, restoring its reservation`; `database/tests/contracts/assets-dead-letter-contracts.ts` › `counts a reclaimed expired lease as a processing attempt and dead-letters the job at the fifth` |

### AS9 — Expire abandoned uploads

| Case | Expected answer | Proving test |
|---|---|---|
| An abandoned upload | expired under the advisory lock, its staging object deleted, a missing object counted as done | `test/integration/assets-processing.integration-spec.ts` › `expires an abandoned upload and deletes its staging object from the storage`; `src/modules/assets/infrastructure/worker/expire-uploads.job.spec.ts` › `expires uploads under an advisory lock and deletes their staging objects, treating a missing object as done` |
| A staging delete that keeps failing; the nightly reconciliation | three attempts, then counted undeleted; reconciliation at 03:00 UTC | `src/modules/assets/application/processing/expire-abandoned-uploads.use-case.spec.ts` › `leaves an orphan staging object when every delete fails after the expiry committed, and counts it undeleted`; `src/modules/assets/infrastructure/worker/reconcile-usage.job.spec.ts` › `schedules the usage reconciliation nightly at 03:00 UTC` |

### AS10 — Purge an asset

| Case | Expected answer | Proving test |
|---|---|---|
| An asset archived more than 30 days ago | enqueued by the hourly sweep, objects deleted, four public URLs purged from the CDN, usage exact | `test/integration/assets-purge.integration-spec.ts` › `enqueues an asset archived more than 30 days ago, deletes its objects, purges its four public urls and leaves the usage counters exact`; `src/modules/assets/infrastructure/worker/enqueue-purge-candidates.job.spec.ts` › `sweeps every hour, enqueuing batches of 500 until a batch comes back short` |
| A referenced asset; a failed one | the referenced asset and its objects kept and retried a month later; a failed one purged after 7 days without the CDN | `test/integration/assets-purge.integration-spec.ts` › `answers referenced for an asset a catalog item uses, keeps it and its objects, and retries it only a month later`; `test/integration/assets-purge.integration-spec.ts` › `purges an asset that failed more than 7 days ago without calling the cdn` |
| CDN unconfigured or URLs not derivable | nothing deleted; retried, or dead-lettered after 14 days or when the URLs cannot be built | `src/modules/assets/application/processing/purge-asset.handler.spec.ts` › `deletes nothing and retries later while the cdn purge is unconfigured for an asset that was ever public`; `src/modules/assets/application/processing/purge-asset.handler.spec.ts` › `dead-letters the job without deleting anything when the public urls cannot be derived` |

***

## Contracts

Stage A5a (no migration): the two published contracts another module calls
inside its own tenant transaction; Catalog consumes both since A5b
(`CATALOG_CASE_MATRIX.md`, Images).

### AS12 — Describe delivery

| Case | Expected answer | Proving test |
|---|---|---|
| Up to 2,000 ids, ready public and other assets | one statement; `imageRef` and URLs only for a ready public asset, the others keep their status with `null`; a foreign id is absent | `src/modules/assets/infrastructure/delivery/asset-delivery.repository.integration-spec.ts` › `describes up to 2000 assets in one statement, with urls only for ready public assets`; `src/modules/assets/application/delivery/asset-delivery.spec.ts` › `gives an image reference and urls only to a ready public asset` |
| No public base URL; more than 2,000 ids; an empty request | `imageRef` kept, no URLs or template; refused before any SQL; an empty map with no statement | `src/modules/assets/application/delivery/asset-delivery.spec.ts` › `keeps the image reference but gives no urls or template when no public base url is configured`; `src/modules/assets/application/delivery/asset-delivery.spec.ts` › `refuses more than 2000 ids and answers an empty request, both without reading the database` |
| Template and fingerprint | the tile template matches the tile URL; the fingerprint changes with the base URL and the profile list | `src/modules/assets/contracts/asset-delivery.contract.spec.ts` › `builds the tile url of an image reference with the template it publishes`; `src/modules/assets/contracts/asset-delivery.contract.spec.ts` › `changes the fingerprint when the public base url changes`; `src/modules/assets/application/delivery/asset-delivery.spec.ts` › `keeps the fingerprint for the same base url and profile list and changes it with the profile list` |
| What a consumer sees | no key, bucket, provider or variant id | `src/modules/assets/contracts/asset-delivery.contract.spec.ts` › `exposes no storage key, bucket, provider or variant id to a consumer` |

### AS13 — Check attachability

| Case | Expected answer | Proving test |
|---|---|---|
| Not ready, archived, purging, private or foreign assets | one statement `FOR SHARE` in id order, one refusal per asset with its reason, the lock held until commit | `src/modules/assets/infrastructure/usage/asset-usage.repository.integration-spec.ts` › `refuses an asset that is not ready, archived, purging, private or of another organization, each with its reason, and holds a share lock until commit`; `src/modules/assets/application/usage/asset-usage.spec.ts` › `names the first refusal that applies, in the order not found, purging, archived, not ready, wrong media kind, wrong access class` |
| One refused id among several; a missing id repeated | the whole request refused naming every refused id, each distinct id once | `src/modules/assets/application/usage/asset-usage.spec.ts` › `refuses the whole request with every refused id when one of several ids is attachable`; `src/modules/assets/application/usage/asset-usage.spec.ts` › `reports one refusal per distinct requested id when a missing id is repeated` |
| Repeated attachable ids; an empty request | each id once at its first position, in request order; no statement | `src/modules/assets/application/usage/asset-usage.spec.ts` › `answers the attachable assets in the order of the requested ids, each id once at its first position`; `src/modules/assets/application/usage/asset-usage.spec.ts` › `answers an empty request without reading the database` |
| The share lock needs a column update grant | the runtime locks `FOR SHARE` through its `UPDATE (display_name)` grant | `database/tests/contracts/assets-lifecycle-contracts.ts` › `lets the runtime lock assets for share, which needs its column update grant on display_name` |
| Wiring | the contract module exports exactly the two contracts, bound to the working services | `src/modules/assets/contracts/assets-contracts.module.spec.ts` › `exports exactly the usage and delivery contracts to an importing module`; `src/modules/assets/contracts/assets-contracts.module.spec.ts` › `resolves both contracts to the working services, wired to their repositories and the configured base url` |

***

## Cross-tenant and concurrency invariants

| Invariant | Expected answer | Proving test |
|---|---|---|
| Two intents of one organization at its asset limit race | exactly one `201` and one `422 · assets.asset_limit_reached`; the counter ends at the limit | `test/e2e/assets-uploads.e2e-spec.ts` › `refuses a new upload once the plan's asset or storage limit is reached, and lets two concurrent intents at the limit admit exactly one` |
| An asset of another organization | the same `404 · assets.not_found` problem as an unknown id, no tag | `test/e2e/assets-library.e2e-spec.ts` › `answers an asset of another organization exactly like an unknown id` |
| An upload of another organization | the same `404 · assets.not_found` problem as an unknown id | `test/e2e/assets-uploads.e2e-spec.ts` › `refuses completing an expired or rejected upload, and an upload of another organization like an unknown id` |
| An asset of another organization renamed, archived or restored | `404 · assets.not_found`, nothing written | `test/e2e/assets-library.e2e-spec.ts` › `renames an asset with If-Match, records it once, and answers an identical rename without writing`; `test/e2e/assets-library.e2e-spec.ts` › `archives and restores an asset on a lapsed plan, keeps an archived attached image deliverable, and refuses restoring after 30 days` |
| The library of another organization | never listed; its cursor refused | `test/e2e/assets-library.e2e-spec.ts` › `lists the library newest first with keyset cursors, archived assets only when asked, and only the caller organization's assets` |
| Every assets refusal | its public code; no SQL, constraint or storage detail in the answer | `src/modules/assets/presentation/failures/to-public-assets-failure.spec.ts` › `answers every assets refusal of the application with its public code and never the cause` |

***

## Gaps

None for the stages built. AS11 (regenerate renditions for a new profile)
joins this page with the stage that builds it; none is planned until
profile 2 is designed.
