---
url: /reference/ASSETS_USE_CASES.md
---
# Assets Use Cases

The Assets module stores the files a business owns (product photos first,
brand logos and AI-generated images later) and tells every other module how
to show them, without any business domain ever persisting a storage URL, a
bucket, an object key or a provider credential. This document follows the
structure of [`CATALOG_USE_CASES.md`](CATALOG_USE_CASES.md): numbered cases
(`AS1`… for the Assets side, `AC1`… for the Catalog side of the link),
routes, statuses, codes, the decisions behind them, the schema changes they
need, and the slices that build them. **Status: built through A6.** Stages
A1a to A6 (A4b included) are built and each has an "as built" note in §14.1;
the module lives in `src/modules/assets` and the media worker in
`src/media-worker.ts`. A7 and A8a to A8d are specified and **blocked** on the
personal-data policy (OD15), and the erasure trigger (OD23) is not built.
The owner's infrastructure stage A0 is recorded per environment in
[`ASSETS_INFRASTRUCTURE_RUNBOOK.md`](../operations/ASSETS_INFRASTRUCTURE_RUNBOOK.md)
§0.4. Where a design section and an "as built" note differ, the note says
what the code does.

**Ground truth.** `database/canonical/0070_assets.sql` (line numbers below
refer to that file unless another file is named), `catalog.item_assets` in
`database/canonical/0080_catalog.sql:615-726`, `database/access/runtime_grants.sql`,
`database/canonical/0060_platform.sql` (plan limits),
`database/canonical/0360_events.sql` (tenant outbox), `.railway/railway.ts`,
`src/foundation/config/schema/environment-variables.schema.ts`,
`src/worker/worker.module.ts`, `src/foundation/logging/redaction/**`,
`src/foundation/telemetry/pipelines/node-instrumentation.ts`,
`src/modules/catalog/**`, `tooling/repository/check-case-matrix.ts`;
`AGENTS.md`; `docs/reference/ARCHITECTURE.md`, `DOMAIN_MAP.md`,
`DECISIONS.md` (D18, D24, D26, D30), `OUTBOX_USE_CASES.md`,
`SUBSCRIPTION_PLANS_USE_CASES.md`, `BUSINESS_DESIGN.md` §4,
`CATALOG_USE_CASES.md`; `docs/contracts/MODULE_CONTRACTS.md`,
`API_AND_SCHEMA_DISCIPLINE.md`, `FOUNDATION_HTTP_AND_OBSERVABILITY.md`.
External facts cite their source in [section 18](#18-sources).

**Status words used below.** *Decided* means this document takes the
decision because repository facts back it, and the owner only needs to
object. *Recommended* means the owner must answer; each one is an OD row in
[section 15](#15-owner-decisions) with a recommended default, so "yes, use
the recommended values" closes them all. *Proposed* means a shape this
document suggests for a later slice, to be confirmed when that slice is
designed in detail. *Assumption* means a fact this document could not verify.

***

## Contents

1. [Purpose and scope](#1-purpose-and-scope)
2. [Ownership and boundaries](#2-ownership-and-boundaries)
3. [Infrastructure that exists today](#3-infrastructure-that-exists-today)
4. [Table traceability and schema gaps](#4-table-traceability-and-schema-gaps)
5. [Domain model and state machines](#5-domain-model-and-state-machines)
6. [Design decisions](#6-design-decisions)
7. [Use cases](#7-use-cases)
8. [Derived API](#8-derived-api)
9. [Vertical variations](#9-vertical-variations)
10. [Authorization and plan](#10-authorization-and-plan)
11. [Security](#11-security)
12. [Performance and data design](#12-performance-and-data-design)
13. [Infrastructure plan (AWS, Cloudflare, OpenAI, Railway)](#13-infrastructure-plan-aws-cloudflare-openai-railway)
14. [Delivery slices](#14-delivery-slices)
15. [Owner decisions](#15-owner-decisions)
16. [Risks and what the existing SQL gets wrong or leaves open](#16-risks-and-what-the-existing-sql-gets-wrong-or-leaves-open)
17. [Planned case matrix](#17-planned-case-matrix)
18. [Sources](#18-sources)
19. [Glossary](#19-glossary)

***

## 1. Purpose and scope

### 1.1 What Assets is for

Assets answers two questions for every other module: **"is this file id
mine, and is it usable?"** and **"how does a client show it?"**. It owns the
logical file (`assets.assets`), its renditions (`assets.asset_variants`), the
physical copies (`assets.storage_objects`) and the provider definitions
(`assets.storage_providers`). The meaning of a file (a dish photo, a brand
logo, a payment receipt) belongs to the module that references it: the
header of `0070_assets.sql:18-24` says product/menu image semantics belong to
catalog/commerce, brand logos to organization/commerce, and "CDN/public
delivery URLs -> delivery layer/config".

| Business | What it needs on day one | What it does NOT need |
|---|---|---|
| Small restaurant | A photo per dish and drink, shown on the till tile and on a digital menu later; uploaded from a phone | Galleries of many photos, documents, video |
| Retail shop | A photo per product, sometimes per variant (the red shirt vs the blue shirt); bulk upload later | Video, 3D, PDFs of spec sheets (later) |
| Hotel | Photos of sellable extras (minibar, spa) as catalog items; room photos belong to Hospitality later | Room-type galleries now |

### 1.2 In scope for the first delivery

*Decided:*

1. **Image upload** by an organization-wide member, directly from the client
   to AWS S3 with a short-lived presigned POST whose policy bounds key, type
   and size; the API never receives the bytes (§6.2).
2. **Server-side verification and sanitization** by the `media-worker`: the uploaded
   bytes are sniffed, decoded under pixel limits, re-encoded into a
   metadata-free master, and never served as uploaded (§6.3, §11).
3. **Renditions** (`thumbnail`, `tile`, `card`, `detail`) generated by the
   worker from a job queued in the same transaction as the upload completion
   (D26 pattern).
4. **Public delivery** of `public` assets through immutable, unguessable,
   cacheable URLs on Cloudflare (R2 public bucket, §6.1, §6.4).
5. **A media library**: read one asset, list, rename, archive, restore.
6. **The Catalog link**: set the images of an item and of a variant
   (`catalog.item_assets`), images in the item read (C3) and the till read
   (C17/C19).
7. **Two in-process contracts** other modules consume: attachability and
   delivery (§6.6).
8. **Plan capability and limits**, quotas and abandoned-upload cleanup.

### 1.3 Explicitly out of scope, with the reason

| Out of scope | Reason | Where it goes |
|---|---|---|
| Private and sensitive delivery (signed GET URLs) | No first-delivery consumer stores private files; the only existing sensitive consumer, `audit.export_artifacts`, requires `access_class = 'sensitive'` (`0380_audit.sql:2148-2152`) but audit export is not built | Slice A7 (AS14), designed here, blocked until the residency sign-off (OD15) |
| AI-generated images (OpenAI GPT Image) | Fully designed (§6.7) but built after the first delivery, once OD15 and OD17 are answered | Slices A8a–A8d (AS15, AS16, AC6) |
| Documents (PDF), video, audio | Need content disarm or malware scanning before any serving (OWASP, §11) and different renditions; `duration_ms` exists (`0070:177`) but no consumer needs it now | Later, with its own design |
| SVG | Script and XXE risk (OWASP); forbidden (OD4) | Not planned |
| Import from a URL | Server-side fetch is an SSRF surface (§11) | Later, behind an allow-listed fetcher |
| Animated images | First frame only (§6.3) | Later if a storefront needs it |
| Content-hash deduplication | Saves little storage at these sizes (§12.3) and cross-tenant dedup leaks existence | Per-organization hint later (SG10) |
| Brand logos, storefront assets, payment evidence, delivery proofs | Their owning modules are not built; they reference `assets.assets` by FK already (§2.4) | Each consumer's own slice, reusing AS1–AS13 |
| Image editing (crop, rotate) in the server | The client crops before upload; the worker only auto-orients | Not planned |

***

## 2. Ownership and boundaries

### 2.1 Facts Assets owns

* the logical identity of a file: id, organization, display name, original
  filename (metadata only), access class, lifecycle (`0070:84-133`);
* its renditions and their immutable content facts: media type, byte size,
  SHA-256, dimensions (`0070:156-159`, protected by `protect_asset_variant`,
  `0070:644-716`);
* where each rendition physically lives (`storage_objects`, `0070:389-511`)
  and which provider holds it (`storage_providers`, `0070:318-371`);
* *new (§4.2):* upload intents, processing jobs and per-organization usage;
* *later:* generation requests (AI provenance).

### 2.2 Neighbours

| Neighbour | Direction | What crosses | Mechanism |
|---|---|---|---|
| Identity | Assets consumes | Permission decision | `PermissionCheckContract` / `OperationAuthorizationContract` |
| Platform | Assets consumes | Capability `assets.media`, limits | `OperationAuthorizationContract`; `platform.effective_limit` read in SQL inside the usage lock (as `identity.assert_membership_seat_available` does, `0040_identity.sql:2103-2129`) |
| Audit | Assets consumes | Tenant audit records | `TenantAuditContract` |
| Catalog | Consumes Assets | "Are these asset ids mine, ready, public?" and "give me delivery for these ids" | `AssetUsageContract`, `AssetDeliveryContract` (§6.6) inside the caller's tenant transaction (D18) |
| Media worker process (`media-worker`, OD14) | Executes Assets jobs and sweeps | Claim one job, record outcome, expire uploads, purge | SECURITY DEFINER functions granted to `ninaku_worker` only (D26, OB6) |
| Object storage (AWS S3 for uploads and masters, Cloudflare R2 for public renditions, OD1) | Assets infrastructure | Sign POST policies (API, local computation), read/write objects (media worker) | `ObjectStoragePort` in `assets/application/ports`, adapter in `assets/infrastructure` |
| Every other module with an FK to `assets.assets` (§2.4) | Consume later | Same two contracts | No new mechanism |

### 2.3 Module contracts (R1 to R9)

*Decided.* The module lives at `src/modules/assets/` with the standard
layers (`ARCHITECTURE.md` §6; `ARCHITECTURE.md:112` already lists
`modules/assets/` in the target layout).

| Rule | Consequence for Assets |
|---|---|
| R1 | Assets imports only `identity/contracts`, `platform/contracts`, `audit/contracts`. Catalog imports only `assets/contracts`, never the opposite: Assets never knows who references an asset (`DOMAIN_MAP.md:37`: "no obligar a conocer significado de todos sus consumidores") |
| R2, R6, R7 | Use cases open the tenant transaction; repositories run SQL; the storage SDK lives only in `infrastructure/` |
| R3 | Pure domain: media-type allow-list, magic-byte sniffing, dimension rules, rendition profile, state transitions, object key layout |
| R4 | The storage adapter is Assets' own infrastructure, not `foundation/`: only Assets touches storage. If a second module ever needs raw object storage, the wrapper moves to `foundation/` then (`AGENTS.md` §4, "three copies" rule) |
| R9 | No system transaction in Assets: the API always runs with tenant context; the worker reaches the database only through SECURITY DEFINER functions as `ninaku_worker`, exactly as the Identity security outbox does (`runtime_grants.sql:177-181`) |

Published contracts (section 15 of `MODULE_CONTRACTS.md` lists them in the
same change that publishes them): `AssetUsageContract`
(`src/modules/assets/contracts/asset-usage.contract.ts`) and
`AssetDeliveryContract` (`src/modules/assets/contracts/asset-delivery.contract.ts`).

### 2.4 Schema boundary and existing cross-schema SQL

* **The API cannot reach the schema.** `runtime_grants.sql:36` grants `USAGE`
  only on `reference, identity, organization, platform` (catalog is added at
  `:116`, audit at `:130`); nothing on `assets`. `ninaku_worker` has only the
  Identity outbox functions (`:177-181`).
* **Fourteen tables already reference `assets.assets`** by a tenant-safe
  composite FK `(asset_id, organization_id)` (counted with `rg 'REFERENCES
  assets\.'`): `catalog.item_assets` (constraint at `0080:660`, `REFERENCES` at `0080:665`), `commerce.storefront_assets`
  (`0100:374`), `payments.payment_evidence` (`0130:1349`),
  `fulfillment.handoff_proofs` (`0160:2347`), `tax.party_exemptions`
  (`0260:909`), `documents.template_assets` (`0310:263`),
  `documents.render_snapshots` (`0310:609`), `printing.print_content_snapshots`
  (`0320:638`), `communications.message_revision_attachments` (`0340:564`),
  `sync.bootstrap_sessions.snapshot_asset_id` (`0370:523`),
  `audit.export_artifacts` (`0380:782`), two analytics tables (`0390:531`,
  `0390:1551`) and `fiscal` qualification evidence (`0400:625`). **Asset
  identity is load-bearing**: an asset id is never reused and a referenced
  asset is never purged (§6.8, AS10).
* None of those FKs has `ON DELETE`, so PostgreSQL refuses to delete a
  referenced `assets.assets` row (`23503`); AS10 uses the `DELETE`'s own
  `23503`, caught inside its SECURITY DEFINER function, as its reference
  check, so Assets never lists or probes its consumers (no `pg_constraint`
  enumeration).
* **Invariant (*Decided*): every consumer attaches an asset only through
  `AssetUsageContract.assertAttachable`** (AS13), inside its own command's
  transaction. That is what lets AS10 mark an asset `purging` under a row
  lock and be sure no new reference appears afterwards; a consumer that
  inserts an FK row without the contract breaks erasure, so each consumer's
  slice carries a test that its attach path calls the contract.
* `assets.assets` has the unique `(id, organization_id)` that makes those
  composite FKs tenant-safe (`0070:127-132`).

***

## 3. Infrastructure that exists today

| Fact | Evidence | Consequence |
|---|---|---|
| Railway runs four resources: `postgres`, `database-migrator`, `api`, `worker` | `.railway/railway.ts:186-189` | No storage resource is declared; adding buckets is an owner-approved infrastructure change (`AGENTS.md` §10) |
| `api` and `worker` each run **one replica** | `railway.ts:127`, `:161` | Image processing inside the API would compete with every request on one process; it goes to the worker (D26) |
| The worker is the same codebase with entrypoint `node dist/worker`, role `ninaku_worker`; it hosts only the Identity security outbox dispatch (OTP codes, invitations) and declares no memory limit | `railway.ts:151-184`, `src/worker.ts`, `src/worker/worker.module.ts:8-18` | A sharp out-of-memory crash there would delay sign-up and recovery codes. Image processing runs in a **separate Railway service `media-worker`** (same image, entrypoint `node dist/media-worker`, its own memory budget, OD14), with its own `MediaWorkerModule` |
| Scheduled sweeps run in the API process today | `SubscriptionLifecycleSweepJob` is registered in `src/modules/platform/platform.module.ts:57,157`, not in the worker | Assets sweeps (AS9) run in `media-worker`, copying that job's advisory-lock discipline, not its process |
| No storage, bucket, CDN or image variable exists | `environment-variables.schema.ts:186-232` lists database, telemetry, AWS SES (`:213-216`), outbox settings; nothing for storage | New variables (§13.6, A0) |
| AWS credentials exist for SES only | `railway.ts:140-142`, `@aws-sdk/client-sesv2` in `package.json:67` | The AWS SDK v3 family is already a dependency; `@aws-sdk/client-s3` and `@aws-sdk/s3-presigned-post` talk to Amazon S3 for uploads and masters, and the same `client-s3`, configured with R2's endpoint, writes only the public renditions to R2. Storage credentials are separate variables, never the SES keys |
| No image library is installed | `package.json` dependencies (no `sharp`); `multer` appears only as an `overrides` pin (`package.json:131-133`) | `sharp` is a new worker dependency (§6.3), justified under `NESTJS_STACK_GUIDE.md` §1 |
| Database timeouts: statement 5,000 ms, lock 2,000 ms, idle in transaction 10,000 ms | `environment-variables.schema.ts:186-189` | No transaction may stay open while bytes move or images decode (§6.3) |
| No JSON body limit override | no `useBodyParser`/`bodyParser` call under `src/bootstrap` (searched); Express' `body-parser` defaults to `100kb` (source S16) | Bytes through the API would need multipart and a raised limit (§6.2) |
| Default rate limit 120 requests/minute per client IP | `src/foundation/http/rate-limiting/rate-limit.ts:1-3` | Upload intents get a stricter `@Throttle` plus a database cap (AS1) |
| CORS allows only the docs origins | `railway.ts:24-41`; `FOUNDATION_HTTP_AND_OBSERVABILITY.md` §13 | The bucket's own CORS policy, not the API's, governs browser `POST` uploads (§6.4, §13.5) |
| Span exporter redacts `X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`, `X-Goog-Signature` query params | `src/foundation/telemetry/pipelines/node-instrumentation.ts:15-30` | Signed URLs in spans are covered |
| Log redaction of query params does **not** cover `X-Amz-Signature` | `redaction.ts:27-33` uses `sensitive-field-names.ts:1-30`: `credential` and `token` match, `signature` does not | Gap SEC-1 (§11): A2a adds it before any presigned URL exists |
| The tenant outbox `events.outbox_records` exists, unused, with no grant to anyone | `0360_events.sql:202-234`; `OUTBOX_USE_CASES.md` OX-W1 decision row | OD7 decides whether Assets jobs use it or a module-owned queue |

***

## 4. Table traceability and schema gaps

### 4.1 Existing tables

| Table (line) | Class | Reason |
|---|---|---|
| `assets.assets` (84) | USE NOW | The logical file. Columns used: all. Missing lifecycle columns (SG1–SG5) |
| `assets.asset_variants` (162) | USE NOW | `original` (sanitized master) and the `p1-*` renditions; its state machine (`0070:674-706`) supports retry from `failed` |
| `assets.storage_objects` (389) | USE NOW | One `primary` per variant; `available` at verification; deleted together with its variant and asset by the purge (SG16), never walked through `deleting` |
| `assets.storage_providers` (318) | USE NOW | Two seeded rows (OD1): `s3-private` (adapter `s3`: `in/`, `m/`, `p/` prefixes) and `r2-public` (adapter `cloudflare_r2`); `configuration_ref` names the environment prefix, never a secret (`0070:310-312`) |
| `catalog.item_assets` (`0080:623`) | USE NOW (A5b) | Item- and variant-level images, `primary`/`gallery`, `sort_order`; one primary per item and per variant (`0080:702-716`) |

### 4.2 Schema gaps (exact proposed changes)

Nothing is added to the database in this task. Each gap names the change a
later migration makes, and why.

| # | Gap | Evidence | Proposed change (migration) | Why |
|---|---|---|---|---|
| **SG1** | `assets.assets` has no lifecycle status: readiness can only be derived by joining variants and storage objects | `0070:84-133`: only `created_at`, `archived_at` | `status varchar(20) NOT NULL DEFAULT 'pending'`, `CHECK (status IN ('pending','processing','ready','failed','purging'))` (`purging`: SG16); `ready_at timestamptz`, `failed_at timestamptz`, `failure_code varchar(80)`; `assets_status_state_chk`: `pending/processing` ⇒ both null; `ready` ⇒ `ready_at NOT NULL AND failed_at IS NULL`; `failed` ⇒ `failed_at NOT NULL AND ready_at IS NULL AND failure_code IS NOT NULL`. A deferred constraint trigger asserts that a `ready` asset has its `original` and every variant of its rendition profile `ready`. `archived_at` stays independent (an archived asset keeps its status) | The delivery hot path (C17) must read one row per asset, never a three-table join (§12.2) |
| **SG2** | No delivery identifier: a public URL would have to expose the asset id or the object key | — | `delivery_token varchar(26) NOT NULL`, `CHECK (delivery_token ~ '^[a-z2-7]{26}$')` (128 random bits, base32), `UNIQUE (delivery_token)` | Unguessable, tenant-free public paths; the asset id and organization never appear in a URL (§6.4) |
| **SG3** | No rendition profile | — | `rendition_profile smallint NOT NULL DEFAULT 1 CHECK (rendition_profile > 0)` | A ready asset has every rendition of its profile, so delivery derives URLs with no variant join; a new profile regenerates under new codes without breaking old immutable URLs (§6.3) |
| **SG4** | No media kind | — | `media_kind varchar(20) NOT NULL DEFAULT 'image' CHECK (media_kind IN ('image'))` | Consumers (Catalog) require images; documents later widen the check |
| **SG5** | No provenance or creator | — | `source_kind varchar(20) NOT NULL DEFAULT 'upload' CHECK (source_kind IN ('upload','ai_generated'))` (SG21); `created_by_membership_id uuid` FK `(created_by_membership_id, organization_id)` → `identity.memberships (id, organization_id)` (the tenant-safe pair `memberships_id_organization_uq`, `0040_identity.sql:570`) | AI provenance hook (§6.7) and "who uploaded this" in the library |
| **SG6** | No upload intent | — | New table `assets.uploads`: `id uuid PK`, `organization_id uuid NOT NULL`, `asset_id uuid NOT NULL` (FK `(asset_id, organization_id)`, `UNIQUE (asset_id)`), `staging_provider_id uuid NOT NULL` FK providers, `staging_object_key varchar(1024) NOT NULL`, `declared_media_type varchar(255) NOT NULL`, `declared_byte_size bigint NOT NULL CHECK (> 0)`, `state varchar(20) NOT NULL DEFAULT 'issued' CHECK IN ('issued','received','verified','rejected','expired')`, `expires_at timestamptz NOT NULL`, `received_at`, `settled_at timestamptz`, `rejection_code varchar(80)`, `creation_idempotency_key uuid NOT NULL`, `creation_request_hash text NOT NULL CHECK (creation_request_hash ~ '^[0-9a-f]{64}$')` (the exact shape of migration `0081_catalog_creates_remember_their_request.sql:8-13`), `created_at`; `UNIQUE (organization_id, creation_idempotency_key)`; `UNIQUE (staging_provider_id, staging_object_key)`; partial index `(expires_at) WHERE state IN ('issued','received')`; state-shape check like `storage_objects_state_chk`; RLS select/insert/update by organization (scalar subquery, `API_AND_SCHEMA_DISCIPLINE.md` §4) | Idempotent intent (`FOUNDATION_HTTP_AND_OBSERVABILITY.md` §7: the claim lives in the module's own table), expiry sweep, declared-vs-verified comparison. The staging object is not a `storage_objects` row: it is never a rendition and never served |
| **SG7** | No processing queue | `0070:25` says "Worker queue execution -> BullMQ/workers", which D26 superseded | New table `assets.processing_jobs` (OD7): `id uuid PK`, `organization_id`, `asset_id` (FK), `job_kind varchar(40) CHECK IN ('process_upload','render_profile','purge_asset')` (`generate_image` added by A8a, SG21) (upload expiry is a sweep, AS9, not a job), `state varchar(20) CHECK IN ('pending','leased','completed','dead_lettered')`, `available_at`, `lease_owner`, `lease_token uuid`, `lease_expires_at`, `attempt_count integer >= 0` (the total of every retry), `processing_error_count integer >= 0` (processing errors only, the dead-letter budget; R1, `0093`), `last_error_code varchar(80)`, `completed_at`, `created_at`, `updated_at`; lease-shape check copied from `evt_outbox_lease_shape_chk` (`0360:221-226`); claim index `(job_kind, available_at) WHERE state IN ('pending','leased')` and `error_class varchar(20)` (`storage`, `processing`, `permanent`) recording the retry class (the stage specification is authoritative, `ASSETS_STAGE_SPECS.md` §4); `UNIQUE (asset_id, job_kind) WHERE state IN ('pending','leased')` so a job is never queued twice. No runtime `SELECT`; enqueue only inside `assets.complete_upload(...)` (SECURITY DEFINER, `EXECUTE` to `ninaku_runtime`); claim/record through `assets.claim_processing_job(owner, lease_token, lease_seconds, job_kinds)` (**one job per call**, of the kinds the calling loop handles, so a lease never covers work queued behind it) and `assets.record_processing_outcome(...)` (`EXECUTE` to `ninaku_worker` only) | D26/OB6: the API enqueues, only the worker dispatches; claim with `FOR UPDATE SKIP LOCKED` + lease (OX-W1–W3) |
| **SG8** | No usage counter: a quota check would sum every variant of the organization on each upload | — | New table `assets.organization_usage (organization_id PK FK, active_asset_count integer NOT NULL DEFAULT 0, stored_bytes bigint NOT NULL DEFAULT 0, reserved_bytes bigint NOT NULL DEFAULT 0, updated_at)`, non-negative checks; row created by a trigger on `organization.organizations` insert and backfilled (the `catalog_versions` pattern, "K1 as built"); maintained only by SECURITY DEFINER functions (intent reserves declared bytes, outcome converts to verified bytes). **Semantics (*Decided*):** `active_asset_count` counts assets in `pending`, `processing` or `ready` that are not archived (failed, archived and purging assets do not count); `stored_bytes` counts every byte physically stored (archived assets included until purged); verified bytes (master + renditions) can exceed the declared upload size, so the storage quota is soft by that delta, at most a few MiB per asset. **Reconciliation:** `assets.reconcile_usage(organization_id)` recomputes the three counters from rows; purge and the nightly sweep call it instead of subtracting, so a counting bug in a release corrects itself instead of tripping the `>= 0` check and failing the purge | O(1) quota check under a row lock, the per-organization serialization point for uploads |
| **SG9** | A failed variant has no reason | `0070:250-274`: `failed` only needs `failed_at` | `asset_variants.failure_code varchar(80)`, required when `status = 'failed'` (extend `asset_variants_state_chk`) | The library shows why processing failed (`assets.content_mismatch`, `assets.image_too_large`, …) |
| **SG10** | No per-organization content lookup | — | *Proposed, not v1:* index `(organization_id, checksum_sha256) WHERE lower(variant_code) = 'original'` | A later "you already uploaded this photo" hint; never across organizations |
| **SG11** | `catalog.item_assets` is unreachable and invisible to the till ETag | No grant (`runtime_grants.sql:117`); `item_assets` is in the K1 contract test's unreachable list (`database/tests/contracts/catalog-foundations-contracts.ts:45-57`); the eleven version-bump tables of `0075_catalog_foundations.sql:60-112` exclude it | Catalog migration (A5b): `GRANT SELECT, INSERT, UPDATE, DELETE ON catalog.item_assets TO ninaku_runtime`; statement-level `catalog.bump_catalog_version()` triggers on `item_assets` insert/update/delete; index `item_assets_organization_asset_idx (organization_id, asset_id)` | Set-based image writes (AC1, AC2); C17's `304` must stop matching after an image change; the purge's foreign-key check and "where is this asset used" need an index leading with `asset_id` (today only `(item_id, asset_id)` and `(item_variant_id, asset_id)` partial uniques exist, `0080:688-700`) |
| **SG12** | Library keyset order lacks a tiebreak | `assets_organization_active_idx (organization_id, created_at DESC) WHERE archived_at IS NULL` (`0070:135-140`) | Replace with `(organization_id, created_at DESC, id DESC) WHERE archived_at IS NULL` and add the archived twin, after `EXPLAIN (ANALYZE, BUFFERS)` | Keyset `(created_at DESC, id DESC)` (`AGENTS.md` §5.3) |
| **SG13** | No `updated_at` on `assets.assets` | `0070:84-133` | None: the application never needs it; the library item ETag is a content hash (D24) | Avoids a column with no reader (`API_AND_SCHEMA_DISCIPLINE.md` §3) |
| **SG14** | Physical erasure has no path for the runtime | No `DELETE` policy (`0070:952-960`) | `assets.purge_asset_begin(job, lease)` and `assets.purge_asset_finish(job, lease)`, SECURITY DEFINER, `EXECUTE` to `ninaku_worker` only (AS10, slice A4b) | Retention and erasure without giving any role `DELETE` |
| **SG15** | Free text has no NUL or length discipline in SQL | `original_filename varchar(500)`, `display_name varchar(500)` (`0070:88-89`) | None in SQL; the application applies `trimmedBoundedText` (≤255, rejects NUL, `AGENTS.md` §4) | One rule in the HTTP schema, as Catalog does |
| **SG16** | No terminal state while an asset is being erased: storage objects cannot move to `deleting` while their variant is `ready` (a ready variant needs an `available` primary at commit, `0070:883-949`, and can never leave `ready`, `0070:657-672`) | `0070:657-672`, `:883-949` | Add `'purging'` to the SG1 status check (terminal: no transition out). AS6 restoration and AS13 `assertAttachable` refuse it; `describe` returns no URLs for it. Storage rows stay untouched until the provider delete is confirmed; `purge_asset_finish` deletes storage objects, variants, upload, jobs and the asset in **one** transaction, which satisfies both deferred triggers (a deleted variant has no primary to assert, a deleted asset no original) | Erasure that never breaks the existing invariants and cannot race a restore or an attach (A4b) |
| **SG17** | `access_class` can be updated freely | `assets_update_policy` (`0070:982-992`) allows any column | `BEFORE UPDATE OF access_class` trigger refusing any change (`assets_access_class_immutable`, `23514`) | Switching public ↔ private means moving objects between buckets and purging the CDN; a new class is a new asset |
| **SG18** | Nothing guards `assets.status` transitions | SG1 adds the column | `BEFORE UPDATE OF status` trigger allowing only `pending → processing → ready`, `pending/processing → failed`, `failed → processing` (retry), `ready/failed → purging`; `archived_at` cannot be cleared while `purging` | The runtime never writes `status` (§14, A1a grants); the trigger is the backstop for the definer functions |

### 4.3 Contradictions between the existing schema and this design

| # | Contradiction | Resolution |
|---|---|---|
| X1 | `0070:25` assigns worker queues to "BullMQ/workers" | D26 (PostgreSQL queue, separate worker, never BullMQ) wins (`AGENTS.md` order: `DECISIONS.md` ranks above a canonical comment); the comment cannot be edited cheaply because `lint:no-comments` keeps a shrink-only allowlist (`AGENTS.md` §4); record only |
| X2 | `access_class` defaults to `private` (`0070:91`), while product images must be `public` | The upload request carries `accessClass` explicitly (no default in the API); Catalog refuses non-public assets (AC1). The SQL default stays as the safe fallback |
| X3 | "Every Asset has exactly one logical original Variant" (`0070:146`) assumes the original is the uploaded file | *Decided:* `original` is the sanitized master (re-encoded, metadata stripped, ≤ 4,096 px). The raw upload is a staging object, never a variant (§6.3). The content facts still "describe exact bytes" (`0070:156-158`) |
| X4 | `asset_variants_asset_code_ci_uq` (`0070:283-287`) forbids a second `tile` for the same asset, and ready content is immutable (`0070:657-672`) | Rendition codes carry the profile (`p1-tile`), so a profile 2 adds `p2-tile` beside it; old URLs stay valid |
| X5 | A ready variant needs an available primary storage object at commit (`0070:937-949`) | The worker function inserts the storage object (`available`, `verified_at`) and marks the variant `ready` in one transaction: compatible |

***

## 5. Domain model and state machines

### 5.1 Aggregates

| Aggregate | Root | Inside | Consistency boundary |
|---|---|---|---|
| **Asset** | `Asset` (`assets.assets`) | `Rendition` (variants), `StoredCopy` (storage objects), `Upload` (1:1 while it lives) | One asset changes in one transaction; the worker changes it only through its SECURITY DEFINER functions |
| **Organization usage** | `OrganizationUsage` (`assets.organization_usage`) | counters | Locked `FOR UPDATE` first by every command that reserves or releases bytes (lock order §7) |
| **Processing job** | `ProcessingJob` | lease, attempts | Claimed with `SKIP LOCKED`; never locked by the API |

### 5.2 Identifiers, codes and keys

| Field | Rule | Source |
|---|---|---|
| ids | UUIDv7 generated by the application | `0070:52` |
| `delivery_token` | 26 lowercase base32 characters from 16 bytes of `crypto.randomBytes` | SG2 |
| variant codes | `original`, `p1-thumbnail`, `p1-tile`, `p1-card`, `p1-detail`; pattern `^[a-z][a-z0-9_-]{0,79}$` | `0070:193-196` |
| object keys | server-generated only, never from user input: staging `in/<upload token>` (S3); master `m/<delivery token>/original.webp` (S3); public renditions `a/<delivery token>/p<profile>/<rendition>.webp` (R2 public bucket); private renditions `p/<delivery token>/p<profile>/<rendition>.webp` (S3). `<rendition>` is the variant code without its profile prefix: variant `p1-tile` lives at `a/<token>/p1/tile.webp`, so the till's `imageRef` (`<token>/p1`) and the template `…/a/{imageRef}/tile.webp` (§6.4) name exactly that object | `0070:379`; OWASP "change the filename to something generated by the application" (S9) |
| no organization prefix | *Decided:* keys never carry the organization, so an organization's objects cannot be deleted by prefix. Offboarding purge is database-driven: it enumerates the organization's `storage_objects` through `storage_objects_organization_idx (organization_id, asset_variant_id)` (`0070:535-539`) and runs AS10 per asset (a staff command after the organization is archived, designed with A4b, built when offboarding is) | Accepted cost: no tenant identifier in any URL |
| `display_name`, `original_filename` | `trimmedBoundedText`, ≤255, NUL rejected, control characters stripped; metadata only, never in a key, a URL or a header | SG15 |

### 5.3 Asset state machine (SG1)

```text
           completion (AS2)          worker verifies + renders (AS7)
 pending ───────────────────▶ processing ───────────────────────▶ ready
    │                             │   ▲                              │
    │ intent expires (AS9)        │   └─ transient error: retry ─────┘ (never leaves ready)
    ▼                             ▼
  failed ◀──────────── permanent error or retries exhausted (AS8)

 ready or failed ── purge begins, nothing references it (AS10) ──▶ purging ──▶ row deleted
 archived_at is orthogonal: archival (AS6) sets it on any status but purging;
 restoration clears it, never on a purging asset. purging is terminal (SG16).
```

| Asset status | Original variant | Rendition variants | Upload state | Job |
|---|---|---|---|---|
| `pending` | `pending` | none yet | `issued` | none |
| `processing` | `pending` → `processing` | `processing` (inserted by the worker) | `received` | `process_upload` pending/leased |
| `ready` | `ready` (master, S3 `m/`) | all of profile 1 `ready` | `verified` | `completed` |
| `failed` | `failed` (`failure_code`) | `failed` or absent | `rejected` or `expired` | `completed` or `dead_lettered` |
| `purging` | unchanged (`ready` or `failed`) | unchanged | unchanged | `purge_asset` pending/leased |

### 5.4 Variant and storage object machines

Variants use the existing transitions (`0070:674-706`): `pending →
processing → ready | failed`, `failed → pending | processing` (retry);
`ready` is final and immutable. Storage objects use theirs (`0070:819-864`)
only for `pending → available`. *Decided:* the worker inserts storage
objects directly as `available` after it has written and re-read the object
(one row per variant, role `primary`). **Purge never walks storage objects
through `deleting`/`deleted`:** while the variant is `ready` that would break
`assert_variant_has_primary_storage` (`0070:883-910`); instead the rows are
deleted together with their variant and asset in AS10's final transaction.

### 5.5 Rendition profile 1

*Recommended (OD3):*

| Code | Geometry | Format | Purpose | Approx. size (assumption) |
|---|---|---|---|---|
| `original` | fit inside 4,096 × 4,096, never enlarged | WebP q90 (alpha kept) | Master for regeneration; S3 `m/`; never served publicly | 300 KB–1.5 MB |
| `p1-thumbnail` | 160 × 160 cover, `attention` crop | WebP q80 | Back-office lists | 5–10 KB |
| `p1-tile` | 320 × 320 cover, `attention` crop | WebP q80 | Till tile, offline cache | 15–30 KB |
| `p1-card` | fit inside 640 × 640 | WebP q80 | Item detail panel, storefront card | 40–80 KB |
| `p1-detail` | fit inside 1,280 × 1,280 | WebP q82 | Zoom, storefront detail | 120–250 KB |

WebP is chosen over AVIF for v1 because sharp's default AVIF effort is far
slower per image and WebP decodes everywhere the clients run (S6); AVIF can
be profile 2. sharp's WebP default is quality 80, effort 4 (S6).

***

## 6. Design decisions

Each decision states the alternatives, the tradeoffs, the default and the
facts behind it.

### 6.1 Storage and delivery architecture (*Recommended*, OD1 — changed)

**Owner input (2026-10-06).** The owner already operates AWS (SES sends the
security emails; the repository holds AWS credentials for SES only,
`railway.ts:140-142`, `environment-variables.schema.ts:213-216`) and
Cloudflare (DNS and CDN), and wants S3 used. The earlier default (R2 for
everything) is replaced by an evaluation of four architectures.

| Option | Storage | Delivery path | Origin authentication | Extra component | Consoles to run |
|---|---|---|---|---|---|
| **A** | S3 | Media domain proxied by Cloudflare, origin S3 | Cloudflare's Cloud Connector routes to S3 but "only supports public buckets" (S23); a private bucket needs a Cloudflare Worker that signs SigV4 requests (aws4fetch, S42). An IP allow-list of Cloudflare ranges or an `aws:Referer` secret in the bucket policy are brittle or weak | A Worker on every media request (Workers run before the cache and every request is billed, S24) | AWS, Cloudflare |
| **B** | S3 | AWS CloudFront with Origin Access Control; the media hostname's DNS record in Cloudflare is DNS-only (not proxied) | OAC, native | CloudFront distribution | AWS, Cloudflare (DNS only) |
| **C** | R2 only (previous version) | R2 custom domain on Cloudflare | Native | None | Cloudflare, plus AWS for SES |
| **D** | **S3 for the system of record** (upload inbox, masters, private renditions) **and R2 only for public renditions** | R2 custom domain on Cloudflare (S3 is never on the delivery path) | S3 never public; R2 public bucket served natively | None (both are S3 APIs: one adapter, two configurations, `0070:302-308` already models several providers) | AWS, Cloudflare |

**Monthly cost (USD) by option.** Prices: S3 $0.023/GB-month, GET
$0.0004/1k, egress $0.09/GB after 100 GB free (S10); CloudFront 1 TB and
10 M requests free, then South America $0.110/GB and $0.022 per 10k HTTPS
requests (S25), origin fetches from S3 free (S25); Workers Paid $5/month,
10 M requests included, $0.30/M after, cached requests billed (S24); R2
$0.015/GB-month, free egress, 10 M Class B free (S1). *Assumptions:* 25 KB
average delivered object, 95% Cloudflare cache hit ratio, masters ≈ 70% of
stored bytes, and the worker reads each upload from S3 once (≈ 3 MB).

| Profile | Stored | Served/month | A: S3 + Cloudflare Worker | B: S3 + CloudFront | C: R2 only | **D: S3 + R2 public** |
|---|---|---|---|---|---|---|
| Small restaurant | 0.3 GB | 5 GB, 0.2 M requests | ≈ $0 (Workers Free 100k/day, S24) | ≈ $0 (free tier) | ≈ $0 | **≈ $0** |
| Retail, 50,000 images | 50 GB | 100 GB, 4 M requests | ≈ $6 ($1.15 S3 + $5 Workers) | ≈ $1.15 | ≈ $0.60 | **≈ $0.90** |
| Platform, 1,000 organizations | 2 TB | 5 TB, 200 M requests | ≈ $129 ($46 S3 + $13.5 egress on misses + $4 GETs + $62 Workers requests + ≈ $3 Workers CPU) | ≈ $916 pay-as-you-go ($46 storage + 4,096 GB × $0.110 = $451 data + $418 requests) or the $1,000 Premium flat plan (S25) | ≈ $30 | **≈ $46** ($32 S3 + $9 R2 + ≈ $4.5 S3 egress for the worker reading uploads, assuming ≈ 150 GB/month of uploads after the 100 GB monthly free allowance) |

Free tiers belong to the account, not to each tenant, so only the platform
row predicts the bill at scale.

**Recommendation: D.** It is **not** the cheapest: C (R2 only) costs about
$30 against D's $46 at platform scale. D is chosen because the owner wants
S3 used, and S3 is the only one of the two with **POST policies that bound
the upload size at the storage layer** (§6.2) and IAM policies scoped by key
prefix; D keeps that for uploads and masters while putting no AWS egress on
the delivery path, needing no extra edge component and no
origin-authentication trick. Cloudflare stays the single CDN, DNS and WAF the
owner already runs.

**D's costs beyond the table (master re-reads, note 21).** Every time the
worker re-reads masters from S3 it pays S3 egress to the internet ($0.09/GB
after 100 GB/month, S10): a profile-2 regeneration of the platform's ≈ 1.4 TB
of masters costs ≈ $126 once; AI "enhance" re-reads one master (≈ 1 MB) per
request (≈ $1.80 per 20,000 requests). Railway's own egress for the worker's
writes to S3 and R2 is **unverified** (Railway pricing not fetched).

**Fallbacks, if the owner rejects R2:** A (Cloudflare remains the CDN; about
$129/month at platform scale, a Worker to own); B (documented, AWS-only
delivery, ≈ $916/month at platform scale because CloudFront's South American
egress and request prices dominate).

**AWS region (*Recommended*, OD1).** `us-east-1`. Its S3 prices are the
ones quoted (S10); `sa-east-1` is the only South American region and
costs more per GB (*assumption*: about 1.7×, not fetched); round-trip time
from Bogotá and Quito to Northern Virginia is lower than to São Paulo
(*assumption*: no AWS-published figure fetched; A0 measures it from a phone
on the tills' networks). Region matters only for uploads and the worker:
public delivery goes through Cloudflare's edge. SES's region is the value of
`AWS_REGION`, a preserved secret this task cannot read (the SES spec uses
`us-east-1` only as a test value, `ses.connector.spec.ts:24`); S3 gets its
own variable, `ASSETS_S3_REGION`, so the two never depend on each other.

**S3 layout (*Decided*): one bucket per environment, prefixes and IAM per
principal.** R2 needed three buckets because its tokens are scoped per bucket
(S21); S3 IAM policies are scoped by key prefix, and lifecycle rules filter
by prefix (S29), so one bucket per environment is enough:

| Prefix (bucket `ninaku-<env>-assets`) | Holds | Writers | Readers | Lifecycle |
|---|---|---|---|---|
| `in/` | Raw uploads | Browser POST signed by the API principal | media-worker | Expire after 1 day; abort incomplete multipart after 1 day (S29) |
| `m/` | Masters (`m/<token>/original.webp`) and AI provider originals | media-worker | media-worker | None |
| `p/` | Private renditions (A7) | media-worker | API presign (A7) | None |

Settings: Block Public Access on (all four settings), Object Ownership
"Bucket owner enforced" with ACLs disabled, which is the default for new
buckets (S28); default encryption SSE-S3, applied to every new object at no
cost since 2023-01-05 (S27); SSE-KMS rejected for v1 (KMS charges and
request quotas apply, S27, with no requirement that needs customer-managed
keys); **versioning off** (keys are immutable and never overwritten after
`ready`, and erasure must really delete: versioning would keep noncurrent
copies); server access logs off in v1 (CloudTrail management events cover
configuration changes; data events are billed per event and not justified
until a private or sensitive file exists, A7). **S3 Event Notifications are
not used:** the completion call (AS2) already enqueues the job in the same
transaction as the state change (D26), while an event pipeline would add SQS,
a consumer and a reconciliation between two queues.

**R2 public bucket (`ninaku-<env>-media-public`).** Written only by the
media worker (a token scoped to this bucket, S21), served at the media
domain (OD13) with Cloudflare cache, `r2.dev` disabled in production (R2-PB).

**Regional/legal.** Product photos are not personal data by design; EXIF
(GPS, device) is stripped (§6.3). Masters sit in `us-east-1`, public
renditions in R2 with location hint `enam` (S5). *Assumption:* neither
Colombian nor Ecuadorian law requires in-country storage of product images.
A legal sign-off is required before any `private` or `sensitive` file ships
and before tenant data is sent to OpenAI (OD15); A7 and A8 stay blocked until
it is answered.

### 6.2 Upload flow (*Decided* except limits, OD5)

**The client never sends bytes through the API.** Reasons (repository facts):
the API runs one replica (`railway.ts:127`); Express' JSON parser accepts
100 kB by default (S16) and the API parses no multipart today; Railway
closes uploads that take over 5 minutes and limits idle transfers (S13);
decoding in the request process is exactly the saturation D26 moved out of
the API. Even "small images through the API" would need multipart parsing,
a raised limit and memory per request, for an image that must still go to
storage and be decoded by the worker: no gain.

```text
client                    API (tenant tx)                 S3 (in/)                 media-worker
  │ POST /assets/uploads ──▶ authorize, quota, rate cap
  │                          reserve_upload: asset(pending) + original(pending)
  │                          + upload(issued) + reserved bytes + audit
  │ ◀── 201 {asset, upload: {url, fields, expiresAt}}
  │                          (the POST policy is signed locally: no network call)
  │ multipart/form-data POST (fields + file) ──────────────▶ in/<token>
  │ POST /assets/uploads/{id}/completion ──▶ upload received, asset processing,
  │                          process_upload queued (same tx)
  │ ◀── 202 {asset status: processing}
  │                                                                     claim one job (tx1)
  │                                                    ◀── HEAD, GET ── sniff, decode once,
  │                                                    ── PUT ────────▶ master (S3 m/)
  │                                                                     renditions ──▶ R2 public
  │                                                                     record outcome (tx2)
  │ GET /assets/{id} (poll) ──▶ status ready + rendition URLs
```

1. **Intent** (AS1): `POST /assets/uploads` with `Idempotency-Key` and
   `{ mediaType, byteSize, accessClass, displayName?, originalFilename? }`.
   One call to `assets.reserve_upload(...)` (SECURITY DEFINER, the **only**
   way the runtime can create assets, variants and uploads) creates the
   asset (`pending`), its `original` variant (`pending`, satisfying the
   deferred "original required" trigger, `0070:584-630`), the
   `assets.uploads` row (SG6) and reserves `byteSize` in `organization_usage`
   (SG8). The response carries an **S3 presigned POST** (URL plus form
   fields) valid 10 minutes whose policy has exactly these conditions:
   `{"bucket": "<bucket>"}`, `{"key": "in/<token>"}` (exact, not
   `starts-with`), `{"Content-Type": "<declared type>"}` (exact),
   `["content-length-range", 1, <declared byteSize>]`, plus the
   `x-amz-algorithm`, `x-amz-credential` and `x-amz-date` conditions SigV4
   requires (S11).
   **How the upload size is bounded now:** S3 itself refuses a body larger
   than the declared size (`content-length-range`, S11), and the declared
   size is at most 10 MiB (OD5). The previous "A2a gate" (proving that R2
   enforces a signed `Content-Length` on `PUT`, with a 3-minute fallback) is
   **no longer needed**; what remains of it in A2a is a contract test that a
   POST one byte over the declared size and a POST with another content type
   or key are refused by S3 (run against the staging bucket and the local
   S3-compatible server). The abuse exposure of the previous version
   (dollars per day from 5 GiB uploads) disappears: an issued slot holds at
   most 10 MiB, and an organization holds at most 20 slots (AS1).
2. **Completion** (AS2): `POST /assets/uploads/{uploadId}/completion`. The API
   **does not call S3**: `assets.complete_upload(...)` (SECURITY DEFINER)
   moves the upload to `received`, the asset to `processing`, and enqueues
   `process_upload` in the same transaction. This keeps every external call
   out of the request (D26) and out of the 5 s statement budget.
3. **Verification** (AS7, media worker): `HEAD` (size, existence) → `GET`
   with a byte cap of `declared + 1` → magic-byte sniff → sharp `metadata()`
   → pixel limit of the detected format (AVIF 12 MP, JPEG, PNG and WebP
   40 MP, OD5) → decode once under that same `limitInputPixels` into the master (auto-orient,
   sRGB, strip metadata, ≤ 4,096 px) → derive every rendition **from the
   master**, never re-decoding the input → write the master to S3 `m/` and
   the public renditions to R2 → one database transaction records SHA-256,
   sizes, dimensions, storage objects, `ready`, and adjusts usage to verified
   bytes. **Staging cleanup on every terminal outcome:** the `in/` object is
   deleted after `ready`, after a permanent failure and at expiry (AS9), but not by the worker for a dead-lettered job, which can be re-queued until the `in/` lifecycle removes it after one day (AS8); a
   second POST after processing (the policy may still be live) leaves an
   orphan the 1-day lifecycle deletes.
4. **Idempotency and flaky networks:** the intent replays with the catalog
   pattern (`resolveIdempotentClaim`, request hash stored next to the key,
   "Idempotent creates as built" in `CATALOG_USE_CASES.md`): same key and
   body → `201` with the asset as it is now and a **fresh** POST policy if
   the upload is still `issued` (the policy is not stored); same key, other
   body → `409 · idempotency.key_reused`. A client whose POST fails
   mid-transfer simply repeats the intent with the same key and posts again
   to the same key `in/<token>` (S3 replaces the object, S26).
   Completion is idempotent by state: a repeat answers `202` with the
   current state and writes nothing.
5. **Single request, not multipart (*Decided*).** At ≤ 10 MiB one POST is
   enough; S3 multipart upload exists for large objects and would add
   initiate/part/complete calls and per-part presigning for no gain.
6. **Abandoned uploads** (AS9): an upload still `issued` 15 minutes after
   creation (policy lifetime + margin) is expired by the media worker's
   sweep: upload `expired`, asset `failed` (`assets.upload_expired`),
   reservation released, staging object deleted if present. Safety net: the
   `in/` lifecycle rule (1 day).

**What the clients do (web and mobile, *Decided* contract, client details
*Recommended*).** Restaurants and retailers upload their own photos from the
camera or the gallery.

| Concern | Decision |
|---|---|
| HEIC from iPhones | **The client converts; the server refuses HEIC.** sharp's prebuilt binaries exclude HEVC because it is patent-encumbered, and HEIC needs a custom libvips built with libheif and libde265 (S40, S8); owning that build and its licensing exposure is not worth it when the client can convert for free. The native app sets Expo ImagePicker's `preferredAssetRepresentationMode` to `compatible` (S38) and re-encodes with `expo-image-manipulator` to JPEG (S39); the web app relies on the browser's file input (`accept="image/jpeg,image/png,image/webp"`; iOS Safari's transcoding of HEIC to JPEG is *unverified*, A2b checks it on a device) |
| Size on the device | The client resizes to at most 4,096 px on the long side and encodes JPEG at quality ≈ 0.85 before requesting the intent (a 12 MP photo then weighs ≈ 1–3 MB, *assumption*), so `byteSize` is the real size and slow networks send less. Native: `expo-image-manipulator` (S39). Web: draw into a canvas from `createImageBitmap` and export with `canvas.toBlob('image/jpeg', 0.85)`, which also re-encodes (and drops EXIF); iOS Safari's HEIC-to-JPEG transcoding of the file input stays *unverified* (A2b) |
| Progress | The client measures progress itself from the POST's upload events (`XMLHttpRequest.upload.onprogress` on the web, the upload task's progress in the native app); the API exposes only states: `pending` (waiting for bytes), `processing`, `ready`, `failed` with `failureCode`, through AS3 polling every 1 s for at most 30 s |
| Retries | Same `Idempotency-Key` for the intent; the POST is retried as a whole; completion retried freely (state-idempotent) |
| Quotas | Per organization: assets, megabytes (OD6) and 20 open uploads; the UI shows the `422` refusals by `code` (D25) |
| Bulk | A retailer with 5,000 products uses the import flow (later, §1.3): N intents in parallel up to the 20-slot cap, then AC5 to attach in bulk; the API needs no bulk upload route for that |

### 6.3 Renditions and processing (*Decided*; formats OD3, SVG OD4)

| Topic | Decision | Facts |
|---|---|---|
| Process | A separate Railway service `media-worker` (OD14): same image, entrypoint `node dist/media-worker`, role `ninaku_worker`, its own memory budget | The existing worker carries OTP and invitation delivery (`worker.module.ts:8-18`); an image out-of-memory crash must never delay a sign-up code |
| Library | **sharp** (libvips) in the media worker only | Prebuilt binaries for Linux x64 glibc ≥ 2.28 and musl, Node ≥ 20.9 (S8); input JPEG, PNG, WebP, AVIF, GIF, TIFF, SVG (S7). Alternatives: Jimp (pure JS, several times slower and memory-heavy, *assumption*), ImageMagick (shell-out, large attack surface), Cloudflare Images (paid per image, moves sanitization outside our control) |
| Accepted inputs | `image/jpeg`, `image/png`, `image/webp`, `image/avif` (OD3); declared type, sniffed signature and sharp's detected format must all agree | OWASP: "Content-Type … cannot be trusted" (S9) |
| Rejected | SVG (OD4), GIF, TIFF, HEIC/HEIF (prebuilt sharp binaries do not include HEIC, S8: converting is a client requirement, see the client table at the end of §6.2), anything else → `assets.media_type_unsupported` | |
| Pixel limit | Per detected format (OD5): JPEG, PNG and WebP `40_000_000`, AVIF `12_000_000`, because decoding an AVIF costs far more memory per pixel (measured locally on macOS arm64, one image alone: a 12 MP AVIF peaks at 572 MB RSS and a 40 MP AVIF at 1,173 MB, against about 372 MB for a 40 MP JPEG or PNG; WebP was not in the measured matrix: `perf:assets` has covered a 12 MP and a 40 MP WebP since the job deadline hardening, still to be measured). Raised (a 48 MP phone photo is 8,000 × 6,000 = 48 M) only with new memory evidence. sharp's default would be 268,402,689 (S7). Read dimensions with `metadata()` before decoding; refuse more pixels than the format's limit or any side `> 12,000` → `assets.image_too_large`; refuse any side `< 64` → `assets.image_too_small`; the decode itself runs with `limitInputPixels` set to the limit of the format the decoder detects, never of the declared type | Decompression bombs (S9) |
| Pipeline order | Decode the input **once** into the master (fit inside 4,096 px: at most 4,096 × 4,096 × 4 bytes ≈ 67 MB RGBA), encode it, then derive every rendition from the in-memory master | Never re-decode a 40 MP input per rendition; memory peaks at one input decode plus one master |
| Strictness | `failOn: 'warning'` (sharp default, S7); `unlimited: false` | Truncated or malformed files fail permanently |
| Orientation | `autoOrient: true` (default `false`, S7) before stripping EXIF | Otherwise phone photos render sideways once EXIF is gone |
| Metadata | Default output removes all metadata including EXIF orientation; output converted to sRGB (S6). Never call `keepMetadata`/`withMetadata` | Removes GPS, device and author PII |
| Animated | `animated: false`: first frame only | |
| Memory/CPU | One job at a time per media-worker process, `sharp.concurrency(1)` (default is 1 on glibc without jemalloc, S15), `sharp.cache(false)` | Scale by replicas; `SKIP LOCKED` makes that safe. **A3c gate:** peak RSS for a 40 MP JPEG and PNG and a 12 MP AVIF is measured and recorded in `PERFORMANCE_BASELINE.md`, and the service memory limit is set from it before A3c merges. Local measurement (macOS arm64) recommends 1.5 GB for `media-worker`, to be re-measured on Railway's Linux after A0 |
| Transactions and lease | `assets.claim_processing_job(owner, token, 120)` claims **one** job per call (tx1), processes it with **no open transaction**, records it (tx2). The lease (120 s) must exceed the measured worst per-job time with margin; A3c sets it from the measurement | Idle-in-transaction timeout 10 s (`environment-variables.schema.ts:189`); claiming a batch processed sequentially would let the last job outlive its lease and be processed twice |
| Failure classes | Permanent (no retry): unsupported, mismatch, too large/small, corrupt, size differs from declared. Transient (retry): storage 5xx, timeouts, throttling | Same taxonomy as `MESSAGE_DELIVERY_FAILURE` (`OUTBOX_USE_CASES.md`) |
| Retry | Two classes (*Decided*). **Storage errors** (S3/R2 5xx, timeouts, DNS failures, throttling): re-queued with capped exponential backoff (5 s doubling to a 15-minute cap) and **no attempt cap**, so an outage of any length never fails an asset; a metric and an alert fire after 1 hour of continuous storage errors. **Processing errors** (an unexpected decoder or encoder error that is not one of the permanent refusals): the first attempt plus 4 retries after 5 s, 30 s, 2 min and 10 min (≈ 12.6 minutes of backoff in total), indexed by `processing_error_count`; the fifth processing error dead-letters the job and the asset is `failed` (`assets.processing_exhausted`). No processing retry waits longer than 15 minutes: `record_processing_outcome` refuses an `available_at` further ahead with `23514 assets_retry_backoff` (`0088`, `0093`), so OB4's 30-minute step is never scheduled here. **The two classes have separate budgets** (R1, `0093`): the five processing errors are counted on their own (`processing_error_count`, consecutive or not); a storage retry adds only to the total `attempt_count` and never counts towards nor resets the processing budget, so an outage never shortens the processing budget of a valid image | OB4 (`OUTBOX_USE_CASES.md:25`) for the second class |
| Idempotent processing | Object keys are deterministic from `delivery_token` and code, so a re-run after a crash overwrites the same keys before anything is `ready`; once `ready`, keys are never rewritten (`0070:657-672`) | |
| Regeneration | A new profile (`p2-*`) is a `render_profile` job reading the master; the asset's `rendition_profile` moves to 2 only when every `p2-*` variant is ready; profile 1 objects stay until a later cleanup job | Immutable URLs stay valid for cached clients |
| Documents | Out of scope (§1.3) | |

### 6.4 Delivery and access (*Recommended* default, OD2)

**Rule:** domains store only `asset_id`; URLs are computed at read time by
`AssetDeliveryContract` from `delivery_token`, `rendition_profile`,
`access_class` and the configured base URL. Nothing outside Assets ever
persists a URL (`0070:8-10`).

| Access class | Delivery | Cache headers on the object | In API responses |
|---|---|---|---|
| `public` (product images, default for catalog) | `https://<ASSETS_PUBLIC_BASE_URL>/a/<token>/p<profile>/<rendition>.webp` (variant `p1-tile` → `/a/<token>/p1/tile.webp`, §5.2) from the R2 public bucket's custom domain, Cloudflare cache in front (R2-PB); S3 is never on this path (option D, §6.1) | `Cache-Control: public, max-age=31536000, immutable` (RFC 8246, S19) and `Content-Type: image/webp`, set at upload by the worker | A plain URL with **no expiry**: deterministic from database state, so it can live inside a strong ETag body (C17) |
| `private` | Short-lived presigned `GET` on the S3 bucket's `p/` prefix (A7), 5 minutes; signed by an API principal that A7 adds | `private, max-age=300` | Never inside an ETagged or cached body; only from AS14, `Cache-Control: no-store` |
| `sensitive` | As private, 60 s TTL, every issue audited, never prefetched | `no-store` | AS14 only, with a fresh step-up for staff contexts later |

**Why not signed URLs for product images:** a signed URL changes with time,
so a till read containing one could never answer `304` from a version-based
strong ETag; the till would re-download the whole sellable list to refresh
URLs. Product photos are marketing content shown to customers, so `public`
with unguessable tokens is the right exposure. An alternative, Cloudflare
WAF token authentication on the custom domain (HMAC + timestamp in a query
parameter), requires a Pro plan or above (S17) and still expires.

**ETag safety (*Decided*):** a public URL depends on `ASSETS_PUBLIC_BASE_URL`,
which can change (domain migration) without any database write. Every
ETagged read that embeds URLs adds a delivery fingerprint (first 8 hex of
SHA-256 of the base URL and the profile list) to its tag, e.g. C17
`"c<catalogVersion>-s<structureVersion>-<businessUnitId>-d<fingerprint>"`.

**AI disclosure in delivery (*Recommended*, OD19):** `describe` returns
`aiGenerated: true` for assets whose `source_kind` is `ai_generated`, and the
till read carries it next to `imageRef`, so every customer-facing surface can
show the label of OD19 without another call.

**Hotlinking and abuse:** tokens are unguessable (128 bits), but a public URL
is shareable by design. Cloudflare's cache absorbs repeated reads and R2
egress is free (S1); a WAF rate rule per IP on the media host is an
operations setting (A0). Hotlink protection by `Referer` is not recommended:
native apps send no referrer.

**Media host headers (A0):** a Cloudflare transform rule on the media domain
adds `X-Content-Type-Options: nosniff` and a restrictive
`Content-Security-Policy: default-src 'none'; img-src 'self'; sandbox`, so
even a mislabelled object can never run as a page.

**Takedown and the CDN (*Decided*, OD2 — changed):** `immutable, max-age=31536000`
means a deleted object can stay in Cloudflare's cache and in browsers. The
purge job (AS10) therefore calls Cloudflare's purge-by-URL API (S22: up to 100 URLs per call, 800 URLs/s on the Free plan; an asset has at most four public URLs (the four profile 1 renditions), one call per asset) for
every public rendition URL of the asset **automatically**, with a token
scoped to cache purge on the media zone only (A0), before the database rows
go. Browser and device caches cannot be recalled; that residual is accepted
for public product photos and is the reason private files never use public
URLs.

**CORS:** the S3 bucket allows `POST` from the app origins (web app;
native apps need no CORS), with no credentials; the R2 public bucket allows `GET` from `*` so a web till's service
worker can cache images with `fetch` (*Decided*: public content, no
credentials). Both are bucket settings in A0, separate from the API's
`CORS_ALLOWED_ORIGINS` (`railway.ts:24-41`).

**What the API returns for an item image (*Decided*):**
never a bucket, key or provider. Two shapes:

* **Back office (C3, AS3):** full URLs per rendition, `{ assetId, status,
  thumbnailUrl, cardUrl, detailUrl }`; bodies are small (≤ 10 images per
  item).
* **Till (C17, C19):** per item and per variant only `imageRef: string |
  null` (`<delivery token>/p<profile>`, 29 characters), plus **one**
  `meta.media = { tileUrlTemplate: "https://<media domain>/a/{imageRef}/tile.webp" }`.
  The client builds the URL by replacing `{imageRef}`; the template comes
  from the server, so the client never hardcodes the key layout. Reason:
  "K7 as built" in `CATALOG_USE_CASES.md` found that the cost missing the
  budget on large tills is building and sending the JSON of a large body,
  and a full URL repeats ≈ 60 bytes of host and path on each of up to 2,000
  items (≈ 120 KB before compression). The asset id is not sent: the till
  never needs it.

**Offline till (*Decided*):** `BUSINESS_DESIGN.md` §4 ("Solo se sincroniza el
subconjunto autorizado por dispositivo/scope", `:89`). The till already
loads only what its outlet sells (C17). It prefetches each tile URL into
its local store keyed by URL; since an image change is a new asset (new
token, new URL) and old URLs never change content, the device cache is
correct by construction, and a `304` from C17 means no image changed. Budget:
2,000 items × ~25 KB ≈ 50 MB worst case per device (*assumption* on size).

**Default access class for product images:** `public` (OD2).

### 6.5 Authorization and plan (*Recommended*, OD6, OD11)

| Topic | Decision |
|---|---|
| Permissions | `assets.media.manage` (request uploads, complete, rename, archive, restore) and `assets.media.view` (library reads); format `module.resource.action` (`permissions_code_chk`, as catalog) |
| Anchor | Organization-wide only: assets have no business-unit column (`0070:84-133`), and catalog master data is edited organization-wide (catalog OD3) |
| Roles (D30 pattern) | `organization_owner` and `manager`: both permissions (effective only with an organization-wide assignment, as `catalog.items.manage`); every other seeded role (`cashier`, `waiter`, `kitchen`, `bar`, `sales_associate`, `stock_keeper`): none. Least privilege: none of them manages images, and the item master (C3, which `sales_associate` and `stock_keeper` read with `catalog.items.view`) and the till (C17, `catalog.sellables.view`) already embed the image references they need |
| Capability | `assets.media` ("Images and files") gates **AS1 (new uploads) only**; `assets.ai_generation` gates AS15 (through AC6) with the monthly limit `assets.ai_generations` (OD18). Never gated: completion of an intent already issued, reads, rename, archive, restore, attach/detach in Catalog (attach is gated by `catalog.items`) |
| Limits | `assets.active_assets` (integer, unit `assets`), `assets.stored_megabytes` (integer, unit `megabytes`, MiB). An integer limit takes 0 to 2,147,483,647 (`0060_platform.sql:1738-1750`, migration `0074`), so bytes cannot be an integer limit (2 GiB maximum); MiB can (2 PiB). `decimal` was rejected: quotas count whole things. Pionero defaults (OD6): 5,000 assets and 10,240 MiB |
| Max file size | A domain constant, not a plan limit: 10 MiB (OD5). One rule for everyone keeps the worker's memory bound fixed |
| Lapsed plan | Reads, archive, restore, rename, detach: never gated by the capability (restore still respects the asset limit). Organizations subscribed to an older Pionero version do not gain `assets.media` until staff move them, as with the catalog rollout (`SUBSCRIPTION_PLANS_USE_CASES.md:719-733`). New uploads: refused with `403 · platform.capability_not_entitled` (capability lost) or `422 · assets.storage_limit_reached` / `assets.asset_limit_reached` (over quota after a downgrade). Nothing is deleted because a plan lapsed |
| Rate limits | `@Throttle` on AS1: 30 requests per minute per client IP; database cap: at most 20 uploads in `issued`/`received` per organization (`422 · assets.too_many_open_uploads`), checked under the usage lock |
| Tenant isolation | RLS by `organization_id` on every tenant table (existing policies `0070:962-1048`); keys and URLs never contain organization or asset ids (§5.2) |
| Deduplication | None across organizations (*Decided*: a cross-tenant hit would reveal that another business holds the same file). Per organization: not in v1 (SG10) |

### 6.6 The Catalog link and the published contracts (*Decided*)

**`catalog.item_assets` semantics:** item-wide rows (`item_variant_id IS
NULL`) and variant rows; `media_role` `primary` (at most one per item and per
variant, `0080:702-716`) or `gallery`; `sort_order ≥ 0`. *Decided:* at most 10
images per item set and 10 per variant set; the list is replaced as a whole
(`PUT`, like C8 and C13), position in the list is `sort_order`, and the first
entry is the `primary`.

**Contracts (D18 modes):**

```ts
// src/modules/assets/contracts/asset-usage.contract.ts (synchronous, shares the caller's tenant transaction)
interface AssetUsageContract {
  assertAttachable(ctx: TransactionContext, input: { assetIds: readonly string[]; requirement: { mediaKind: 'image'; accessClass: 'public' } }):
    Promise<readonly AttachableAsset[]>; // locks FOR SHARE in id order; throws AssetNotAttachableError { assetId, reason }
}
// reasons: 'not_found' (unknown or another tenant: RLS makes them identical), 'not_ready', 'archived', 'purging', 'wrong_media_kind', 'wrong_access_class'

// src/modules/assets/contracts/asset-delivery.contract.ts (read contract)
interface AssetDeliveryContract {
  describe(ctx: TransactionContext, input: { assetIds: readonly string[]; renditions: readonly RenditionName[] }):
    Promise<AssetDeliveryDescription>; // one set-based statement on assets.assets; ≤ 2,000 ids; public assets only get URLs
  fingerprint(): string; // the delivery fingerprint of §6.4, for consumers' ETags
}
```

Consumers ask only "is this id mine and usable" and "how do I show these
ids". They never see keys, buckets, providers, storage objects or variant
ids.

**Lock order across the two modules (*Decided*):** a Catalog image command
locks `catalog.catalog_versions` first (the catalog rule, `CATALOG_USE_CASES.md`
§5), then the item `FOR UPDATE`, then calls `assertAttachable`, which locks
the asset rows `FOR SHARE` in id order. Assets commands lock
`organization_usage`, then the asset `FOR UPDATE`, and never touch catalog
rows. No cycle exists, so no deadlock. `FOR SHARE` makes "archive" (which
takes the asset `FOR UPDATE`) and "attach" serialize: an asset archived
concurrently is never attached.

**Archived but attached:** archiving an asset hides it from the library and
makes it unattachable, but **does not** change existing references or
delivery: its objects stay until AS10 proves nothing references it, and AS10
never starts on a referenced asset. So no catalog ETag can go stale because
of an Assets command.

**Catalog ETags:** C3's item ETag is a hash of the representation ("K2 as
built"), so adding `images[]` changes it automatically. C17's tag is
version-based; SG11 adds the bump triggers on `item_assets` and §6.4 adds the
delivery fingerprint.

**Image writes and the till `304` (*Recommended*, OD16).** Every image
command is a catalog command: it takes `catalog_versions FOR UPDATE` and
bumps the organization's version, so every till of the organization re-reads
its whole sellable list once afterwards. An owner who sets 300 photos one
item at a time causes 300 full till re-reads and 300 serialized version
locks. The K8 measurement found that catalog commands keep succeeding next
to admissions as long as a sale holds its transaction 50 ms or less
(`CATALOG_CASE_MATRIX.md` preamble, `PERFORMANCE_BASELINE.md` §15); an
image `PUT` is an ordinary short catalog command, so it adds no new
starvation risk. The cost is accepted, and the **recommended path for the
product-creation wizard and imports is the bulk command AC5**: images for up
to 100 items in one request, one transaction, a constant number of version
bumps (one per statement, independent of N).

**`CatalogSellableContract` snapshot (*Decided*):** it does **not** carry an
image id in v1. Sales and Pricing never show images; the till gets images
from C17. Adding `primaryImageAssetId` later is additive.

**Audit actions to seed (Catalog migration, A5b):** `catalog.item.images_set`
and `catalog.item.variant_images_set`, the latter also written once per item
by AC5 (resource kind `catalog.item`,
category `configuration`, retention class `administration.platform`, as
catalog R16).

### 6.7 AI-generated images with OpenAI GPT Image (*Recommended*, OD9, OD17–OD19 — changed)

The owner will use OpenAI's GPT Image models. This is now a fully designed
future slice set (A8a–A8d); nothing is built before the owner answers OD17
and OD15.

**Provider facts (fetched 2026-10-06).**

| Fact | Value | Source |
|---|---|---|
| Models offered for images | `gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, `gpt-image-2` (snapshot `gpt-image-2-2026-04-21`), `gpt-image-1.5` (`gpt-image-1.5-2025-12-16`), `gpt-image-1-mini`, `gpt-image-1`, `chatgpt-image-latest` | S31, S34, S33 |
| Endpoints | `v1/images/generations`, `v1/images/edits` (image input, inpainting with a mask), `v1/batch`; also an image tool in the Responses API | S30, S34 |
| Sizes | 1024×1024, 1536×1024, 1024×1536 recommended; custom sizes in multiples of 16, ratio 1:3 to 3:1, edge ≤ 3,840 px (newest models) | S30 |
| Quality | `low`, `medium`, `high` (and `xhigh`, `max`, `auto` on the 2.5 models) | S30 |
| Output | base64 image data; `png` (default), `jpeg`, `webp`; transparent background with `png`/`webp` | S30 |
| Moderation parameter | `auto` (default, standard filtering) or `low` | S30 |
| Organization verification | "may need to complete the API Organization Verification" before using GPT Image models | S30 |
| Latency | "Complex prompts may take up to 2 minutes" | S30 |
| Price per image (1024×1024 / 1024×1536) | `gpt-image-1-mini`: low $0.005 / $0.006, medium $0.011 / $0.015, high $0.036 / $0.052 (S32). `gpt-image-1.5`: low $0.009 / $0.013, medium $0.034 / $0.05, high $0.133 / $0.20 (S33). `gpt-image-2` and 2.5: token-priced (e.g. `gpt-image-2` image output $30 per 1M tokens), per-image cost from OpenAI's calculator (S31) | S31–S33 |
| Rate limits | `gpt-image-1-mini` and `gpt-image-2`: 20 images/minute at the Build tier, 150 at Launch, 250 at Grow (S32, S34) | S32, S34 |
| Data | API data not used for training unless opted in; abuse-monitoring logs up to 30 days; image endpoints eligible for Zero Data Retention with some models; suspected CSAM retained for review even under ZDR | S35 |
| Provenance | API images include C2PA metadata and a SynthID watermark (*not Decided*: from secondary sources and the search snippet of OpenAI's help page; the primary page was not fetched) | S36 |
| Spend control | Monthly organization and project spend limits, as alerts or enforced hard limits that make requests fail with `429` | S37 |
| Usage policies | https://openai.com/policies/usage-policies/ (the page answered `403` to the fetch tool; its text is **not** quoted here; OD19's disclosure is Ninaku's own consumer-protection choice, not a claimed OpenAI requirement) | S41 |

**Model choice (*Recommended*, OD17).** The model is **configuration**
(`OPENAI_IMAGE_MODEL`, `OPENAI_IMAGE_QUALITY`, size), never code. OpenAI's
guide now leads with `gpt-image-2.5` (`sunburst`, `flare`) and `gpt-image-2`
(S30), and describes `gpt-image-1.5` as its previous image generation model,
so 1.5 is **not** presented as current. A8 starts with `gpt-image-1.5`,
`medium`, 1024×1024, `output_format: png`, `moderation: auto`, because its
per-image price is published: $0.034 (low $0.009, high $0.133; 20/150/250
images per minute by tier) (S33). A8a compares it on real menu items against
the 2.5 models (token-priced, S31; all three families listed as eligible for
Zero Data Retention, S35) and switches the configuration if quality per
dollar is better. *Assumption:* 1.5 medium is realistic enough for food and
products.

**Two intents.**

| Intent | Endpoint | Input |
|---|---|---|
| **Generate** a photo for a product that has none | `images/generations` | A prompt built by the server |
| **Enhance** a photo the user uploaded (clean background, better light) | `images/edits` | The asset's master (read from S3 `m/` by the worker) plus the server-built prompt; no mask in v1 |

**Flow.**

```text
user (product form) ── POST /catalog/items/{itemId}/image-generations {stylePreset, instruction?, sourceAssetId?}
  Catalog (AC6, tenant tx): permission, item read (name, description, primary category)
     └─ AssetGenerationContract.request(ctx, {subject, description, category, stylePreset, instruction, sourceAssetId, idempotencyKey})
          Assets (definer function): capability, monthly quota reserve, generation_request(queued), job generate_image  ── 202
media-worker: claim one generate_image job (tx1) ─ no transaction ─ OpenAI call (timeout 180 s)
     ├─ success: bytes → SAME pipeline as an upload (sniff, decode, master, renditions); provider original kept in S3 m/ (C2PA kept)
     │           asset(ready, source_kind ai_generated), request(review), quota consumed (tx2)
     └─ failure: request(failed | refused), quota released, no asset
user ── GET /assets/generations/{id} (poll) ── preview (thumbnail, card)
user ── POST /assets/generations/{id}/approval ── request(approved) ── now attachable (AC1/AC5)
     or /rejection ── request(rejected), asset archived (purged later, AS10); regenerate = a new request (new quota unit)
```

| Rule | Decision |
|---|---|
| Prompt | Built **server-side** from the caller organization's own catalog data (RLS) and a fixed template per `stylePreset` (`studio_white`, `rustic_table`, `bright_menu`, `transparent_cutout`; the list is configuration): "Professional product photograph of {name}. {description ≤ 300 chars}. Category: {category}. Style: {preset text}. {instruction ≤ 200 chars}". The user's `instruction` is optional, ≤ 200 characters, NUL and control characters rejected, URLs removed. No other tenant's data can enter a prompt. **Decided risk (note 18):** a product name or description written by the same organization may carry a prompt injection; it is accepted because the data is the caller's own, the call has no tools and no data access, and the outcome is bounded by moderation and the mandatory human approval |
| Idempotency | `Idempotency-Key` on AC6 with the request hash (the AS1 pattern); a replay answers the same generation request |
| Asset quotas (*Recommended*, OD20) | A generated asset is a **normal asset**: it counts toward `assets.active_assets` and `assets.stored_megabytes` like an upload. Checked twice: at request time (AC6 reserves one asset slot and an estimated 3 MiB in `organization_usage`, refusing with `assets.asset_limit_reached` / `assets.storage_limit_reached` before any OpenAI cost) and again in `create_generated_asset` (verified bytes replace the estimate; if the limit was reached meanwhile, the image is still stored and counted, because OpenAI was already paid; the quota is soft by that one asset) |
| Generation quota | Plan limit `assets.ai_generations`, `integer`, `reset_period = 'month'` (`0060_platform.sql:783-792`), reserved under the usage row lock at request time, consumed when an image is produced, **released** when the call fails or is refused (the organization never pays for a failure). A provider timeout after the provider may have billed is consumed (*Decided*: at-least-once; OpenAI's cost is ours, the tenant is charged one unit) |
| Transactions and lease | No database transaction is open during the OpenAI call; one job per claim; the lease covers **one attempt**: lease 300 s > request timeout 180 s > OpenAI's 2-minute worst case (S30) |
| Retries | **Re-queues, never one long lease:** a 429 or 5xx records the attempt and sets `available_at` (honoring `Retry-After`), releasing the lease; at most 3 provider attempts, counted by the handler from the claimed `attempt_count` (storage errors while saving are uncapped, like every job; `ASSETS_STAGE_SPECS.md` §25); 400 refused by moderation: permanent, `refused` with the provider's reason code, quota released |
| Concurrency | The media worker runs **two independent claim loops**: the image loop (`process_upload`, `render_profile`, `purge_asset`) claims one job at a time so sharp work stays serialized (§6.3); the generation loop (`generate_image`) runs up to 2 jobs concurrently (Build tier 20 images/minute, S32) and hands the returned bytes to the image loop's pipeline through the same serialized decoder |
| Storage and asset creation | `assets.create_generated_asset(job, lease, …)` (SECURITY DEFINER, `ninaku_worker` only, SG19) creates, in one transaction, the `ai_generated` asset (`processing`: there is no upload step, so it never passes through `pending`; new `delivery_token`), its `original` variant and the private `provider-original` variant with its S3 `m/` storage object; the bytes then go through the AS7 pipeline exactly like an upload. The provider original is kept so a C2PA manifest, if present (S36, not verified), survives for audits; served renditions are re-encoded and lose it |
| Provenance | `generation_requests` holds model, quality, size, provider request id (`x-request-id`), prompt SHA-256, prompt text (tenant data, retained 90 days then nulled), style preset, cost units, moderation outcome, requester, timestamps |
| Approval | `assertAttachable` refuses an `ai_generated` asset whose request is not `approved` (`not_approved`); approval and rejection need `assets.media.manage`; audited `assets.generation.requested`, `.approved`, `.rejected` |
| Disclosure | `source_kind = 'ai_generated'` is permanent; delivery carries `aiGenerated: true`; customer-facing surfaces show a label (OD19); counsel confirms the Colombian and Ecuadorian consumer-protection exposure as part of OD15 (note 19) |
| Secrets and logs | `OPENAI_API_KEY` and `OPENAI_PROJECT_ID` only in `media-worker`; never logged; prompt text and response bodies (base64) never logged; logs carry the request id, model, latency and outcome only |
| Cost ceiling | An OpenAI **project** per environment with an enforced hard monthly spend limit and an alert at 50% (S37); per-organization monthly limits (OD18) |
| Data | ZDR requested for the production project if eligible for the chosen model (S35); OD15 covers sending tenant product data to OpenAI |

### 6.7.1 Schema gaps for AI (A8a)

| # | Change |
|---|---|
| **SG19** | Function `assets.create_generated_asset(job, lease_token, …)` (SECURITY DEFINER, `ninaku_worker` only): creates the `ai_generated` asset, its `original` and `provider-original` variants and storage objects, links the request, and re-checks the asset quotas (OD20). Table `assets.generation_requests`: `id`, `organization_id`, `asset_id` (nullable until produced, FK tenant-safe), `source_asset_id` (nullable, enhance), `requested_by_membership_id` (FK `(id, organization_id)`), `intent` (`generate`, `enhance`), `style_preset varchar(40)`, `instruction varchar(200)`, `prompt_text varchar(2000)` (nullable after retention), `prompt_sha256 text CHECK (~ '^[0-9a-f]{64}$')`, `model_code varchar(80)`, `quality varchar(20)`, `size varchar(20)`, `provider_request_id varchar(120)`, `state` (`queued`, `generating`, `review`, `approved`, `rejected`, `failed`, `refused`), `failure_code varchar(80)`, `moderation_outcome varchar(40)`, `cost_units integer`, `creation_idempotency_key uuid`, `creation_request_hash text` (`^[0-9a-f]{64}$`), timestamps; unique `(organization_id, creation_idempotency_key)`; RLS; state-shape check; runtime reads only, writes through definer functions |
| **SG20** | `assets.ai_generation_usage (organization_id, period_start date, reserved integer, consumed integer)`, primary key `(organization_id, period_start)`, non-negative checks; reserve/consume/release in definer functions under the row lock |
| **SG21** | SG5's `source_kind` values become `('upload','ai_generated')`; job kind `generate_image` added to SG7 |

### 6.8 Retention and erasure (*Recommended*, OD8)

* **Archive** is soft (`archived_at`); **restore** within 30 days.
* **Purge** (AS10, slice A4b) of an asset archived more than 30 days, or
  `failed` more than 7 days, when nothing references it, in three steps:
  1. `purge_asset_begin` locks the asset `FOR UPDATE` and tests the
     reference **with the `DELETE` itself**: inside a PL/pgSQL block it
     deletes the asset's storage objects, variants, upload and asset row; a
     `23503` (some consumer's FK still points at it) is caught and answers
     `referenced`; success raises a private exception that rolls the block
     back and answers `deletable`. On `deletable` it sets `status =
     'purging'` (SG16) in the same transaction. Storage rows are untouched.
     Because `assertAttachable` takes `FOR SHARE` on the same row and
     refuses `purging`, no reference can appear between the test and the
     status change, nor afterwards (the invariant of §2.4).
  2. The media worker deletes the objects from S3 and R2 (missing counts as done)
     and purges every public rendition URL from Cloudflare's cache (S22).
  3. `purge_asset_finish` deletes storage objects, variants, upload, jobs and
     the asset in **one** transaction, which satisfies the deferred triggers
     (`0070:625-637`, `:937-949`), then calls `assets.reconcile_usage`. A
     `23503` here can only come from a consumer that bypassed the contract:
     the function catches it, keeps the asset `purging`, records
     `assets_asset_referenced_after_purge` on the job and raises an alert
     metric; the objects are already gone, which is why the invariant is
     tested in every consumer slice.
* Each referencing table that holds data needs an index leading with
  `asset_id` so the `DELETE`'s FK check is an index probe (SG11 for
  `item_assets`; each future consumer adds its own).
* **Data-subject erasure (OD23).** An erasure request under the personal-data
  policy maps to this existing path: archive the subject's assets, then the
  purge (AS10) deletes them from S3 and R2 and purges the CDN; the trigger
  path is defined when the policy exists. Backups and OpenAI's retention
  (abuse-monitoring logs, Zero Data Retention when granted, S35) are covered
  by the policy text, not by this purge.
* **Erasure on request (GDPR-style):** the consumer detaches first, then the
  owner archives, then a staff-triggered purge skips the 30-day wait. The
  CDN copies are purged automatically in step 2; copies already in browsers
  or devices cannot be recalled.

***

## 7. Use cases

**Conventions for every case.** Routes are relative to `/api/v1`; every
request carries a session and `X-Organization-Id` (`TenantContextGuard`).
Bodies are strict Zod schemas (D24). Responses use `{ data }` or
`{ data, meta }`. `detail` is English; clients translate `code` (D25).
Commands that change something write one tenant audit record through
`TenantAuditContract` in the same transaction, only for a real change.

**Lock order (Assets).** Every Assets command that changes a usage counter
(bytes or the active asset count: reservations, outcomes, sweeps, archive and
restore) locks `assets.organization_usage` of its organization `FOR UPDATE`
first, then the asset and its upload `FOR UPDATE`. Rename changes no counter
and locks only the asset. Reads lock nothing.
`AssetUsageContract.assertAttachable` takes `FOR SHARE` on asset rows only.

**Shared rows** (not repeated per case):

| Shared path | Answer |
|---|---|
| No session | `401 · identity.session_required` |
| Organization not selectable | `403 · tenant.context_rejected` |
| Permission missing | `403 · assets.action_forbidden` (*new*, audited as denied, like `catalog.action_forbidden`) |
| Capability not in the plan (AS1, AS15 only; AS15 checks `assets.ai_generation`) | `403 · platform.capability_not_entitled` |
| Malformed id or body | `400 · foundation.validation_failed` |
| Unknown or foreign-tenant asset or upload | `404 · assets.not_found` (*new*; RLS makes both identical) |
| `If-Match` stale | `412 · foundation.precondition_failed` |
| Unmapped constraint | `422 · assets.asset_refused` (*new*, generic backstop) |

### 7.1 Uploads

#### AS1 — Request an upload

**Actor:** `assets.media.manage` organization-wide; capability `assets.media`.
**Route:** `POST /assets/uploads`, `Idempotency-Key` required.
**Request:** `{ mediaType: 'image/jpeg'|'image/png'|'image/webp'|'image/avif',
byteSize: 1..10485760, accessClass: 'public', displayName?,
originalFilename? }`. *Decided:* `private` and `sensitive` are refused
(`422 · assets.access_class_unavailable`) until A7 ships a way to view them;
`accessClass` is still required so the contract does not change when A7
widens it, and it is immutable after creation (SG17).
**Main path (one tenant transaction):** authorize → `assets.reserve_upload(...)`
(SECURITY DEFINER), which locks `organization_usage` → checks
`active_asset_count + 1 ≤ assets.active_assets`,
`stored_bytes + reserved_bytes + byteSize ≤ assets.stored_megabytes × 2^20`,
open uploads `< 20` → claims the `Idempotency-Key` on `assets.uploads` →
inserts asset (`pending`, new `delivery_token`), original variant
(`pending`), upload (`issued`, `expires_at = now() + 15 min`, staging key
`in/<random token>`) → reserves bytes → audit
`assets.asset.upload_requested` → commit → sign the S3 POST policy (10
minutes, exact key and content type, `content-length-range` 1..`byteSize`,
§6.2).
**Response:** `201` `{ asset: {id, status: 'pending', accessClass,
displayName}, upload: { id, url, method: 'POST', fields: { key,
'Content-Type', policy, 'x-amz-algorithm', 'x-amz-credential', 'x-amz-date',
'x-amz-signature' }, maxBytes, expiresAt } }`, `Cache-Control: no-store`.
The client posts `fields` then `file` as `multipart/form-data`. The policy
and signature are never stored, logged or audited.
**Idempotency:** §6.2 item 4.
**Errors:** `422 · assets.media_type_unsupported`, `422 · assets.file_too_large`
(*new*, > 10 MiB), `422 · assets.asset_limit_reached`, `422 ·
assets.storage_limit_reached`, `422 · assets.too_many_open_uploads`, `429`
from the throttler.
**Concurrency:** two intents of one organization serialize on the usage row;
the last one over quota fails, never both pass.
**Audit:** `assets.asset.upload_requested` (resource kind `assets.asset`).
**Scenario rows:** in [§17](#17-planned-case-matrix).

#### AS2 — Complete an upload

**Actor:** `assets.media.manage` organization-wide. Not capability-gated (the
intent was already admitted).
**Route:** `POST /assets/uploads/{uploadId}/completion` (no body).
**Main path:** `assets.complete_upload(upload_id)` (SECURITY DEFINER, the
only way the runtime moves an asset's status): lock the upload and asset
`FOR UPDATE` → if `issued` and not expired: upload `received`, asset and
original variant `processing`, `process_upload` job queued → no audit (the
outcome is a processing fact; the request was audited in AS1).
**Response:** `202` with the AS3 shape. Repeat in `received`/`verified` →
`202`, nothing written.
**Errors:** `422 · assets.upload_expired` (*new*), `422 ·
assets.upload_rejected` (*new*, already rejected).
**Never** calls the storage provider (§6.2).

#### AS3 — Read one asset

**Actor:** `assets.media.view` or `assets.media.manage` organization-wide.
**Route:** `GET /assets/{assetId}`.
**Response:** `{ id, status, accessClass, mediaKind, displayName,
originalFilename, sourceKind, width, height, byteSize, createdAt, readyAt,
archivedAt, failureCode, renditions: { thumbnail, tile, card, detail } }`
where each rendition is `{ url }` for a ready public asset and `null`
otherwise; never the master.
**ETag:** content hash, `private, no-cache` (D24: read often while polling,
changes rarely once ready). **Not capability-gated.**

#### AS4 — List the media library

**Actor:** as AS3. **Route:** `GET /assets?status=&archived=&cursor=&limit=`.
Keyset `(created_at DESC, id DESC)` (SG12), `limit` 50 default, 100 max,
signed cursor. Items: the AS3 shape with only the `thumbnail` URL. No ETag
(cursor page, D24).

#### AS5 — Rename an asset

**Actor:** `assets.media.manage`. **Route:** `PATCH /assets/{assetId}`
`{ displayName }`, optional `If-Match`. Unchanged → `200`, nothing written.
**Audit:** `assets.asset.renamed`. Never gated.

#### AS6 — Archive and restore an asset

**Actor:** `assets.media.manage`. **Routes:**
`POST /assets/{assetId}/{archival,restoration}`. Never gated. Repeat →
`200`, nothing written. Both go through SECURITY DEFINER functions
(`assets.archive_asset`, `assets.restore_asset`, created with A1b-3) because
they change `active_asset_count`; the runtime has no `UPDATE (archived_at)`.
Restoration re-checks the asset limit (`422 · assets.asset_limit_reached`).
Archive locks the usage row and then the asset `FOR UPDATE` (the lock order
above; the asset lock serializes with AC1's `FOR SHARE`), sets `archived_at`;
references and delivery are untouched
(§6.6). Restore within 30 days; after that `422 · assets.restoration_expired`.
Restoration of a `failed` asset, and archival or restoration of a `purging`
asset, is refused (`422 · assets.transition_invalid`); the restore path
locks the asset `FOR UPDATE`, the same row `purge_asset_begin` locks, so a
restore never races a purge.
**Audit:** `assets.asset.archived`, `assets.asset.restored`.

### 7.2 Worker cases

#### AS7 — Process an upload

**Actor:** the media worker (`ninaku_worker`, service `media-worker`), no
HTTP route.
**Main path:** `assets.claim_processing_job(owner, token, 120)` (one job) →
outside any transaction: `HEAD` staging object (missing → transient
until the upload's expiry, then permanent `assets.upload_missing`); size must
equal the declared size (`assets.size_mismatch`); `GET` with a byte cap;
sniff magic bytes (JPEG `FF D8 FF`, PNG `89 50 4E 47 0D 0A 1A 0A`, WebP
`RIFF….WEBP`, AVIF `ftyp` with brand `avif`/`avis`) and compare with the
declared type and sharp's detected format (`assets.content_mismatch`); check
dimensions (§6.3); decode once into the master, derive profile 1 from the
master, SHA-256 of each output; `PUT` the master to S3 `m/` and
each public rendition to the R2 public bucket with `Content-Type` and the immutable
`Cache-Control`; then `assets.record_processing_outcome(job, lease, …)` in
one transaction: storage objects `available`, variants `ready` with their
content facts, asset `ready`, upload `verified`, usage: reserved → stored
(sum of all variant bytes); then delete the staging object (on every
terminal outcome, §6.2; the `in/` lifecycle is the net).
**Lease:** 120 s for exactly one job, above the worst processing time of a
40 MP JPEG, PNG or WebP or a 12 MP AVIF (*assumption*, measured in A3c,
which sets the final value). A
lost lease refuses the record call; the next claimant redoes the job
(deterministic keys).

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

Permanent failure (unsupported, mismatch, too large or small, corrupt) →
variant(s) `failed` with `failure_code`, asset `failed`, upload `rejected`,
reservation released, job `completed`, staging object deleted. Storage
errors → re-queued with capped backoff and no attempt cap (§6.3). Processing
errors → OB4 schedule; the fifth processing error (counted apart from storage retries, which never count towards it nor reset it: R1) → job `dead_lettered`, asset
`failed` (`assets.processing_exhausted`); **the staging object is kept**
(not deleted by the worker) so the job can be re-queued, but only **within
one day of the upload**: the `in/` lifecycle rule deletes every inbox object
after 1 day (§6.1), so an older dead letter stays failed and is purged
after 7 days.

**Dead-letter recovery (*Recommended*, OD21).** v1 has no staff route: an
operator runs the runbook query `SELECT assets.requeue_dead_letters(ARRAY[...]::uuid[])`
with the migrator's credentials after fixing the cause (for example a sharp
upgrade). The SECURITY DEFINER function moves each dead-lettered
`process_upload` job back to `pending` with `attempt_count = 0` and `processing_error_count = 0`, the asset
`failed → processing` (allowed by SG18) and the reservation back on, only
for jobs created in the last day (the inbox lifetime); `ninaku_runtime` and
`ninaku_worker` cannot execute it. A metric
`assets_processing_dead_letters` is exported; a staff route reusing the
OX-O1/OX-O2 shape comes when Platform's staff console needs it.

#### AS9 — Expire abandoned uploads

A sweep, not a job: a scheduled task in the `media-worker` process (every 5
minutes, transaction-scoped advisory lock, bounded batch of 500), copying
the discipline of `SubscriptionLifecycleSweepJob`, which runs in the API
process (`platform.module.ts:57,157`), not in the worker. It calls
`assets.expire_uploads(batch)` (SECURITY DEFINER, `ninaku_worker` only),
which moves `issued` uploads past `expires_at` to `expired`, their assets to
`failed` (`assets.upload_expired`) and releases reservations; the sweep then
deletes each returned staging object. The failed assets are purged later by
AS10 (failed > 7 days). Nightly, the same sweep pages through every
organization's usage row with `assets.reconcile_usage_batch(after, batch)`
(A1c-2).

#### AS10 — Purge an asset (reference-safe, slice A4b)

Job `purge_asset` for assets archived > 30 days or failed > 7 days, in the
three steps of §6.8: `purge_asset_begin` (reference test with the `DELETE`'s
own `23503` caught in the definer function, status `purging`, storage rows
untouched) → the media worker deletes the objects from S3 (`m/`, `p/`) and from R2 (public renditions) and purges every public
rendition URL from Cloudflare's cache (token from A0) → `purge_asset_finish`
deletes storage objects, variants, upload, jobs and asset in one
transaction and reconciles usage. A referenced asset answers `referenced`,
stays as it is, and is re-checked after 30 days.

#### AS11 — Regenerate renditions for a new profile

Job `render_profile` per asset reading the master. No route in v1: a
staff-run backfill when profile 2 is designed. Throughput §12.4.

### 7.3 Contracts

#### AS12 — Describe delivery (`AssetDeliveryContract.describe`)

One statement: `SELECT id, status, access_class, delivery_token,
rendition_profile FROM assets.assets WHERE organization_id = $1 AND id =
ANY($2)` under RLS (primary key lookups). Returns, per id, `{ status,
imageRef, urls }` for `public` and `ready` (`imageRef` = `<token>/p<profile>`
for compact consumers like the till, `urls` for the renditions asked);
`null` otherwise, also for `purging`. Archived assets still resolve (§6.6).
≤ 2,000 ids per call (`CATALOG_USE_CASES.md` C17 bound). Also exposes the
`tileUrlTemplate` and the delivery fingerprint (§6.4).

#### AS13 — Check attachability (`AssetUsageContract.assertAttachable`)

`SELECT … FOR SHARE` in id order; refuses with a reason per id (§6.6).
Throws `AssetNotAttachableError`; the consumer maps it to its own public code
(Catalog: `422 · catalog.image_not_attachable` with `meta.errors[].reason`).

### 7.4 Later cases (designed here, built in A7 and A8a–A8d)

| Case | Route | Slice |
|---|---|---|
| AS14 Signed access to a private rendition | `GET /assets/{assetId}/renditions/{rendition}/access` → `200 { url, expiresAt }`, `Cache-Control: no-store`; `sensitive` audited | A7 |
| AS15 Request an AI image (contract `AssetGenerationContract.request`, reached through AC6; read with `GET /assets/generations/{id}`) | No public create route in Assets: the prompt is built from catalog data by AC6 (§6.7); `202` with the request; refusals `403 · platform.capability_not_entitled`, `422 · assets.ai_generation_limit_reached`, `422 · assets.media_type_unsupported` for an enhance source that is not a ready image | A8b |
| AS15w Generate in the worker (job `generate_image`) | OpenAI call, pipeline, quota consume/release, refusals (§6.7) | A8c |
| AS16 Approve or reject a generated image | `POST /assets/generations/{id}/{approval,rejection}`; approval makes the asset attachable; rejection archives it; audited | A8d |

### 7.5 The Catalog side

#### AC1 — Set an item's images

**Actor:** `catalog.items.manage` organization-wide; capability
`catalog.items`, except a list that only removes images (ungated, like C13).
**Route:** `PUT /catalog/items/{itemId}/images` with `{ images: [{ assetId }] }`
(0–10, distinct, first = primary), optional `If-Match` (C3 ETag).
**Main path:** lock `catalog_versions` → item `FOR UPDATE` → refuse archived
item (`422 · catalog.item_archived`) → `AssetUsageContract.assertAttachable(ids,
{image, public})` for ids not already attached (already-attached ids stay
even if their asset was archived later) → delete rows not in the list,
upsert the rest with `sort_order` = position and `media_role` = `primary` for
the first → bump (SG11 trigger) → audit `catalog.item.images_set` with
before/after id lists.
**Response:** `200` with the C3 shape and its new ETag. Same list → `200`,
nothing written.
**Errors:** `422 · catalog.image_not_attachable` (*new*), `400` for > 10 or
duplicates, `404 · catalog.not_found`.

#### AC2 — Set a variant's images

`PUT /catalog/items/{itemId}/images` with `{ images, variants? }` (the
separate variant route was merged into it in API v7), same rules on rows
with `item_variant_id`; audit `catalog.item.variant_images_set`. The
till shows the variant's primary image when the cashier picks that variant,
else the item's.

#### AC3 — Item read with images (C3 extension)

`images: [{ assetId, isPrimary, sortOrder, status, thumbnailUrl, cardUrl,
detailUrl }]` and `variants[].images` likewise; one extra statement
(`AssetDeliveryContract.describe`) for all ids of the item. The item ETag
(content hash) changes when images change.

#### AC4 — Till read with images (C17, C19 extension)

The C17 read statement left-joins `item_assets` through
`item_assets_one_item_primary_uq` (`0080:702-708`) to carry the primary
asset id; one `describe` call for the distinct ids (≤ 2,000) adds
`imageRef: string | null` per item and per variant (when the variant has
its own primary) and `meta.media.tileUrlTemplate` (§6.4). The ETag gains
`-d<fingerprint>`. C19 adds the same fields per page.

**Statement budgets (corrected).** As built, C17 costs **8** statements for
the `200` and **7** for the `304`, and C19 **8** (session, membership,
`BEGIN`, tenant context, permission, outlet state, the read, `COMMIT`;
"K7 as built" in `CATALOG_USE_CASES.md`, proven by
`test/e2e/statement-budget.e2e-spec.ts`). AC4 adds the `describe` statement
to the `200` paths only: **C17 `200` = 9, C17 `304` = 7 (unchanged: the
`304` answers before any read, so it never reads assets), C19 = 9**. The
slice that ships AC4 (A6) updates `statement-budget.e2e-spec.ts` and
re-baselines the `catalog-till-sellables` rows of `KNOWN_BREACHES`
(`tooling/performance/measurement/known-breaches.ts`): `perf:http` fails on
a regression above 25% against that baseline (`AGENTS.md` §5.5), so A6
measures before and after and records the new baseline with its cause.

#### AC5 — Set images for many items at once (bulk)

**Actor:** as AC1. **Route:** `PUT /catalog/item-images` with `{ items: [{ itemId, images: [{ assetId }] }] }`, 1–100 items, each list as AC1 (0–10,
distinct, first = primary); an item may appear once.
**Main path (one transaction, atomic):** lock `catalog_versions` → items
`FOR UPDATE` in id order → `assertAttachable` once for every new asset id
(≤ 1,000, id order) → one set-based `DELETE` and one set-based upsert for
all items → audit one `catalog.item.images_set` per changed item. The
statement-level bump triggers fire once per statement, so the till version
moves by a constant (two), not by N.
**Response:** `200 { items: [{ itemId, etag }] }`; any refusal (unknown
item, archived item, unattachable asset) refuses the whole request with
`meta.errors[]` naming each item and asset, nothing written.
**Why:** the product-creation wizard and imports set many images at once;
AC1 per item would bump the version and force a full till re-read per item
(§6.6, OD16).

#### AC6 — Request an AI image for an item (Catalog side, A8b)

**Actor:** a member holding `catalog.items.manage` and `assets.media.manage`
organization-wide; capability `assets.ai_generation` and the monthly limit
`assets.ai_generations` (OD18).
**Route:** `POST /catalog/items/{itemId}/image-generations`,
`Idempotency-Key` required, body `{ stylePreset, instruction?,
sourceAssetId? }` (`sourceAssetId` = enhance an uploaded photo; it must pass
`assertAttachable` as an image of the organization).
**Main path (one tenant transaction, no external call):** authorize → read
the item (name, description, primary category; archived → `422 ·
catalog.item_archived`) → `AssetGenerationContract.request(...)`, which
builds the prompt from those fields (§6.7), reserves one quota unit, inserts
the generation request (`queued`) and the `generate_image` job, and audits
`assets.generation.requested` → `202 { generationId, state: 'queued' }`.
The catalog writes nothing (no version bump): the image is attached later
with AC1/AC5 after approval.
**Errors:** `422 · assets.ai_generation_limit_reached` (*new*),
`403 · platform.capability_not_entitled`, `404 · catalog.not_found`,
`400` for an unknown preset or an instruction over 200 characters.
**Statements:** session, membership, `BEGIN`, tenant context,
authorization (2 permissions + capability, 1), item read (1),
`request_generation` (1: quota lock, insert, enqueue), audit (1), `COMMIT` =
**9**.

***

## 8. Derived API

| Group | Routes | Cases |
|---|---|---|
| Uploads | `POST /assets/uploads`, `POST /assets/uploads/{uploadId}/completion` | AS1, AS2 |
| Library | `GET /assets`, `GET /assets/{assetId}`, `PATCH /assets/{assetId}`, `POST /assets/{assetId}/{archival,restoration}` | AS3–AS6 |
| Later | `GET /assets/{assetId}/renditions/{rendition}/access`, `GET /assets/generations/{id}`, `POST /assets/generations/{id}/{approval,rejection}`, `POST /catalog/items/{itemId}/image-generations` | AS14–AS16, AC6 |
| Catalog | `PUT /catalog/items/{itemId}/images` (`{ images, variants? }`: the item list and the lists of its variants), `PUT /catalog/item-images` | AC1, AC2, AC5 |
| Internal | `AssetUsageContract`, `AssetDeliveryContract` | AS12, AS13 |
| Media worker | jobs `process_upload`, `purge_asset`, `render_profile`; sweep `expire_uploads` (not a job) | AS7–AS11 |

Routes carry the `/assets` prefix (task names: `uploads`, `completion`,
`archival`), never table CRUD (`API_AND_SCHEMA_DISCIPLINE.md` §1).

***

## 9. Vertical variations

| Concept | Restaurant | Retail | Hospitality | Where |
|---|---|---|---|---|
| Item photo on the till tile | Yes (visual menu) | Optional (search-and-scan tills often hide images) | Extras | Assets + Catalog, common |
| Variant photos | Rare | Core (colour) | — | AC2, common |
| Gallery | Rare | Storefront later | Room galleries (Hospitality) | Catalog `gallery` role, common |
| Brand logo | Receipts, menu | Receipts, labels | Folios | Organization/commerce later, same contracts |
| Bulk upload | — | Later (import) | — | Its own design |

*Decided:* no vertical-specific table or `if (vertical === …)` in Assets
(`ARCHITECTURE.md` §14, R8). The till client decides whether to show tiles.

***

## 10. Authorization and plan

### 10.1 Permission codes

| Code | Grants | Anchor | Capability gate |
|---|---|---|---|
| `assets.media.manage` | AS1, AS2, AS5, AS6 (later AS16 and, with `catalog.items.manage`, AC6) | Organization-wide | `assets.media` for AS1; `assets.ai_generation` for AC6 |
| `assets.media.view` | AS3, AS4 | Organization-wide | none |

### 10.2 Seeded role mapping (proposal, D30)

| Role | `assets.media.manage` | `assets.media.view` |
|---|---|---|
| `organization_owner` | ✓ | ✓ |
| `manager` | ✓ (effective only organization-wide) | ✓ |
| `cashier`, `waiter`, `kitchen`, `bar`, `sales_associate`, `stock_keeper` | | |

### 10.3 Capability and limits

One capability `assets.media` attached to a new Pionero version (the
migration `0071`/`0075` pattern: copy the current version, add, publish
offerings). The new version number is **the current maximum version of
Pionero + 1, resolved inside the migration** (staff can create plan
versions at runtime, `platform.create_draft_plan_version_as_staff`,
`runtime_grants.sql:91`), never a literal.
Limit definitions `assets.active_assets` (`integer`, unit `assets`) and
`assets.stored_megabytes` (`integer`, unit `megabytes`), values per OD6.
Later: `assets.ai_generation` capability and `assets.ai_generations` limit
(`integer`, `reset_period = 'month'`).

***

## 11. Security

| OWASP topic (S9) | Decision | Where |
|---|---|---|
| Extension/type allow-list | Four image types; extension ignored entirely | AS1, AS7 |
| Content-Type not trusted | Declared type must equal sniffed magic bytes and sharp's detected format | AS7 |
| Signature validation alone is insufficient | Full decode and re-encode (content disarm and reconstruction); raw bytes are never served or kept beyond processing | §6.3 |
| Polyglots | Re-encoding to WebP drops any trailing or embedded payload | §6.3 |
| SVG / XML / XXE | SVG refused (OD4) | AS1 |
| Filename | Server-generated keys; `original_filename` is metadata, sanitized, never in keys, URLs or headers; a future download uses RFC 6266 `filename*` | §5.2 |
| Path traversal in keys | Keys are composed only from base32 tokens and fixed codes | §5.2 |
| Size limits / DoS | 10 MiB declared, enforced by S3's `content-length-range` and verified again by the worker; 40 MP for JPEG, PNG and WebP and 12 MP for AVIF (OD5); open-upload cap; throttle; quotas; media worker isolated from the OTP worker (OD14) | AS1, AS7 |
| Storage location | Separate hosts: S3 (private, Block Public Access) for raw uploads (`in/`) and masters (`m/`); R2 public bucket only for re-encoded renditions | §6.1 |
| Authentication and authorization | Session + tenant + permission before an intent; POST policy scoped to one exact key, one content type and a size range, 10 minutes | AS1 |
| Media host | `X-Content-Type-Options: nosniff` and a restrictive CSP by transform rule; `r2.dev` disabled in production | A0 |
| Takedown | Automatic Cloudflare purge-by-URL in AS10 | AS10 |
| CSRF | Bearer tokens, no cookies (`FOUNDATION_HTTP_AND_OBSERVABILITY.md` §13) | — |
| Malware scanning | *Recommended out of scope for images* (OD10): every image is decoded and re-encoded, and only the re-encoded bytes are stored and served. Residual risk: a libvips decoder vulnerability exploitable during decode in the worker. Mitigations: keep sharp current, worker runs as an unprivileged process with no database privileges beyond its functions, `limitInputPixels`, `failOn`. Documents (PDF) require scanning before they are ever accepted | §6.3 |
| SSRF | No server-side URL fetch in v1; AI provider endpoint is fixed configuration | §6.7 |
| Signed URL TTL | Upload: S3 POST policy valid 10 minutes with `content-length-range` up to the declared size; private `GET` 5 min; sensitive 60 s | §6.2, §6.4 |
| Leaking private assets | Private renditions live in S3 `p/` (Block Public Access on), never in R2 or behind the media domain; `describe` returns URLs only for `public` | AS12 |
| Quota abuse | Reservation at intent time (only through `reserve_upload`: the runtime has no `INSERT` on assets tables), verified bytes at outcome, expiry sweep, `in/` lifecycle, AWS Budgets and Cloudflare alerts (OD12); at most 20 × 10 MiB staged per organization | §6.2 |
| Log redaction (SEC-1) | Presigned URLs never logged, audited or stored. Gap: the log redaction (`redaction.ts:27-33`) does not treat `X-Amz-Signature` as sensitive, while spans already do (`node-instrumentation.ts:15-19`). A2a adds `signature` to `SENSITIVE_FIELD_NAME_WORDS` (or the four cloud parameter names to `ADDITIONAL_SENSITIVE_QUERY_PARAM_NAMES`) with a spec, before the first presign | A2a |
| PII in images and EXIF | EXIF/XMP/IPTC removed by default output (S6); faces in product photos are the tenant's responsibility | §6.3 |
| Retention and deletion | §6.8 | AS10 |
| Credentials | Separate IAM users per environment and service with prefix-scoped policies (§13.4); an R2 token scoped to the public bucket for the media worker only (S21); a Cloudflare token scoped to cache purge on the media zone; S3 clients always receive explicit credentials, never the SDK's default chain, because `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` in the environment are the SES key (§13.1) | A0 |

***

## 12. Performance and data design

### 12.1 Expected cardinalities (assumption)

| Profile | Items | Images | Photos per item | Uploads per day (steady) |
|---|---|---|---|---|
| Small restaurant | 50–300 | 50–300 | ~1 | < 10 |
| Restaurant group | 300–1,500 | 300–2,000 | 1–2 | < 30 |
| Small retail | 500–5,000 | 500–8,000 | 1–2 (variant colours) | 10–100 |
| Larger retail | 5,000–50,000 | 5,000–75,000 | 1–3 | 100–1,000, bursts on import |

Per asset stored bytes ≈ 0.5–2 MB across master and four renditions
(§5.5, *assumption*). Rows per asset: 1 asset + 5 variants + 5 storage
objects + 1 upload + 1–2 jobs.

### 12.2 Hot reads

| Read | Class | Design |
|---|---|---|
| Image bytes | hottest | Served by Cloudflare from the public bucket; **zero API calls** |
| C17 till read with images | hot (in `perf:http`) | +1 statement on the `200` (`describe`, PK lookups on ≤ 2,000 ids): 8 → 9; the `304` stays at 7; no join to variants or storage objects (SG1–SG3); `imageRef` plus one template instead of full URLs (§6.4). The `200` is already a known breach at p50 (`AGENTS.md` §5.5: 23.99 ms on a 925-item outlet, cause: building and sending one large body), so A6 re-baselines `catalog-till-sellables` with the measured delta |
| C3 item read | frequent | +1 statement |
| AS3 polling | frequent during uploads only | One row + nothing else; client polls every 1 s, up to 30 s; not in `perf:http` |
| AS4 library | administrative | Keyset on SG12 index |

*Decided:* no denormalized `primary_asset_id` on `catalog.items`: the
existing unique partial index `item_assets_one_item_primary_uq` already makes
the join an index lookup per item; a denormalized column would need its own
trigger to stay right. Revisit only if `EXPLAIN (ANALYZE, BUFFERS)` on the
"larger retail" profile shows otherwise.

### 12.3 Storage growth and cost (R2-only prices, S1; the recommended S3 + R2 split is compared in §6.1 and §13.8)

| Profile | Images | Stored | Storage cost/month | One-time Class A (≈7 writes per asset) |
|---|---|---|---|---|
| Small restaurant | 300 | 0.3 GB | inside free tier | 2,100 ops, free tier |
| Small retail | 8,000 | 8 GB | inside free tier per account, else $0.12 | 56,000 ops ≈ $0.25 |
| Larger retail | 75,000 | 75 GB | $1.13 | 525,000 ops ≈ $2.36 |
| 1,000 tenants mixed (assumption: 2,000 images each) | 2 M | 2 TB | ≈ $30 | ≈ $63 one-time |

Egress is free on R2 (S1), which is why option D serves public renditions from R2 (§6.1); on S3 the same 2 TB tenant base re-downloading
tiles monthly (say 5 TB of reads) would cost about $450 (S10). Deduplication
would save cents at these sizes.

### 12.4 Media worker throughput (assumption, measured in A3c)

One job ≈ download + one input decode + master encode + 4 encodes derived
from the master + 5 uploads ≈ 0.5–1.5 s for a 12 MP photo on one vCPU →
2,500–7,000 images per hour per media-worker replica. A 50,000
image import ≈ 7–20 hours on one replica, 2–5 hours on four. Small tenants
see "ready" in 1–3 s.

### 12.5 Statement budget

Counted the way the built routes are counted: every statement the request
sends, including session, membership, `BEGIN`, tenant context and `COMMIT`
(C19 = 8 as built, "K7 as built" in `CATALOG_USE_CASES.md`). Each slice
pins its routes in `test/e2e/statement-budget.e2e-spec.ts`.

| Route | Statements (target) |
|---|---|
| AS1 (first call) | session (1) + membership (1) + `BEGIN` (1) + tenant context (1) + authorization (1) + `reserve_upload` (1: usage lock, limits, idempotency claim, inserts, reservation) + audit (1) + `COMMIT` (1) = **8** |
| AS1 (replay) | same, with the existing upload read (1) instead of the audit = **8** |
| AS2 | session + membership + `BEGIN` + tenant context + permission + `complete_upload` (1) + asset read for the answer (1) + `COMMIT` = **8** |
| AS3, AS4 | session + membership + `BEGIN` + tenant context + permission + read (1) + `COMMIT` = **7** |
| AS5 | 10 when it writes (session, membership, `BEGIN`, tenant context, permission, read and lock for `If-Match`, update, audit, read the answer, `COMMIT`); 7 when unchanged |
| AS6 | 9 (session, membership, `BEGIN`, tenant context, permission, the definer function, audit, read the answer, `COMMIT`) |
| AC1 | session + membership + `BEGIN` + tenant context + authorization + version lock + item lock + `assertAttachable` + delete + upsert + audit + item representation read (C3's statements) + `COMMIT` ≈ **13**, pinned by A5b |
| AC5 | as AC1 with set-based statements for all items, plus one audit statement per changed item (≤ 100): constant in N except the audit; A5c measures it |
| C17 `200` | 8 as built + `describe` = **9** |
| C17 `304` | **7**, unchanged (answers before any read) |
| C19 | 8 as built + `describe` = **9** |
| AC6 | session + membership + `BEGIN` + tenant context + authorization + item read + `request_generation` + audit + `COMMIT` = **9** |
| AS16 | session + membership + `BEGIN` + tenant context + permission + `decide_generation` (1) + audit + read the answer + `COMMIT` = **9** |

### 12.6 `perf:http`

C17 with images replaces its current row (hot); the `catalog-till-sellables`
baseline in `KNOWN_BREACHES` is re-measured and updated in A6, because
`perf:http` fails on a regression above 25% (`AGENTS.md` §5.5). AS1/AS2 are
administrative and not measured. AS7 throughput and the media worker's peak
RSS are measured by a dedicated script in A3c (`perf:assets`, *proposed*),
reported in `PERFORMANCE_BASELINE.md`; that measurement is the A3c gate for
the pixel cap and the service memory limit.

***

## 13. Infrastructure plan (AWS, Cloudflare, OpenAI, Railway)

Nothing here is executed by an agent: `railway config apply`, variable
changes and every console action are the owner's (`AGENTS.md` §10). Values
never appear in this document, only names.

### 13.1 Existing AWS usage and what we reuse

| What exists today | Evidence | What the Assets work must not break | Reused? |
|---|---|---|---|
| SES sends email through `SesConnector` (`SESv2Client`, `SendEmailCommand`, one attempt, 10 s timeout) | `src/foundation/messaging/ses/ses.connector.ts:1,67-90`; `src/foundation/messaging/messaging.module.ts:19-42` | Nothing in this design changes SES code, variables, region or sender | No: separate principals |
| SES configuration: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `SES_SENDER_EMAIL_ADDRESS`, all-or-nothing | `environment-variables.schema.ts:213-216`, `:297-309` | These names are also the AWS SDK's **default credential-chain variables**: an S3 client built without explicit credentials would silently use the SES key. *Decided:* every Assets AWS client receives explicit credentials from `ASSETS_S3_*` variables and a unit test asserts it | The `@aws-sdk/*` v3 family (`package.json:67`) |
| Who sends: the worker dispatches the security outbox (OTP codes, invitations); the API also holds the SES variables | `railway.ts:140-143` (api), `:174-177` (worker); `worker.module.ts:8-18`; `identity.module.ts:3,160` imports `MessagingModule` | The `worker` service stays exactly as it is; images go to `media-worker` (OD14) | No |
| Sending domain and identity | `SES_SENDER_EMAIL_ADDRESS` (value preserved, not readable here) | *Assumption:* a verified SES domain identity exists in the SES region; its DNS records (DKIM, SPF, MAIL FROM) live in Cloudflare DNS and must not be touched when adding media records | DNS zone only |

**Shared AWS account hygiene (*Recommended*, OD12).** One AWS account holds
SES and S3 (same billing, as the owner asked), so:

* **Separate IAM principals:** the existing SES principal keeps only SES
  sending permissions (A0 verifies it has no S3 permission); new principals
  per environment and service for S3 (§13.4). No wildcard actions or
  resources in any policy; no principal holds both SES and S3.
* **Root account:** MFA on, no access keys, used only for account-level
  tasks; daily work through IAM Identity Center or an admin IAM user with
  MFA.
* **AWS Budgets:** a monthly cost budget for the whole account with alerts
  at 50%, 80% and 100% (OD12), plus an S3-filtered budget.
* **CloudTrail:** the default management-events trail (bucket policy and
  IAM changes). S3 **data events** are not enabled in v1: billed per event
  and not justified while only public product photos exist; revisit with
  A7 (private and sensitive files).
* **Rotation:** access keys rotated every 90 days with the two-key overlap
  IAM allows per user (*assumption*: AWS's documented limit of two access
  keys per user, not fetched); the procedure is in the A0 checklist.

### 13.2 Accounts and ownership

| Account | Holds | Owner and billing | MFA | Alerts |
|---|---|---|---|---|
| AWS (existing) | SES (today), S3 buckets `ninaku-staging-assets`, `ninaku-production-assets`, IAM principals | The company account owner (OD12) | Root and every human user | AWS Budgets 50/80/100%; CloudTrail management events |
| Cloudflare (existing) | DNS zone(s), media custom domains, R2 public buckets `ninaku-staging-media-public`, `ninaku-production-media-public`, cache/transform/WAF rules, API tokens | The company account owner (OD12) | Every member | Billing notification; R2 storage usage notification |
| OpenAI (new, A8) | Organization (verified, S30), one project per environment, API keys, spend limits | The company account owner (OD17) | Every member | Project hard monthly limit and 50% alert (S37) |
| Railway (existing) | `api`, `worker`, `database-migrator`, `postgres`, new `media-worker` | Existing | Existing | Existing |

### 13.3 Environment matrix

| Environment | Object storage | CDN | OpenAI | How tests reach it |
|---|---|---|---|---|
| Unit and integration tests, CI | In-memory fake `ObjectStoragePort`; a fake image-model port | — | Fake | Every slice; CI needs no cloud credential |
| Local development | A local S3-compatible server in a container for the manual browser flow, POST policies included (*Decided*: MinIO first, LocalStack as fallback; *assumption*: current image availability and POST-policy support are checked in A2a); public renditions written to a second local bucket and served by it | None | Fake by default; a developer key only on request | `npm run` scripts in A2a |
| Staging | `ninaku-staging-assets` (S3) + `ninaku-staging-media-public` (R2) | `media-staging.<domain>` (OD13) | Staging project with a low hard limit | Contract test of the POST policy (A2a) and a manual end-to-end upload |
| Production | `ninaku-production-assets` + `ninaku-production-media-public` | `media.<domain>` | Production project, ZDR if eligible | — |

Staging and production never share a bucket, a principal, a token or an
OpenAI project.

### 13.4 Per-service credentials and least-privilege policies

| Principal (per environment) | Used by | Can do | Cannot do |
|---|---|---|---|
| `ninaku-<env>-assets-api` (IAM user) | `api` | Sign POST policies for `in/*` (its `s3:PutObject` permission is what S3 checks on the browser's POST) | Read anything; write outside `in/`; delete |
| `ninaku-<env>-assets-media-worker` (IAM user) | `media-worker` | Read and delete `in/*`; read, write, delete `m/*` and `p/*`; `s3:ListBucket` on the bucket **without** a prefix condition (the bucket holds only assets), so that `HEAD` on a missing key answers `404`, not `403` (a `HEAD` carries no `prefix` parameter, so a prefix-conditioned list grant would not apply; *assumption* from the HeadObject documentation, not fetched; A2a's contract test proves it on the staging bucket) | Touch bucket configuration; SES |
| SES principal | `api`, `worker` | SES sending only: a new SES-only key replaces the current one through the A0 swap (step 2), without changing SES behavior | S3, R2, anything else |
| R2 token `media-public-<env>` (Object Read & Write, one bucket, S21) | `media-worker` | Write and delete public renditions | Other buckets |
| Cloudflare token `media-purge-<env>` (Cache Purge, media zone) | `media-worker` | Purge by URL (S22) | Anything else |
| `database-migrator` | — | **No** storage or OpenAI credential, ever | — |

API principal policy (design text):

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SignUploadPoliciesForTheInboxOnly",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::ninaku-<env>-assets/in/*"
    }
  ]
}
```

Media worker principal policy:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadAndClearTheInbox",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::ninaku-<env>-assets/in/*"
    },
    {
      "Sid": "ManageMastersAndPrivateRenditions",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": [
        "arn:aws:s3:::ninaku-<env>-assets/m/*",
        "arn:aws:s3:::ninaku-<env>-assets/p/*"
      ]
    },
    {
      "Sid": "ListTheAssetsBucketSoMissingKeysAnswer404",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::ninaku-<env>-assets"
    }
  ]
}
```

Bucket policy (both environments): deny every request when
`aws:SecureTransport` is `false`; Block Public Access stays on, so no
statement can make the bucket public.

**Credential delivery (*Decided*).** Railway is not AWS compute, so there is
no instance role. Options weighed: (a) long-lived IAM user keys with the
policies above, in Railway variables, rotated every 90 days; (b) a base key
that can only call `sts:AssumeRole` into a role with those policies, giving
1-hour credentials: the base key is just as long-lived and as powerful in
effect, and every service needs a refresh loop; a presigned POST signed with
temporary credentials also dies with them (S26); (c) IAM Roles Anywhere:
needs a private certificate authority. **(a)**, with the rotation procedure
in A0 and CloudTrail recording key use.

**Log redaction.** `X-Amz-Signature`, `X-Amz-Credential` and
`X-Amz-Security-Token` never reach logs: spans already redact them
(`node-instrumentation.ts:15-19`), the log redaction gets `signature` in
A2a (SEC-1), and the API never logs a POST policy or its fields (the policy
travels in a form field, so the field names `policy` and `x-amz-signature`
are added to the redacted names too).

### 13.5 DNS and Cloudflare configuration

| Item | Setting |
|---|---|
| `media.<domain>` / `media-staging.<domain>` | Connected from the R2 bucket's custom-domain settings (proxied), per environment (R2-PB) |
| `r2.dev` | Disabled for both public buckets in production; allowed only for staging debugging (R2-PB) |
| Cache rule | `media` hosts, path `/a/*`: eligible for cache, respect origin `Cache-Control` (`public, max-age=31536000, immutable` set by the worker) |
| Transform rule (response headers) | `X-Content-Type-Options: nosniff`; `Content-Security-Policy: default-src 'none'; img-src 'self'; sandbox` |
| WAF | One rate-limiting rule per media host by IP (*assumption*: the plan in use includes at least one rate-limiting rule; else skip, R2 egress is free) |
| R2 CORS | `GET` from `*`, no credentials |
| S3 CORS | `POST` from the web app origins only (staging and production lists kept apart, like `CORS_ALLOWED_ORIGINS`, `railway.ts:24-41`) |
| Tokens | R2 token per environment (one bucket); cache-purge token per zone |
| Existing records | SES DKIM/SPF/MAIL FROM and every other record untouched |

### 13.6 Railway variables (names only)

| Variable | `api` | `media-worker` | `worker` |
|---|---|---|---|
| `ASSETS_S3_REGION`, `ASSETS_S3_BUCKET` | ✓ | ✓ | |
| `ASSETS_S3_API_ACCESS_KEY_ID`, `ASSETS_S3_API_SECRET_ACCESS_KEY` | ✓ | | |
| `ASSETS_S3_WORKER_ACCESS_KEY_ID`, `ASSETS_S3_WORKER_SECRET_ACCESS_KEY` | | ✓ | |
| `ASSETS_R2_ACCOUNT_ID`, `ASSETS_R2_PUBLIC_BUCKET`, `ASSETS_R2_ACCESS_KEY_ID`, `ASSETS_R2_SECRET_ACCESS_KEY` | | ✓ | |
| `ASSETS_PUBLIC_BASE_URL` | ✓ | ✓ | |
| `ASSETS_CDN_ZONE_ID`, `ASSETS_CDN_PURGE_TOKEN` | | ✓ | |
| `OPENAI_API_KEY`, `OPENAI_PROJECT_ID`, `OPENAI_IMAGE_MODEL`, `OPENAI_IMAGE_QUALITY` (A8) | | ✓ | |
| `DATABASE_*` (user `ninaku_worker`), `NODE_ENV`, `OTEL_*` | existing | ✓ (process role `media-worker`, telemetry name `ninaku-media-worker`, `ASSETS_STAGE_SPECS.md` §7) | existing |
| `CURSOR_SIGNING_KEY`, `ACCESS_TOKEN_SIGNING_KEY`, `TOTP_SECRET_ENCRYPTION_KEY`, `SECURITY_OUTBOX_ENCRYPTION_KEY` | existing | **never** (the `media-worker` role does not read them) | existing |
| `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `SES_SENDER_EMAIL_ADDRESS` | existing, unchanged | **never** | existing, unchanged |

Secrets use `preserve()` in `.railway/railway.ts`; `ASSETS_PUBLIC_BASE_URL`,
model names and the bucket names are plain literals per environment, like
`CORS_ALLOWED_ORIGINS`.

### 13.7 A0 owner checklist (in order)

1. Answer OD1, OD12, OD13, OD14 (and OD17 before A8).
2. AWS: confirm root MFA and no root keys; create AWS Budgets (account and
   S3-filtered). **Do not edit the current SES key in place** (it may be an
   administrator key, and narrowing it could stop the security emails):
   create a **new** IAM user with SES sending permission only, put its key
   in the Railway variables `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` of
   `api` and `worker` in staging, send a test OTP (sign-up code) and an
   invitation, repeat in production, and only then deactivate the old key
   (delete it a week later). This changes credentials, not SES behavior.
3. AWS, per environment: create bucket `ninaku-<env>-assets` in
   `us-east-1` with Block Public Access on, Object Ownership "Bucket owner
   enforced", SSE-S3, versioning off; lifecycle rule `in/` → expire 1 day and
   abort incomplete multipart uploads after 1 day; bucket policy denying
   non-TLS; CORS for `POST` from the web app origins.
4. AWS, per environment: create IAM users `ninaku-<env>-assets-api` and
   `ninaku-<env>-assets-media-worker` with the §13.4 policies; one access
   key each; store them only in Railway.
5. Cloudflare, before anything else: confirm R2 is enabled on the account
   (it may ask for a payment method even on the free tier: *unverified*,
   OD22); confirm the media domain's zone is in the **same** Cloudflare
   account as the R2 buckets (a custom domain must be a zone of that
   account, *assumption*); confirm no `media` or `media-staging` DNS record
   already exists in the zone. Then, per environment: create R2 bucket
   `ninaku-<env>-media-public` (location hint `enam`, S5); connect
   `media[-staging].<domain>`; disable `r2.dev` in production; R2 CORS
   `GET *`; the cache and transform rules; the R2 token (one bucket) and
   the cache-purge token; billing and R2 usage notifications.
6. Railway: add the `media-worker` service (same repository, entrypoint
   `node dist/media-worker`, a memory limit set from A3c's measurement; until
   then 1.5 GB, recommended from the local measurement and re-measured on
   Railway's Linux) and the §13.6 variables, through
   `.railway/railway.ts` and `railway config plan` showing only those
   changes; the owner applies.
7. Staging smoke test after A2c: upload one photo from a phone, see it
   ready, open its tile URL through `media-staging`.
8. Rotation (every 90 days, per principal): create the second key, update
   the Railway variable, redeploy, confirm traffic, deactivate the old key,
   delete it a week later.
9. OpenAI (A8): verify the organization (S30), create one project per
   environment, set enforced hard monthly limits and a 50% alert (S37),
   request ZDR for production if eligible (S35), create a project key, store
   it only in Railway.

### 13.8 Monthly cost estimate per profile

Assumptions as in §6.1 plus: AI images at `gpt-image-1.5` medium 1024×1024,
$0.034 each (S33); worker reads ≈ 3 MB per upload from S3; the `media-worker`
Railway service cost is not estimated (*unknown*: depends on Railway's
usage pricing and A3c's memory).

| Profile | Images stored | Storage + requests + transfer (option D) | CDN | AI generations | Total |
|---|---|---|---|---|---|
| Small restaurant | 300 (0.3 GB) | ≈ $0.01 | $0 (Cloudflare, free egress from R2) | 20/month ≈ $0.68 | ≈ $0.70 |
| Restaurant chain (20 outlets, one catalog) | 2,000 (2 GB) | ≈ $0.05 | $0 | 50/month ≈ $1.70 | ≈ $1.75 |
| Retail, 50,000 images | 50 GB | ≈ $0.90 (+ ≈ $4.50 once for the worker reading 50,000 uploads) | $0 | 200/month ≈ $6.80 | ≈ $7.70 |
| Platform, 1,000 organizations | 2 TB | ≈ $46 | $0 | 20,000/month ≈ $680 | ≈ $726 |

The AI line dominates as soon as generation is used, which is why OD18 sets a
monthly limit per plan and OpenAI's project hard limit caps the total.

### 13.9 Third-party outage register

| Outage | Effect | Behavior (*Decided*) |
|---|---|---|
| S3 unavailable | New uploads fail at the POST; processing jobs cannot read or write | The client shows "try again"; an unused intent expires and its quota is released (AS9); jobs re-queue with capped backoff and **no attempt cap**, so assets wait in `processing` instead of failing; an alert fires after 1 hour; **delivery is unaffected** (public renditions are on R2) |
| R2 unavailable | Public images not served on cache misses; renditions cannot be written | Cached images keep serving; the till's offline cache keeps showing tiles; jobs re-queue without an attempt cap |
| Cloudflare unavailable | Media domain down (and any other proxied hostname) | Tills show cached tiles; no fallback domain in v1 (*accepted*: a second CDN doubles configuration for a rare event) |
| OpenAI unavailable or rate-limited | Generation requests wait | Jobs retry with `Retry-After`, then fail and release quota; the UI says "try later"; nothing else depends on OpenAI |
| SES unavailable | Unchanged by this design | Unchanged (security outbox, D26) |
| Railway `media-worker` down | Uploads stay `processing`; generations stay `queued` | Nothing is lost (jobs in PostgreSQL); the `api` and the OTP `worker` are unaffected (OD14) |

***

## 14. Delivery slices

**This table is the overview; the PR splits, line estimates and migration numbers that a writer follows are those of [`ASSETS_STAGE_SPECS.md`](ASSETS_STAGE_SPECS.md) (§27 lists every PR: A1b is three PRs, A1c, A2a-1, A3c, A4b and A5b two each, A8a three).**

Each slice is one PR into `staging`, ≤ 400 changed lines (`AGENTS.md` §7),
counting the canonical mirror of every migration (`AGENTS.md` §8), which
roughly doubles the SQL lines. Sizes: S 1–2 h, M 3–5 h, L 6 h+. Line counts
are estimates; a slice that crosses 400 splits at the named point.

| # | Slice | Cases | Depends on | Size | Est. lines | Risk | Security-critical | Migration | Measured |
|---|---|---|---|---|---|---|---|---|---|
| **A0** | Infrastructure (owner-run, §13.7): AWS (budgets, root MFA, S3 bucket per environment with prefixes, lifecycle, CORS, TLS-only policy, IAM users per service), Cloudflare (R2 public bucket per environment, media custom domain, `r2.dev` off in production, cache and transform rules, R2 and cache-purge tokens, alerts), OpenAI later (A8); `.railway/railway.ts` declares the `media-worker` service and the §13.6 variables with `preserve()` | — | OD1, OD12–OD14 | S | ≈ 80 (`railway.ts`) | Medium | **Yes** (credentials) | No | `railway config plan` shows only the new service and variables |
| **A1a** | Structure: SG1–SG5, SG9, SG12, SG16–SG18 on existing tables; SG6–SG8 tables, RLS, usage backfill; grants: runtime `USAGE`, `SELECT` on `assets`, `asset_variants`, `uploads`, `organization_usage`, `storage_providers`, column `UPDATE (display_name)` on `assets.assets` only (archive and restore go through definer functions that keep the usage counters), no `INSERT`; worker nothing yet. **Also, in the same PR (OD7):** a new decision **D31** in `DECISIONS.md` (Spanish: "Las tareas pesadas de un módulo usan una cola propia del módulo con `organization_id`, con la forma de `events.outbox_records`; `events.*` queda para eventos de integración") and the amendment of `AGENTS.md` §5.4 naming the module-owned queue | — | — | M | ≈ 650 → split A1a-1 (columns, triggers, grants, D31, AGENTS; ≈ 350) and A1a-2 (three tables, RLS, backfill; ≈ 330) | High | **Yes** | Yes (`0083`, then the next free number) | `db:test` contracts |
| **A1b** | Admission: `reserve_upload` and `complete_upload` (SECURITY DEFINER, the runtime's only write path for status and creation), seeds (two providers, `s3-private` and `r2-public`, permissions §10.1 and roles §10.2, capability `assets.media` on Pionero current maximum + 1, limit definitions per OD6, audit kind and actions), quota and idempotency contracts | — | A1a | M | ≈ 500 → split seeds (≈ 200) from functions (≈ 300) | High | **Yes** | Yes (next free number) | `db:test` |
| **A1c** | Processing contract: `claim_processing_job` (one job), `record_processing_outcome`, `expire_uploads`, `reconcile_usage`; worker grants (`USAGE`, `EXECUTE` on these only); lease contracts | — | A1a | M | ≈ 400 | High | **Yes** | Yes (next free number) | `db:test` |
| **A2a** | Module skeleton, `ObjectStoragePort`, S3 POST-policy signer and the S3-API object adapter for S3 and R2 (**explicit credentials only**), 11 storage variables in the environment schema, SEC-1 log redaction (`signature`, `policy`, `x-amz-signature`). **Contract check (no longer a gate):** against the local S3-compatible server and the staging bucket, a POST one byte over the declared size, with another content type or another key is refused by S3, and a `HEAD` on a missing key answers `404` | — | A0 (staging), A1b | L | ≈ 650 → split **A2a-1** (skeleton, port, environment schema, redaction; ≈ 300) and **A2a-2** (POST signer, S3/R2 adapter, credential-isolation spec, the local-server contract test; ≈ 350) | High | **Yes** | No | — |
| **A2b** | AS1 and AS3 | AS1, AS3 | A2a | M | ≈ 400 | High | **Yes** | No | — |
| **A2c** | AS2 and the upload e2e journey with a fake storage adapter | AS2 | A2b | S | ≈ 300 | Medium | Yes | No | — |
| **A3a** | `media-worker` entrypoint and module, job loop (claim one, record), staging cleanup, AS9 sweep | AS9 (sweep) | A1c, A2c | M | ≈ 380 | Medium | Yes | No | — |
| **A3b** | Image pipeline: sniff, decode once into the master, renditions from the master, sharp dependency | AS7 | A3a | L | ≈ 550 → split **A3b-1** (magic-byte sniffing, dimension and pixel checks, master decode; ≈ 300) and **A3b-2** (renditions, outputs to S3 and R2, outcome recording; ≈ 250) | High | **Yes** (decoder) | No | — |
| **A3c** | Retry classes (storage errors without attempt cap, processing errors 5 attempts), dead letter, `assets.requeue_dead_letters` runbook function (OD21), `perf:assets`, memory limit from measured peak RSS (gate for OD5) | AS8 | A3b | M | ≈ 400 | High | Yes | Yes (next free number, the requeue function) | throughput, peak RSS |
| **A4a** | Library list, rename, archive, restore | AS4–AS6 | A2b | M | ≈ 400 | Medium | No | No | — |
| **A4b** | Purge: `purge_asset_begin/finish`, deletes from S3 (`m/`, `p/`) and R2 (public renditions), automatic CDN purge by URL, offboarding enumeration designed | AS10 | A3c, A5b | M | ≈ 450 → split SQL (≈ 250) from worker (≈ 200) | High | **Yes** (erasure) | Yes (next free number) | — |
| **A5a** | `AssetUsageContract`, `AssetDeliveryContract`, `MODULE_CONTRACTS.md` §15 rows | AS12, AS13 | A3b | M | ≈ 350 | High | Yes | No | — |
| **A5b** | Catalog: `item_assets` grants, bump triggers, index (SG11), audit actions; AC1–AC3 | AC1–AC3 | A5a | L | ≈ 450 → split migration (≈ 150) from routes (≈ 300) | High (cross-module locks) | Yes | Yes (next free number) | — |
| **A5c** | Bulk image command | AC5 | A5b | M | ≈ 300 | Medium | No | No | statement count |
| **A6** | Till read with `imageRef` and template, ETag fingerprint, statement-budget update (9/7/9), `KNOWN_BREACHES` re-baseline | AC4 | A5b | M | ≈ 250 | High (hot path) | No | No | C17 p50–p99, 1 and 16 clients |
| **A7** | Private and sensitive delivery (blocked by OD15) | AS14 | A3c, OD15 | M | ≈ 400 | Medium | **Yes** | Maybe | — |
| **A8a** | AI SQL: SG19–SG21 (with `create_generated_asset`), `request_generation`, consume/release functions, capability `assets.ai_generation`, limit `assets.ai_generations` (`integer`, monthly), audit actions | — | A1c, OD9, OD15, OD17, OD18, OD20 | M | ≈ 500 → split tables (≈ 250) from functions and seeds (≈ 250) | High | **Yes** | Yes (next free number) | `db:test` |
| **A8b** | `AssetGenerationContract`, prompt builder and presets, AC6 route, AS15 read | AS15, AC6 | A8a, A5b | M | ≈ 400 | High | **Yes** (prompt input) | No | — |
| **A8c** | Media worker: generation claim loop (concurrency 2), `generate_image` job, OpenAI client (fixed endpoint, key from variable, timeout 180 s), re-queue on 429/5xx, moderation and refusal handling, quota consume/release, `create_generated_asset` and pipeline hand-off | AS15w | A8b, A3c | L | ≈ 550 → split **A8c-1** (OpenAI client port and adapter, error classification, redaction of prompts and bodies; ≈ 250) and **A8c-2** (claim loop, job, asset creation, quota; ≈ 300) | High | **Yes** (secret, cost) | No | provider latency, cost per image |
| **A8d** | Review: AS16 approval/rejection, `not_approved` in `assertAttachable`, `aiGenerated` in delivery and the till, disclosure label contract (OD19) | AS16 | A8c | M | ≈ 350 | Medium | Yes | No | — |

**Default order:** A0 ∥ A1a → A1b ∥ A1c → A2a-1 → A2a-2 → A2b → A2c → A3a →
A3b-1 → A3b-2 → A3c → A5a → A5b → A6 → A5c → A4a → A4b → A7 → A8a → A8b → A8c-1 → A8c-2 → A8d (A7 and A8 wait for OD15). A4 moves after A6
because a restaurant can live without the library list before it can live
without the till image; nothing is purged before A4b, so failed and archived
assets simply wait.

### 14.1 Stage specifications

Every stage, A0 to A8d, is specified in
[`ASSETS_STAGE_SPECS.md`](ASSETS_STAGE_SPECS.md): goal, preconditions,
deliverables per file, draft DDL for SQL stages, routes with schemas and
statement counts for API stages, worker rules, exact test titles, mutation
targets, security checks, gates, rollout and the definition of done. The
owner's infrastructure stage A0 is the ordered checklist of
[`ASSETS_INFRASTRUCTURE_RUNBOOK.md`](../operations/ASSETS_INFRASTRUCTURE_RUNBOOK.md).
The specifications are the source for the writers; where they refine a line
of this document (a function signature, a migration number), the
specification wins and this document was updated in the same change.

**Status of every slice:** specified on 2026-10-06; A1a to A6 (A4b included)
are built, each with its "as built" note below; A7 and A8 are blocked on
OD15.

### A1a-1 as built (migration `0083_assets_lifecycle_columns`, baseline revision 93)

| Piece | As built |
|---|---|
| Columns (SG1–SG5, SG9) | As the stage DDL: `assets.assets` gains `status`, `ready_at`, `failed_at`, `failure_code`, `delivery_token` (`NOT NULL`, unique, `^[a-z2-7]{26}$`), `rendition_profile`, `media_kind`, `source_kind`, `created_by_membership_id` (tenant-safe FK `assets_created_by_membership_fk`); `assets.asset_variants` gains `failure_code` with `asset_variants_failure_code_chk`. The migration refuses a non-empty `assets.assets` with `assets_lifecycle_requires_empty_table` (23514) and rolls back whole |
| Triggers (SG16–SG18) | `assets_guard_status_trg` (`assets.guard_asset_status`, `BEFORE UPDATE OF status, archived_at`): only the stage's transition table, same status always allowed, a `purging` asset keeps its status and `archived_at`; refusal constraint `assets_status_transition`. `assets_protect_access_class_trg` refuses any change with `assets_access_class_immutable`. `assets_ready_requires_renditions_trg` on `assets` and `asset_variants_ready_asset_renditions_trg` on `asset_variants` (deferred) require a `ready` asset to have `original` and every code of `assets.profile_rendition_codes(rendition_profile)` `ready`; a profile with no codes cannot be `ready` (a refinement of the spec, so an unknown profile never passes with its original alone). No trigger function is SECURITY DEFINER; errors name their code in `CONSTRAINT` |
| Indexes (SG12) | `assets_organization_active_idx` replaced by `(organization_id, created_at DESC, id DESC) WHERE archived_at IS NULL`; new `assets_organization_archived_idx` with `WHERE archived_at IS NOT NULL` |
| Grants | Runtime: `USAGE` on `assets`; `SELECT` on `assets`, `asset_variants`, `storage_providers`; `UPDATE (display_name)` on `assets`; `EXECUTE` on `profile_rendition_codes(smallint)` (the deferred trigger calls it as the writing role). Nothing for `ninaku_worker`. Asserted from `information_schema.role_table_grants` and `role_column_grants` |
| Proof | `database/tests/contracts/assets-lifecycle-contracts.ts` (`NINAKU_ASSETS_LIFECYCLE_OK`), the seven titles of the stage. Mutations that each fail a test: `ready → pending` allowed, `p1-detail` dropped from profile 1, the access class trigger dropped, `UPDATE (status)` or `UPDATE (archived_at)` granted. `0083` applied by `db:prepare` on a revision 92 database leaves a schema identical to a fresh revision 93 install |

### A1a-2 as built (migration `0085_assets_uploads_jobs_and_usage`, baseline revision 95)

| Piece | As built |
|---|---|
| Tables (SG6–SG8) | Exactly the stage DDL: `assets.uploads` (state machine with `uploads_state_shape_chk`, staging key `^in/[a-z2-7]{26}$` unique per provider, JPEG/PNG/WebP/AVIF of 1 byte to 10 MiB, idempotency key unique per organization with its SHA-256 request hash, expiry after creation), `assets.processing_jobs` (lease shape of `events.outbox_records`, `error_class`, `processing_jobs_one_open_uq` on `(asset_id, job_kind)` while `pending` or `leased`) and `assets.organization_usage` (three non-negative counters). Indexes: `uploads_open_expiry_idx`, `uploads_organization_open_idx`, `processing_jobs_claim_idx`, `processing_jobs_one_open_uq`, `processing_jobs_dead_letters_idx` |
| Usage row | `organizations_assets_usage_insert_trg` (`AFTER INSERT ON organization.organizations`) runs `assets.create_organization_usage()`, `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`, `EXECUTE` revoked from `PUBLIC` and granted to nobody; a plain insert, so an existing row is refused (`organization_usage_pkey`), never overwritten. The migration backfills every existing organization once with `ON CONFLICT DO NOTHING`; the canonical file has no backfill because a fresh install has no organizations (the `catalog_versions` precedent) |
| RLS and grants | RLS enabled on the three tables with one `FOR SELECT` tenant policy each (scalar subquery); no insert, update or delete policy (the stage spec wins over SG6's insert and update policies). Runtime: `SELECT` on `uploads` and `organization_usage`; no grant on `processing_jobs` to anyone; nothing for `ninaku_worker`. The A1a-1 contract's exact runtime grant list now includes both tables |
| Proof | `database/tests/contracts/assets-tables-contracts.ts` (`NINAKU_ASSETS_TABLES_OK`), the five titles of the stage plus RLS, definer and index checks. An unknown job state is refused by `processing_jobs_lease_shape_chk`, which PostgreSQL evaluates before `processing_jobs_state_chk` (name order). Mutations that each fail a test: `processing_jobs_one_open_uq` dropped, the usage trigger made `ON CONFLICT DO UPDATE`, a runtime `INSERT` grant added to `runtime_grants.sql`. `0085` applied by `db:prepare` on a revision 94 database with three existing organizations backfills three zeroed usage rows and leaves a schema (with privileges) identical to a fresh revision 95 install |

### A1b-1 as built (migration `0086_assets_vocabulary`, baseline revision 96)

| Piece | As built |
|---|---|
| Seeds | Providers `s3-private` (`s3`, `ASSETS_S3`) and `r2-public` (`cloudflare_r2`, `ASSETS_R2`); permissions `assets.media.manage` and `assets.media.view` on the system roles `organization_owner` and `manager` only (`0070_assets.sql`); capability `assets.media` and the integer, never-reset limit definitions `assets.active_assets` (`assets`) and `assets.stored_megabytes` (`megabytes`) (`0060_platform.sql`); resource kind `assets.asset` and the four tenant configuration actions `upload_requested`, `renamed`, `archived`, `restored` (`0380_audit.sql`). Every insert is `ON CONFLICT DO NOTHING`; no grant changes |
| Pionero version | One idempotent `DO` block, byte-identical in the migration and at the end of `0060_platform.sql`: under a lock on the plan row it takes the active Pionero version with the highest number, publishes a new version numbered **the highest version number of the plan plus one** (the rule of `platform.create_draft_plan_version_as_staff`, `0060:3058`, because `plan_versions_plan_version_uq` covers drafts too and "active + 1" would collide with a staff draft) with every capability and limit of that version plus `assets.media`, 5,000 and 10,240, copies its **active** offerings (market, currency, interval, amount, trial days, and also visibility and target organization, so a private offering never becomes public) before retiring it, and returns when any Pionero version already has `assets.media`. It refuses with `platform_pionero_assets_seed_missing` when there is no active Pionero version or the capability or a limit definition is missing. A fresh install yields version 5 |
| Proof | `database/tests/contracts/assets-seed-contracts.ts` (`NINAKU_ASSETS_SEED_OK`), the two titles of the stage; the second replays the migration block twice, inside a rolled-back transaction, on a Pionero plan with an active version 6 (a private, a paused and two public offerings) and a draft version 7, expecting one new active version 8. Mutations that each fail a test: a hard-coded version number, "active + 1", no idempotency return, offerings read after the retirement, the permission given to `cashier`. `0086` applied by `db:prepare` on a revision 95 database with three organizations, three assets and a staff draft version 5 publishes version 6, keeps version 5 a draft and leaves a schema (with privileges) identical to a fresh revision 96 install |

### A1b-2 as built (migration `0087_assets_admission_functions`, baseline revision 97)

| Piece | As built |
|---|---|
| Functions | `assets.reserve_upload(...)` (the stage signature) and `assets.complete_upload(uuid)`, `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`, `EXECUTE` revoked from `PUBLIC` and granted to `ninaku_runtime` only (migration and `runtime_grants.sql`; the canonical file only revokes, like every runtime grant). Both call `identity.assert_tenant_context()`, take the organization only from `identity.current_organization_id()` and refuse a transaction outside READ COMMITTED with `assets_admission_isolation` (`0A000`). No permission or capability check in SQL: the route authorizes first (`OperationAuthorizationContract`, §9.1 of the stage specification) |
| `reserve_upload` | In the stage order: lock the usage row `FOR UPDATE` (`INTO STRICT`: a missing row fails instead of admitting unserialized); an upload with the same `(organization, idempotency key)` answers `existing` with its stored asset, upload, state, request hash, staging key and expiry (the application compares the hash) before any refusal; then `assets_access_class_unavailable`, `assets_asset_limit` (`active_asset_count + 1 > limit`), `assets_storage_limit` (`stored_bytes + reserved_bytes + byte_size > limit × 1048576`) and `assets_open_uploads_limit` (20 or more uploads `issued` or `received`, the predicate of `uploads_organization_open_idx`), all `23514`; `platform.effective_limit` with no row or unlimited is no ceiling. Inserts the asset (`pending`, `created_by_membership_id = identity.current_membership_id()`), the `original` variant (`pending`) and the upload (`issued`, provider `s3-private`, key `in/<staging token>`), adds 1 and the declared bytes to the usage row, answers `created` |
| `complete_upload` | Locks the upload `FOR UPDATE` filtered by the context organization; answers `not_found` (foreign or unknown), `rejected`, `already_received` (`received`, `verified`) or `expired` (state `expired`, or `issued` with `expires_at <= now()`) without writing; otherwise locks the asset `FOR UPDATE` (UC AS2; only on the writing path, after the upload, so a repeat never waits on the asset), sets the upload `received` with `received_at = now()`, the asset and its original `processing`, and inserts one `process_upload` job (`uuidv7()`) |
| Proof | `database/tests/contracts/assets-admission-contracts.ts` (`NINAKU_ASSETS_ADMISSION_OK`): five titles of the stage, the stage's concurrency title (two psql sessions; kept in the contract script because `database/tests/concurrency/` was outside this stage's edit surface), and two more: the definer shape, grants and isolation, and the asset row lock (a `FOR KEY SHARE` holder of the asset makes a completion wait; a plain status update would not). The archive and restore title belongs to A1b-3. Mutations that each fail a test: the usage row lock removed, the organization filter of `complete_upload` removed, an organization parameter added, the `existing` return removed, `reserved_bytes` left out of the storage check, the asset lock removed, `EXECUTE` granted to `ninaku_worker` in `runtime_grants.sql`. `0087` applied by `db:prepare` on a revision 96 database with three organizations, assets and issued uploads leaves a schema (with owners and privileges) identical to a fresh revision 97 install |

### A1c-1 as built (migration `0088_assets_processing_outcome_functions`, baseline revision 98)

| Piece | As built |
|---|---|
| Functions | `assets.claim_processing_job(varchar, uuid, integer, varchar[])` and `assets.record_processing_outcome(...)` (the stage signatures), `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`, `EXECUTE` revoked from `PUBLIC` and granted to `ninaku_worker` only, with `USAGE` on schema `assets` and no table privilege (migration and `runtime_grants.sql`; the canonical file only revokes). Neither reads the tenant context; both refuse a transaction outside READ COMMITTED with `assets_processing_isolation` (`0A000`, §1.1 of the stage specification). `expire_uploads` and `reconcile_usage_batch` are A1c-2's |
| `claim_processing_job` | Refuses a lease outside 30–900 seconds (`assets_job_lease_seconds`); takes at most one job of the requested kinds that is `pending` with `available_at <= now()` or `leased` with `lease_expires_at <= now()`, ordered by `available_at, id`, `FOR UPDATE SKIP LOCKED`; leases it to the owner and token (the attempt count is untouched); returns the job, its asset's delivery token, access class and profile, and its upload (id, staging provider code and key, declared type and size; `NULL` when the asset has no upload) |
| `record_processing_outcome` | Locks the job `FOR UPDATE`; a job that is not `leased`, has another token or an expired lease is `assets_job_lease_lost`. `assets_outcome_shape` for: an unknown outcome, parallel arrays of different lengths, arrays on a non-`ready` outcome or none on `ready`, an error code on `ready` or none on a failure, any outcome but `retry_storage` on a job other than `process_upload` (purge storage errors, A4b, and generation re-queues, A8c-2, use `retry_storage`), and on `ready` codes that are not exactly `original` plus the profile's, an `original` id that is not the asset's, a rendition id that already exists or an unknown provider code. Retries refuse `available_at` missing or more than 15 minutes ahead (`assets_retry_backoff`). The writing outcomes (`ready`, `failed_permanent`, the fifth `retry_processing`) lock, in the lock order of §7, the usage row, then the asset, its upload and its original variant `FOR UPDATE`, which serializes them with the deferred ready-renditions check's unlocked read, and refuse an asset that is not `processing` or an upload that is not `received` (`assets_outcome_state`). A failure releases the active asset slot only while the asset is not archived (SG8: an archived asset is no longer counted). **Known limits, requirements of A3c (`ASSETS_STAGE_SPECS.md` §14):** an expired lease reclaimed by the claim does not increment `attempt_count` and keeps its old `available_at`, so a worker that crashes before recording (for example out of memory on a huge image) gets the same job back forever; and a job whose asset is no longer `processing` or whose upload is no longer `received` is refused with `assets_outcome_state` on every terminal outcome, stays `leased` and is reclaimed without bound |
| Proof | `database/tests/contracts/assets-processing-contracts.ts` (`NINAKU_ASSETS_PROCESSING_OK`): the six A1c-1 titles of the stage, its concurrency title (two psql sessions, kept in the contract script as A1b-2 did) and the asset row lock (a `FOR KEY SHARE` holder makes a ready outcome wait). Inside those titles the contract also proves the accepted lease bounds 30 and 900, `assets_outcome_state` for a failed asset and for a verified upload (the job stays leased), `assets_outcome_shape` for an unknown outcome, an error code on `ready`, variants on a retry and `retry_processing` or `failed_permanent` on a `purge_asset` job (whose `retry_storage` is accepted), `assets_retry_backoff` for a retry without `available_at`, and that an archived asset failed permanently or dead-lettered at the fifth error keeps its active count and releases its reserved bytes exactly once. The A1a-1 lifecycle contract now expects `USAGE` on `assets` for the worker. Mutations that each fail a test: `SKIP LOCKED` dropped, the lease-expiry check dropped from the record and from the claim, storage retries capped at five, `active_asset_count` forgotten on a failure, an archived asset uncounted twice, the asset lock dropped, the code-set check dropped, the `assets_outcome_state` guard dropped, the job-kind check dropped, `EXECUTE` granted to `ninaku_runtime` in `runtime_grants.sql`. `0088` applied by `db:prepare` on a revision 97 database with organizations, assets, uploads and jobs leaves a schema (with owners and privileges) identical to a fresh revision 98 install |

### A1c-2 as built (migration `0089_assets_upload_expiry_and_usage_reconciliation`, baseline revision 99)

| Piece | As built |
|---|---|
| Functions | `assets.expire_uploads(integer)` and `assets.reconcile_usage_batch(uuid, integer)` (the stage signatures), `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`, `EXECUTE` revoked from `PUBLIC` and granted to `ninaku_worker` only (migration and `runtime_grants.sql`; the canonical file only revokes). Neither reads the tenant context; both refuse a transaction outside READ COMMITTED (`assets_processing_isolation`) and a batch that is not 1–500 (`assets_sweep_batch`) |
| Counting predicate (SG8) | One helper, `assets.counts_as_active_asset(varchar, timestamptz)`: `pending`, `processing` or `ready` and not archived; plain SQL, `IMMUTABLE`, `EXECUTE` revoked from `PUBLIC` and granted to nobody (only the definer functions call it). `expire_uploads`, `reconcile_usage_batch` and `record_processing_outcome` use it; `0089` replaces `record_processing_outcome` with that one line changed and no change of behaviour (it only fails a `processing` asset, so the predicate equals "not archived" there) |
| `expire_uploads` | Reads up to `p_batch` `issued` uploads with `expires_at <= now()`, oldest first (`expires_at, id`), without a lock; for each, locks in the lock order of §7 the usage row, the asset, the upload `FOR UPDATE SKIP LOCKED` re-checking `issued` and expired (a held or already settled upload is skipped, never waited for, never settled twice) and the original variant; sets the upload `expired` (`settled_at`), the asset and its original `failed` (`assets.upload_expired`, `failed_at`), releases the declared bytes once and the active asset slot only while the asset still counts (an asset archived before or while the sweep waits is not uncounted twice: F8), and returns the upload id, its staging provider code and key for the worker to delete. No job is touched (an `issued` upload has none) |
| `reconcile_usage_batch` | For up to `p_batch` organizations whose usage row id is greater than `p_after` (all when `NULL`), in id order: locks the usage row `FOR UPDATE`, then in a new statement (a fresh READ COMMITTED snapshot, so a reservation that committed while it waited is counted) sets `active_asset_count` from the predicate, `stored_bytes` to the sum of `byte_size` of `ready` variants with an `available` primary storage object, and `reserved_bytes` to the declared bytes of `issued` and `received` uploads. Returns the last organization processed, `NULL` only when none follows `p_after`. Purge's per-organization reconciliation (AS10, SG8 `reconcile_usage`) is A4b's |
| Proof | `database/tests/contracts/assets-expiry-contracts.ts` (`NINAKU_ASSETS_EXPIRY_OK`, registered after the processing script; a new script because the processing one is already 777 lines): the stage's two titles, plus `frees the open uploads of an organization for new reservations once the sweep expires them` (20 open uploads refuse a 21st with `assets_open_uploads_limit`; once they expire and are swept a new reservation is `created`), the grant and predicate title, and four races on two psql sessions: a held expired upload is skipped and swept next time; an archive committed while the sweep waits on the usage row is uncounted once; of two concurrent sweeps only one settles an upload; an archive committed while a `failed_permanent` outcome waits is uncounted once; a reservation committed while the reconciliation waits is counted. The reconciliation title also proves that the counters the functions maintained equal the reconciled ones. The A1c-1 grant title now lists the four worker functions. Mutations that each fail a test: `SKIP LOCKED` dropped from the expiry, the expiry's usage row lock dropped, an unconditional active decrement in the expiry and in `record_processing_outcome`, the reservation not released, the re-check of the locked upload dropped, newest first, the reconciliation's usage row lock dropped, `received` uploads left out of `reserved_bytes`, the batch limit ignored, archived assets counted (in the reconciliation and in the helper), `EXECUTE` on `expire_uploads` granted to `ninaku_runtime` and on the helper to `ninaku_worker` in `runtime_grants.sql`. Sweeping `received` uploads in the candidate scan survives because the re-check on the locked row refuses them (equivalent mutant). A ready variant without an available primary object cannot be built (the storage trigger refuses it), so the `available` filter of `stored_bytes` has no failing test. `0089` applied by `db:prepare` on a revision 98 database with three organizations, expired, archived and expired, received (with a job) and open uploads leaves a schema (with owners and privileges) identical to a fresh revision 99 install; on it the worker's sweep expired the six expired uploads and left each organization at `2,0,4096` |
| Carried to A1b-3 | `archive_asset` changes `active_asset_count`, so it must lock the usage row **before** the asset, like every writer here; §7 says archive "locks only the asset", which would deadlock against a sweep or an outcome holding the usage row and waiting for that asset. Done in A1b-3 (below), and §7 now says so |

### A2a-1a as built (process roles and assets environment groups, no migration)

| Piece | As built |
|---|---|
| Process role | `src/foundation/config/values/process-role.ts`: `PROCESS_ROLE` (`api`, `worker`, `media-worker`), `databaseUserFor` (`api` → `ninaku_runtime`, the other two → `ninaku_worker`) and `serviceNameFor` (`ninaku-api`, `ninaku-worker`, `ninaku-media-worker`, the last added to `SERVICE_NAME`). `src/main.ts`, `src/worker.ts`, `WorkerConfigModule`, `DatabaseModule` and `WorkerDatabaseModule` pass a process role; `DatabaseRuntimeRoleGuardService` receives the process role and checks `databaseUserFor(role)` |
| Schemas | `validateEnvironmentVariables(raw, role)`: `api` and `worker` keep one shape and one type (`EnvironmentVariables`, the four identity keys required); `media-worker` has its own shape and type (`MediaWorkerEnvironmentVariables`: database, logging, telemetry and its storage group only), so the identity keys, SES, Wasender, OAuth and outbox variables are not read there even when set. The value parsers moved to `schema/environment-value-parsers.ts`, the groups to `schema/assets-storage-variables.ts` |
| Groups (S4) | API upload-signing group and media-worker storage group, each all-or-nothing with the `reportPartiallyConfiguredChannel` message; `ASSETS_PUBLIC_BASE_URL` must be an absolute `https` origin without path. A non-blank variable of a group the process role does not own (computed from both groups, so the shared `ASSETS_S3_REGION`, `ASSETS_S3_BUCKET` and `ASSETS_PUBLIC_BASE_URL` are refused only in `worker`) fails the boot with `<KEY> belongs to another process role`. No variable has a default value. The CDN purge and OpenAI groups join with A4b and A8c-1 |
| Settings | `AppConfigService.assetsStorageSettings()`: the API group or `undefined` |
| Redaction | `signature` in `SENSITIVE_FIELD_NAME_WORDS` (covers `X-Amz-Signature` and the `x-amz-signature` POST field; `X-Amz-Credential` and `X-Amz-Security-Token` were already covered by `credential` and `token`); `policy` as an exact field name (case-insensitive, so `retentionPolicy` is not affected), which also makes it a sensitive query parameter. The log string redactor masks the value of `X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token` and `Signature` (CloudFront-style) query parameters wherever a URL or query text appears in any logged string (after `?` or `&`, at the start of a bare query string, after whitespace, a quote, a comma or a semicolon, and percent-encoded inside another URL), keeping host, path, fragment and other parameters; spans keep their own query redaction (`redacting-span-exporter.ts`). Proven in `redaction.spec.ts` and through `serializeLogRecord` in `log-record.spec.ts`; mutations removing the masking or the exact `policy` name each fail a test |
| Proof | The six stage titles in `environment-variables.schema.spec.ts` and `redaction.spec.ts`, plus an `https` origin test, two `assetsStorageSettings` tests and the media-worker role in `database-runtime-role-guard.service.spec.ts`. Mutations that each fail a test: `signature` dropped from the list; the identity keys required for `media-worker`; the `api` role reading the media-worker group |
| Carried to A3a | `createProcessLogger`, `startTelemetry` and `collectEnvironmentSecrets` take `EnvironmentVariables`; the media-worker entry point needs them to accept `MediaWorkerEnvironmentVariables` (or the fields they read), and its secrets (`ASSETS_S3_WORKER_SECRET_ACCESS_KEY`, `ASSETS_R2_SECRET_ACCESS_KEY`) must join the known secrets; the API's `ASSETS_S3_API_SECRET_ACCESS_KEY` joins them in A2a-2, when it is first used |

### R1 as built (migration `0093_assets_processing_error_budget`, baseline revision 103)

| Piece | As built |
|---|---|
| Defect | Architect review of staging `29ba81bf`, R1: `record_processing_outcome` (`0088`, `0089`) re-queued `retry_storage` without a cap but incremented the same `attempt_count` that `retry_processing` compared with 5, so four storage retries followed by the first processing error dead-lettered a possibly valid image (asset and original `failed`, upload `rejected`, reservation released). The stage table wrote the shared counter too, so the design itself codified the interaction |
| Decision | Two budgets (*Decided*, the architect's recommendation accepted by the owner). `attempt_count` stays the total of every retry, for metrics and backoff; a separate `processing_error_count` counts processing errors only and alone drives the dead letter: five processing errors, consecutive or not, dead-letter. A storage retry never counts towards it and never resets it: a reset on a class change would let interleaved storage retries erase real processing failures |
| Column | `assets.processing_jobs.processing_error_count integer NOT NULL DEFAULT 0` with `processing_jobs_processing_errors_chk` (`>= 0`), added by `0093` (expand only) and in the canonical table. Backfill (**a conservative approximation**): `attempt_count` for jobs whose `error_class` is `processing`, 0 for the rest. The true number of processing errors inside an old `attempt_count` is unknowable, because the job keeps only its latest error class. A job whose latest error was `processing` is backfilled to its whole total, so it never gets more processing attempts than the old rule allowed. A `pending` or `leased` job whose latest error was `storage` but whose earlier errors were `processing` is backfilled to 0, so those earlier processing errors are forgotten and it gets a fresh budget of five. The approximation is harmless: no worker has claimed a job in any environment yet (no Assets code exists), so every existing job has `attempt_count = 0` and the backfill writes 0 everywhere |
| `record_processing_outcome` | `CREATE OR REPLACE` with the same owner, `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp` and `ninaku_worker` grant; only the retry and dead-letter lines change. `retry_storage`: `attempt_count + 1`, processing count untouched, no cap. `retry_processing`: both counters `+ 1`; `dead_lettered` (both counters `+ 1`) when `processing_error_count + 1 >= 5`. `ready` and `failed_permanent` do not change; the archived-asset rule (the active slot is released only while the asset counts) does not change |
| Carried to A3c (`ASSETS_STAGE_SPECS.md` §14) | `requeue_dead_letters` does not exist yet; when A3c-1 writes it, a requeued dead letter resets **both** counters. A reclaimed expired lease, when A3c-1 counts it, counts as a processing error (both counters). The OB4 processing backoff step must follow processing errors, while the claim returns only the total |
| Proof | `database/tests/contracts/assets-processing-contracts.ts` (`NINAKU_ASSETS_PROCESSING_OK`), four new titles: `keeps a job pending at its first processing error after four storage retries` (`pending`, total 5, processing 1, asset still `processing`); `never resets the processing error count on a storage retry`; `dead-letters at the fifth processing error when storage retries are interleaved` (P S P S S P S P P: eight `pending`, then `dead_lettered` with total 9 and processing 5); `dead-letters after five accumulated processing errors, failing the asset and releasing its reservation once`. The A1c-1 title now seeds `processing_error_count` instead of `attempt_count` for its fourth and fifth error and for the archived dead letter. `assets-tables-contracts.ts` refuses a negative count with `processing_jobs_processing_errors_chk`. Observed RED on the column alone (old function): the first three new titles and the accumulated one fail (`dead_lettered` at the first processing error; processing count stays 0). Mutations that each fail a named title: a storage retry counted as a processing error; `attempt_count` compared with 5 again; the processing count reset on a storage retry; the dead letter not incrementing the processing count. `0093` applied by `db:prepare` on a revision 101 database holding `pending`, `leased`, `dead_lettered` and `completed` jobs of every error class leaves a schema (with owners and privileges) identical to a fresh revision 103 install; there, the migrated `processing` job with 3 attempts dead-letters at its fifth processing error and a migrated `storage` job with 4 attempts stays `pending` at its first processing error |

### A1b-3 as built (migration `0094_assets_archive_restore`, baseline revision 104)

| Piece | As built |
|---|---|
| Functions | `assets.archive_asset(uuid)` and `assets.restore_asset(uuid)`, both `RETURNS varchar`, `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`, `EXECUTE` revoked from `PUBLIC` and granted to `ninaku_runtime` only (migration and `runtime_grants.sql`; the canonical file only revokes). Both call `identity.assert_tenant_context()`, refuse a transaction outside READ COMMITTED with `assets_admission_isolation` (`0A000`, the runtime functions' name) and take the organization only from `identity.current_organization_id()`. No permission check in SQL: the A4a route authorizes first |
| Lock order | The usage row `FOR UPDATE` (`INTO STRICT`) first, then the asset `FOR UPDATE` filtered by the context organization, the order of every writer of §7 (the "Carried to A1b-3" row of A1c-2). The asset lock is the row `purge_asset_begin` (A4b) will lock, so a restore never races a purge |
| `archive_asset` | `not_found` (unknown or foreign); a `purging` asset is refused with `assets_status_transition` (`23514`, the name of the status guard trigger, mapped to `422 · assets.transition_invalid`); `already_archived` without writing; else sets `archived_at = now()` and decrements `active_asset_count` only when `assets.counts_as_active_asset` held before (a `failed` asset is not counted, so it is archived without a decrement); answers `archived` |
| `restore_asset` | `not_found`; `purging` refused with `assets_status_transition`; `not_archived` without writing; `failed` refused with `assets_status_transition`; archived more than 30 days ago (`archived_at < now() - 30 days`, the complement of the A4b purge rule) refused with `assets_restoration_expired` (mapped to `422 · assets.restoration_expired`); then, only when `assets.counts_as_active_asset(status, NULL)` holds for the asset once restored (the mirror of the archive check), `active_asset_count + 1` over `platform.effective_limit(organization, 'assets.active_assets')` is refused with `assets_asset_limit` (no row or unlimited is no ceiling, as `reserve_upload`) and the count is incremented; it clears `archived_at` and answers `restored`. Every refusal is `23514` with its named constraint, the rule of §5.2 of the stage specification |
| Rename | No function: AS5 is a plain `UPDATE (display_name)` under the tenant RLS policy, granted by A1a-1, and changes no counter; A4a builds it |
| Proof | `database/tests/contracts/assets-archive-contracts.ts` (`NINAKU_ASSETS_ARCHIVE_OK`, registered after the expiry script; a new script because the admission one is already 554 lines): the stage title `archives and restores through the definer functions only, keeping the active asset count, and refuses restoring past 30 days or over the asset limit` (pending, processing and failed assets; the count is compared with the counting predicate after every step; refused at 30 days and 1 minute, restored at 29 days and 23 hours; an archive then a restore of a processing asset leaves the count unchanged; a tenant at a limit of 1; at a count of 5,000 a restore is refused by the plan limit of 5,000 but admitted with an unlimited override and with no limit row for the organization), `answers another organization's asset as not found and takes the organization only from the tenant context` and the definer shape, grant and isolation title. `assets-lifecycle-contracts.ts`: `refuses archiving or restoring a purging asset and never lets its status leave purging`. `database/tests/concurrency/assets-archive-concurrency.ts` (`NINAKU_ASSETS_ARCHIVE_CONCURRENCY_OK`): an archive waiting on the usage row while the expiry sweep holds it and then locks the same asset finishes with no deadlock and uncounts the asset once; a restoration waiting while a reservation takes the last slot is refused with `assets_asset_limit`; an archive waiting on a reconciliation uncounts from the reconciled count. Mutations that each fail a test: the usage row lock removed (archive: deadlock with the sweep; restore: admitted over the limit), the asset locked before the usage row (deadlock), the organization read from the asset row instead of the context (both functions), the counting check skipped (a failed asset uncounted), the 30-day check skipped, the limit check skipped (also caught by the plan limit of 5,000), the purging check removed (both functions), `EXECUTE` granted to `ninaku_worker` or to `PUBLIC` in `runtime_grants.sql`. Removing the counting gate of the restore (on the increment, or on both the limit check and the increment) survives as an equivalent mutant: the restore reaches the gate only for `pending`, `processing` or `ready` (`assets_status_chk` allows five statuses and `failed` and `purging` are refused before it), and the predicate holds for all three; the gate keeps the restore the exact mirror of the archive if a status is ever added. The expiry race backdates its upload 50 years, so the sweep's oldest-first batch of 500 always includes it whatever other expired uploads exist (proven with 600 leftover expired uploads). `0094` applied by `db:prepare` on a revision 103 database holding assets in every status, archived or not, leaves a schema (with owners and privileges) identical to a fresh revision 104 install |

### A2a-1b as built (module skeleton, domain and public failures, no migration, no route)

| Piece | As built |
|---|---|
| Module | `src/modules/assets/assets.module.ts` (`AssetsModule`), imported by `AppModule`; it imports `DatabaseModule` and the `audit`, `identity` and `platform` contract modules only (R1) and has no provider or controller yet, so `docs/generated/openapi.json` does not change. No `contracts/` folder yet: no module consumes Assets until A5 |
| Delivery token | `domain/delivery-token.ts`: `generateDeliveryToken()` encodes 16 bytes of `node:crypto` `randomBytes` with the shared `encodeBase32Unpadded` (`foundation/otp/base32.ts`) in lowercase, 26 characters; `isDeliveryToken(value)` accepts only `^[a-z2-7]{26}$`, the shape of `assets_delivery_token_chk` and `uploads_staging_key_chk`. The staging (upload) token has the same shape and generator |
| Object keys | `domain/object-key.ts`: `stagingKey` → `in/<token>`, `masterKey` → `m/<token>/original.webp`, `publicRenditionKey` → `a/<token>/p<profile>/<name>.webp`, `privateRenditionKey` → `p/<token>/p<profile>/<name>.webp`; profile and name are typed fixed codes, and every builder refuses a value that is not a generated token with `ObjectKeyTokenInvalidError`, whose message never carries the refused value |
| Rendition profile | `domain/rendition-profile.ts`: `RENDITION_PROFILE.ONE`, `RENDITION_NAME` (`thumbnail`, `tile`, `card`, `detail`), `variantCode(profile, name)` (`p1-tile`), `renditionName(code)` (`undefined` for `original` or any unknown code), `profileRenditionCodes(1)` in the order of `assets.profile_rendition_codes(1)`, and `PROFILE_ONE_GEOMETRY` (§5.5: 160 and 320 cover, 640 and 1,280 inside, quality 80, 80, 80, 82) |
| Status | `domain/asset-status.ts`: `ASSET_STATUS` and `canTransition(from, to)`, the exact pairs of `assets.guard_asset_status()` (same status allowed, `purging` terminal), plus `isArchivable` (not `purging`) and `isRestorable` (not `purging`, not `failed`), the early refusals of `archive_asset` and `restore_asset` |
| Media type | `domain/media-type.ts`: `ACCEPTED_IMAGE_MEDIA_TYPE` (`image/jpeg`, `image/png`, `image/webp`, `image/avif`), `isAcceptedImageMediaType` (exact, case-sensitive) and `MAX_UPLOAD_BYTES = 10_485_760` |
| Failures | `presentation/failures/assets-failures.ts` (`ASSETS_FAILURE`): the module's public codes, as Catalog keeps its own (`catalog-failures.ts`; the Foundation error registry holds only Foundation codes): `403 · assets.action_forbidden`, `404 · assets.not_found`, `503 · assets.storage_unavailable` (retryable) and, all `422`, `assets.asset_refused`, `asset_limit_reached`, `storage_limit_reached`, `too_many_open_uploads`, `access_class_unavailable`, `media_type_unsupported`, `file_too_large`, `upload_expired`, `upload_rejected`, `transition_invalid`, `restoration_expired`, `ai_generation_limit_reached`. `to-public-assets-failure.ts` maps a check violation on `assets_asset_limit`, `assets_storage_limit`, `assets_open_uploads_limit`, `assets_access_class_unavailable`, `assets_status_transition` and `assets_restoration_expired` to its code, any other check, unique, foreign key or exclusion violation to `assets.asset_refused` (the §7 backstop), and leaves everything else (transient failures, `0A000` isolation refusals, non-database errors) to the shared filters; `AssetsFailureInterceptor` applies it, as `CatalogFailureInterceptor` does |
| Proof | The four stage titles in `delivery-token.spec.ts`, `object-key.spec.ts`, `rendition-profile.spec.ts` and `to-public-assets-failure.spec.ts`, plus the geometry title, `asset-status.spec.ts`, `media-type.spec.ts` and `assets-failure.interceptor.spec.ts`. Mutations that each fail a named title: 15 random bytes (the token and the key titles); the token check of the key builders removed, so a display name becomes a key (the key title); `assets_restoration_expired` dropped from the mapping (the failure title) |
| Carried to A2b | The constraint mapping sits in `presentation/failures/`, as this stage specifies; today every other module turns a constraint into an application error in `infrastructure/` and maps only error classes in `presentation/`. When A2b adds the first repository, decide whether the constraint table moves to an infrastructure mapper with an `AssetsRefusedError`, as Catalog and Identity do |

### A2a-2 as built (storage adapters and the storage contract, no migration, no route)

| Piece | As built |
|---|---|
| Dependencies | `@aws-sdk/client-s3` and `@aws-sdk/s3-presigned-post` `^3.1135.0`, the version of `@aws-sdk/client-sesv2`, so the shared `@aws-sdk/core` and `@smithy/*` packages stay one copy; `npm audit --omit=dev --audit-level=high` finds 0 vulnerabilities before and after |
| Ports | `application/ports/upload-policy-signer.port.ts`: `signUploadPolicy({ key, mediaType, maxBytes, expiresInSeconds }) → { url, fields, expiresAt }` and `UPLOAD_POLICY_LIFETIME_SECONDS = 600`. `application/ports/object-store.port.ts`: `STORAGE_PROVIDER` (`s3-private`, `r2-public`, the codes seeded in `assets.storage_providers`), `head → { byteSize } \| null`, `readAtMost → Buffer \| null` (a missing object is absent here too, so a key removed between `HEAD` and `GET` is not a storage failure), `put` and `remove`, and `StoredObjectTooLargeError`. The Nest injection tokens join with the first provider that binds them (A2b), so no unused export exists today |
| Factory | `infrastructure/storage/object-storage.factory.ts`: `createS3StorageBucket` (the API's signing client and the media worker's S3 client) and `createObjectStorageBuckets` (one client per provider code). Every client receives **explicit** `credentials` from its own settings, never the default chain (UC §13.1, R18). The R2 client uses `https://<ASSETS_R2_ACCOUNT_ID>.r2.cloudflarestorage.com`, region `auto` and `requestChecksumCalculation` and `responseChecksumValidation` `WHEN_REQUIRED` (S14). Each provider's settings carry `endpoint` (absent: the provider's own endpoint) and `forcePathStyle` (off for real S3 and R2), which is how a local S3-compatible server stands in for both. No SDK logger is configured, so the clients log nothing |
| Signer | `infrastructure/storage/s3-upload-policy-signer.ts`: `createPresignedPost` with `Conditions: [['content-length-range', 1, maxBytes], ['eq', '$Content-Type', mediaType], ['eq', '$key', key]]`, `Fields: { 'Content-Type': mediaType }` and `Expires` from the request (the use case passes 600). Signing is local, with no network call. The SDK also adds its own exact `{"key": key}` and `{"Content-Type": mediaType}` conditions from the fields. Policy fields are never logged; if they ever reach a log, `Policy`, `X-Amz-Signature` and `X-Amz-Credential` are masked by the A2a-1a redaction |
| Object store | `infrastructure/storage/s3-object-store.ts`: `HeadObject` (`ContentLength`), `GetObject` streamed through a byte counter that stops at the first byte past the cap and throws `StoredObjectTooLargeError` (leaving the loop destroys the body stream), `PutObject` with `ContentLength`, `ContentType` and `CacheControl`, and `DeleteObject`. Only a missing **key** is absent (`null`, or done for `remove`). On `GetObject` and `DeleteObject` that is the `NoSuchKey` error code. A `HeadObject` answer has no body, so the SDK names every `404` `NotFound`, whether the key or the bucket is missing. MinIO tells the two apart only in its own `x-minio-error-code` header, which S3 does not send. So a `NotFound` HEAD is confirmed with one `HeadBucket` (the worker's §13.4 `s3:ListBucket` grant): the bucket answers, so the key is absent; otherwise the failure surfaces. Every other failure, including `NoSuchBucket`, `AccessDenied`, a bare `404` without those names, a body stream error and a HEAD without a content length, becomes `ObjectStorageFailedError` (port, original error as `cause`, a fixed message), the storage failure A2b maps to `503 · assets.storage_unavailable`. `StoredObjectTooLargeError` stays as it is. **No `403` mapping**: the local server answered `404` for a missing `in/` key, and staging has not been measured yet (open item below). Review fix after the first approval: the original rule treated any `404` as absent, so a missing or deleted bucket made `readAtMost` answer `null` and `remove` succeed |
| Contract script | `tooling/assets/check-storage-contract.ts` (`npm run assets:storage-contract`, not part of `verify`) with `tooling/assets/storage-contract-environment.ts`. It reads the ten storage variables of the API and media-worker groups (`ASSETS_S3_REGION`, `ASSETS_S3_BUCKET`, `ASSETS_S3_API_ACCESS_KEY_ID`, `ASSETS_S3_API_SECRET_ACCESS_KEY`, `ASSETS_S3_WORKER_ACCESS_KEY_ID`, `ASSETS_S3_WORKER_SECRET_ACCESS_KEY`, `ASSETS_R2_ACCOUNT_ID`, `ASSETS_R2_PUBLIC_BUCKET`, `ASSETS_R2_ACCESS_KEY_ID`, `ASSETS_R2_SECRET_ACCESS_KEY`), plus the optional `ASSETS_STORAGE_CONTRACT_S3_ENDPOINT` and `ASSETS_STORAGE_CONTRACT_R2_ENDPOINT` (a set endpoint also turns on path-style addressing). When a variable is missing it names the variable and never prints a value. It signs with the API key and reads, writes and deletes with the worker keys. It uses a declared size of 65,536 bytes, because the bound is the same mechanism at any size. It prints one `PASS` or `FAIL` line per expectation, with only the status and the S3 error code, never a URL, field, policy or body. It removes every object it may have created, even after a failure, and ends with `NINAKU_ASSETS_STORAGE_CONTRACT_OK` or exit code 1. An expired-policy step was added to the specified steps. **Real-provider options (2026-10-10):** `ASSETS_STORAGE_CONTRACT_KEY_PREFIX` is put in front of every key the script signs, writes, reads or deletes (`<prefix>/in/...`, `<prefix>/m/...`, `<prefix>/a/...`; a missing closing `/` is added, a leading `/` or a `..` segment is refused), and the script refuses to start without it unless every endpoint is an override on `localhost`, `127.0.0.1` or `[::1]` (`storage-contract-environment.ts`). With none of the four R2 variables it runs **S3 only** (Plan B, §8 of the stage specs): one S3 client and bucket stand for both providers, the R2 round trip prints `SKIPPED (no R2 credentials)` and an `a/` rendition round trip runs on the S3 bucket instead; with only some of them it names the missing ones. A missing-key answer (the missing-key `HEAD` and `GET`, and the `HEAD` after removal) that is a `403` prints `LIMITED by IAM (no ListBucket)` with what AWS answered, never `PASS` or `FAIL`; every other step keeps its strict expectation. It first deletes three never-written keys (`in/`, `m/`, `a/`) with the worker key and, when one is refused, stops before writing anything; it also stops after the first `POST` when that answers `403`. After cleanup it prints one `CLEANUP` line per tracked key with the delete outcome and what a `GET` answers afterwards. A run with a skipped or limited step and no failure ends with `NINAKU_ASSETS_STORAGE_CONTRACT_PARTIAL: <n> passed, 0 failed, <n> skipped, <n> limited` and exit code 0 (`storage-contract-report.ts`, `storage-contract-requests.ts`). Proof: `storage-contract-environment.spec.ts` and `storage-contract-report.spec.ts` (11 titles, no network), observed RED before the helpers existed |
| Real S3 run (legacy bucket, 2026-10-10) | One run, owner-authorized, against the **legacy production bucket of another product** (`ninaku-assets-prod`, `us-east-1`) with the legacy IAM user `ninaku-s3-assets` for both the API and the worker keys, prefix `ninaku-new-module-contract-test/run-20261010-4745922c/`, no R2 credentials, before the cleanup preflight existed. That user is known not to be allowed `ListAllMyBuckets`, `HeadBucket`, `ListObjectsV2` or IAM reads. Per expectation: `POST exactly the declared size` **PASS** `204` · `POST one byte over` **PASS** `400 EntityTooLarge` · `POST with another Content-Type` **PASS** `403 AccessDenied` · `POST to another key` **PASS** `403 AccessDenied` · `POST with an expired policy` **PASS** `403 AccessDenied` · `HEAD of the uploaded staging object` **FAIL** `403` (SDK error name `Unknown`) · `GET capped one byte below` **FAIL** `403 AccessDenied` · `HEAD of a missing staging key` **LIMITED** `403` (`Unknown`) · `GET of a missing key` **LIMITED** `403 AccessDenied` · `HEAD in a bucket that does not exist` **PASS** `ObjectStorageFailedError` · `put, read and remove on s3-private` **FAIL**: `PutObject` succeeded, the `HEAD` after it answered `403` · `put, read and remove of an a/ rendition (Plan B)` **FAIL**, same answers · both `HEAD after removal` **LIMITED** `403` · `put, read and remove on r2-public` **SKIPPED** (no R2 credentials) · summary `4 storage contract expectation(s) failed`. **Finding:** this user may `PutObject` (presigned `POST` and SDK) under the prefix but not `GetObject`, `HeadObject` or `DeleteObject`, so the bound of the upload policy is proven on real S3 (the four refusals and the exact-size `204` are AWS's answers, matching MinIO), but reads, deletes and the missing-key `404` are not. **Missing key: AWS answered `403`, not `404`**, and an existing key answered the same `403`, so with this user a `403` cannot tell absent from refused. **Left behind (cleanup refused, `DeleteObject` `403 AccessDenied`):** three objects under the prefix, `in/dh5bvxiqbngwg3mbmr7rbhr3le` (65,536 bytes), `m/dr7ysjrn7bneelon5owyrnj5xq/original.webp` (4,096 bytes) and `a/iddsclraqzjru4tt27efkgwspu/p1/tile.webp` (4,096 bytes); the other two tracked `in/` keys were refused by S3 and never written. A `GET` of each afterwards answered `403 AccessDenied`, which proves nothing about deletion. The cleanup preflight was added after this run so the script now stops before writing when the worker key cannot delete |
| Adapter consequence (open, owner) | Measured with the legacy user, production code unchanged. `HeadObject` refused answers a bodiless `403`, which the SDK names `Unknown`: not `NoSuchKey` and not `NotFound`, so `headOutput` never reaches its `HeadBucket` confirmation and throws `ObjectStorageFailedError` (cause status `403`); `readAtMost` does the same on `AccessDenied`, and `remove` cannot clear anything. In the worker, `verifyAndMaster` then fails every job with `ObjectStorageFailedError`, which `failure-classifier.ts` classifies as `RETRY_PROCESSING` · `STORAGE_REFUSED` (a `403` is not transient) with the processing backoff, never as `StagingObjectMissingError`. That is **safe** (it fails closed: nothing is treated as absent, no upload is expired as missing, no object is deleted by mistake) but **not functional**: no asset would ever be processed and purge and the expiry sweep could delete nothing. A user with `s3:GetObject` but without `s3:ListBucket` would process uploads that arrived, while an upload that never arrived would be retried as `STORAGE_REFUSED` instead of waiting for its expiry as `upload_missing`. Mapping `403` to `null` would be **unsafe**: this run shows an existing object answering the same `403`. Decision for the owner: keep the adapter as is and grant the worker `s3:ListBucket` on the new bucket (runbook §4.4), so a missing key answers `404` and `HeadBucket` confirms it |
| Local run (2026-10-08) | MinIO on `127.0.0.1:9100`, two fresh buckets (`ninaku-test-private-<random>` for S3, `ninaku-test-public-<random>` standing in for R2), both endpoints overridden, one MinIO user for the three principals; the buckets were empty after the run and were deleted. Transcript: `PASS POST exactly the declared size: 204` · `PASS POST one byte over the declared size: 400 EntityTooLarge` · `PASS POST with another Content-Type: 403 AccessDenied` · `PASS POST to another key with the same policy: 403 AccessDenied` · `PASS HEAD of the uploaded staging object with the worker key: 65536 bytes` · `PASS GET of the staging object capped one byte below its size: StoredObjectTooLargeError` · `PASS POST with an expired policy: 403 AccessDenied` · `PASS HEAD of a missing staging key with the worker key: absent (404)` · `PASS GET of a missing key: absent` · `PASS HEAD in a bucket that does not exist: ObjectStorageFailedError` · `PASS put, read and remove on s3-private: head 4096, read true, after removal null` · `PASS put, read and remove on r2-public: head 4096, read true, after removal null` · `NINAKU_ASSETS_STORAGE_CONTRACT_OK` |
| Proof | The four stage titles in `object-storage.factory.spec.ts`, `s3-upload-policy-signer.spec.ts` and `s3-object-store.spec.ts`. Three more titles: the endpoint override with path-style addressing, the stored size and any other refusal rethrown, and put and remove going to the named provider's bucket. Observed RED: all three spec files failed while the adapters did not exist. Mutations: removing `content-length-range` fails the signer title, and the contract fails `POST one byte over` (`204`) and the staging `HEAD` (65,537 bytes). `starts-with` for the key fails the signer title, but the contract still passes, because the SDK's own exact `{"key": key}` condition still binds the key at the storage. Without `credentials` in the factory, the credential title fails: every client resolves the stubbed SES `AWS_ACCESS_KEY_ID`. A cap of `maxBytes + 1` fails the read title. Review fix: `surfaces a missing bucket and an unreadable bucket as storage failures and only treats a missing key as absent` was RED against the any-`404` rule. Treating `NoSuchBucket` as absent fails it, and so does dropping the `HeadBucket` confirmation |
| Open items (only real S3 and R2 can prove them) | (1) The `HEAD` of a missing `in/` key with the staging worker key: `404` (the §13.4 `s3:ListBucket` grant) or `403`. The adapter maps `403` to `null` only if staging answers `403`. Partly answered on 2026-10-10 (row "Real S3 run"): without `s3:ListBucket` AWS answers `403`, and an existing key answers the same `403` when `GetObject` is missing, so no `403` mapping; the `404` with the grant is still to be seen on the new bucket. Also open: the three objects left under `ninaku-new-module-contract-test/run-20261010-4745922c/` in the legacy bucket, to be deleted by a principal that may (the owner), and the legacy credentials, to be rotated. (2) R2 accepts the client as configured: account endpoint, region `auto`, checksums only when required (S14). MinIO proves only the S3 API shape. (3) The IAM prefix policies of §13.4: the API key can sign but not read, the worker key can read and delete `in/`. MinIO ran with one user. The worker S3 key and the R2 token must also be allowed `HeadBucket` (`s3:ListBucket`), or a missing key surfaces as a storage failure. (4) The real S3 error codes and statuses for the refused POSTs. The local server matches the documented ones. Command for the owner, with the staging values in an untracked file outside the repository: `set -a; . /path/to/assets-staging.env; set +a; npm run assets:storage-contract`. The file sets the ten variables above and **no** `ASSETS_STORAGE_CONTRACT_*_ENDPOINT`, so the real S3 and R2 endpoints are used |
| Carried to A2b | `ASSETS_S3_API_SECRET_ACCESS_KEY` joins `collectEnvironmentSecrets` (`src/foundation/logging/redaction/environment-secrets.ts`) when A2b wires the signer into the API process. The worker secrets join in A3a. A2b also maps a storage failure of the signer or the store to `503 · assets.storage_unavailable` |

### A2b as built (request an upload and read an asset, no migration)

| Piece | As built |
|---|---|
| Routes | `POST /api/v1/assets/uploads` (`UploadsController`) and `GET /api/v1/assets/{assetId}` (`AssetsLibraryController`), both under `AssetsController` (session and tenant guards, `AssetsFailureInterceptor`, OpenAPI tag `Assets`). The intent is `@Throttle` 30 a minute (`UPLOAD_INTENT_RATE_LIMIT` in `presentation/controllers/asset-route-documentation.ts`, because `foundation/http/rate-limiting` was outside the stage), answers `Cache-Control: no-store` through `HttpIdempotentResponse` (`Idempotent-Replayed: true` on a replay). The read answers a strong tag of the body (`computeContentEntityTag`) with `private, no-cache` and `304` on a match |
| Request | Strict Zod (`presentation/dtos/upload-request.schemas.ts`): `mediaType` a `type/subtype` of at most 255 characters, `byteSize` a positive safe integer, `accessClass` one of the three classes, optional trimmed `displayName` and `originalFilename` of at most 255 (`ASSET_TEXT_MAX_LENGTH`, `domain/access-class.ts`). The domain refusals (`acceptedMediaTypeOf`, `application/uploads/upload-intent.ts`) answer `422` before any transaction, then the configuration check answers `503 · assets.storage_unavailable` before authorizing |
| Use case | `RequestUploadUseCase`: in one tenant transaction `AssetsAccess.admitUpload` (`OperationAuthorizationContract`, `assets.media.manage` and `assets.media`, a refusal audited as denied on `organization.organization` and returned so the audit commits, as Catalog does), then `reserve_upload` through `resolveIdempotentClaim` (so a lock timeout answers `409 · idempotency.in_progress`), then on `created` the `assets.asset.upload_requested` audit with the after attributes `assets.asset.media_type`, `assets.asset.byte_size` and `assets.asset.access_class` (the dotted attribute naming of the other modules). On `existing` it reads the asset (the replay's eighth statement) and compares the stored request hash (`requestFingerprint` of the five fields, absent names as `null`): another hash is `409 · idempotency.key_reused`. After the commit it signs a policy of `min(600 s, time left on the upload)` seconds, or answers `upload: null` when the upload is no longer `issued` or has less than one second left |
| Created answer | `reserve_upload` does not return `created_at`, so the reserve statement also selects `now()`: the transaction timestamp, which is exactly the `created_at` default of the asset row. The other fields of the created asset are known (`pending`, `image`, `upload`), so the created path stays at 8 statements |
| Read | `ReadAssetUseCase`: `PermissionCheckContract` with `assets.media.view` organization-wide (a refusal is `403 · assets.action_forbidden`, not audited, like Catalog's reads), then one statement reading the asset `LEFT JOIN` its `original` variant (width, height, byte size). `AssetViews` builds the four `a/<token>/p1/<name>.webp` addresses under `ASSETS_PUBLIC_BASE_URL` only for a `ready`, `public`, profile 1 asset; without the storage group there are no addresses. `aiGenerated` is `sourceKind = ai_generated` |
| Wiring | `ASSETS_STORAGE_PORT` (`application/ports/assets-storage.port.ts`) holds `{ configured: { signer, publicBaseUrl } \| undefined }`, built by `ASSETS_STORAGE_PROVIDER` from `AppConfigService.assetsStorageSettings()` with `createS3StorageBucket` (explicit API credentials, real endpoint). `ASSET_REPOSITORY_PORT` is `AssetRepository` (`infrastructure/assets/`). The API binds no object store: it never reads, writes or deletes objects (AS2, §13.4: the API key can only sign). `ASSETS_S3_API_SECRET_ACCESS_KEY` joined `collectEnvironmentSecrets` and `KnownSecretsRegistryService` |
| Failures | The application errors (`application/assets-errors.ts`) map in `to-public-assets-failure.ts`; an `OperationRefusedError` other than the capability one is the plain denial. A storage failure (`AssetsStorageUnconfiguredError`, `ObjectStorageFailedError`, which the signer now raises for any signing failure) becomes a `ServiceUnavailableException` carrying the code `assets.storage_unavailable` (decision accepted by the owner's coordinator). Foundation masks every `PublicFailure` of status 5xx: `buildPublicFailureProblem` answers `foundation.internal_error` for it, because a server-side failure is not meant to be actionable, so the `ASSETS_FAILURE.STORAGE_UNAVAILABLE` entry alone would never reach the client. The `HttpException` route is the one Foundation already opens for a module code: `buildHttpExceptionProblem` takes the `code` of the exception payload over the registry entry of its status (`service.unavailable`), while still hiding any 5xx detail. The answer is `503`, code `assets.storage_unavailable`, retryable, with the registry's title and default detail, and the cause stays out of the answer. Publishing retryable 5xx public failures in Foundation would be the alternative, a change outside Assets. The constraint table stays in `presentation/failures/` (the A2a-1b open point): `reserve_upload` raises named check violations and no repository needs its own refusal class |
| Proof | `test/e2e/assets-uploads.e2e-spec.ts`, `assets-access.e2e-spec.ts` and `assets-library.e2e-spec.ts` with the stage titles, one more e2e title for the `429` and the unit specs of the use case, the view, the signer failure, the storage provider and the failure mapping; `statement-budget.e2e-spec.ts` rows: 8 for an intent and for its replay, 7 for a read. The e2e use the real signer with fixed credentials (no network) through an override of `ASSETS_STORAGE_PORT`; a ready or failed asset is written in SQL with `session_replication_role = replica`, since the worker that produces it is A3. Mutations that each fail a named title: the hash comparison skipped (the replay title and the reuse unit title), addresses for a pending asset (the pending/processing/failed title and the view unit titles), the `@Throttle` removed (the `429` title) |
| OpenAPI | Three operations added under the `Assets` tag (`tooling/openapi/openapi-document.ts`); `npm run openapi:compat` against `origin/staging`: no breaking change, three `path_added` |
| Route classification | `tooling/performance/http/route-classification-assets.ts` classifies the three operations as `admin`, unmeasured (administrative media management, D24), registered in `route-classification.ts`; the typed table counts 157 operations (14 hot, 40 frequent, 101 admin, 2 not measurable, 41 measured), noted in `PERFORMANCE_BASELINE.md` §11.1 |

### A2c as built (complete an upload, no migration)

| Piece | As built |
|---|---|
| Route | `POST /api/v1/assets/uploads/{uploadId}/completion`, no body, `202` with the AS3 body and `Cache-Control: private, no-store` |
| Use case | `CompleteUploadUseCase`: `PermissionCheckContract` with `assets.media.manage` organization-wide (never the plan), `complete_upload`, then the asset read through `AssetViews`; `not_found` is `404 · assets.not_found` (an upload of another organization answers the same), `expired` is `422 · assets.upload_expired`, `rejected` is `422 · assets.upload_rejected`, `completed` and `already_received` answer the asset. No audit and no storage call: the specification and UC AS2 keep every external call out of the request, so the `HEAD` of the staging object stays with the media worker (A3) |
| Proof | The three e2e titles of the stage, `complete-upload.use-case.spec.ts` › `never calls the object storage while completing an upload` (the use case is built by Nest with an object store spy bound to `OBJECT_STORE_PORT`, so a use case that injects and calls it fails), and the statement row: 8. Mutations that each fail a named title: a `head` call in the use case (the storage title) and an expired upload treated as completed (the expired/rejected title) |

### A3a as built (the media-worker process, no migration)

| Piece | As built |
|---|---|
| Process | `src/media-worker.ts` validates the environment with the `media-worker` process role, starts the process logger and telemetry like `src/worker.ts` and boots `createMediaWorkerApplication()` (`src/bootstrap/create-media-worker-application.ts`), a Nest application context with no HTTP server, so no route and no health endpoint, exactly like the OTP worker. Graceful shutdown is the shared `registerGracefulShutdown`. Scripts `start:media-worker` and `start:media-worker:prod` (`node dist/media-worker`); `knip.json` and `check-main-imports.ts` list the entry with its root module |
| Modules | `src/worker/media-worker.module.ts` (`MediaWorkerModule`): `MediaWorkerConfigModule`, `WorkerDatabaseModule`, `LoggingModule`, `ObservabilityModule`, `ScheduleModule.forRoot()`, `AssetsWorkerModule`, and no messaging or Identity module. `src/foundation/config/media-worker-config.module.ts` validates with the `media-worker` role and provides `AppConfigService` and `MediaWorkerSettingsService` (`storage()`, the media-worker storage group or `undefined`, and `processing`). `src/modules/assets/assets-worker.module.ts` (`AssetsWorkerModule`) holds the processing providers only; `AssetsModule` is unchanged |
| Environment | `ASSETS_IDLE_POLL_MS` (default 2,000, 500–60,000) and `ASSETS_IMAGE_LEASE_SECONDS` (default 120, 30–900), read by the `media-worker` role only. The worker's two storage secrets join the known secrets: `collectEnvironmentSecrets` accepts either role's environment, and `KnownSecretsRegistryService` reads `MediaWorkerSettingsService` when it is present |
| Storage | Without the media-worker storage group the process boots and stays up (the OTP worker's rule for an unconfigured channel: warn and keep running, not crash-loop), logs `assets.media_worker.storage_unconfigured` once, and **starts neither the claim loop nor the expiry sweep** (`MEDIA_WORKER_STORAGE_CONFIGURED`), so no job is claimed and no upload is expired without a way to delete its staging object; only the nightly usage reconciliation, which needs no storage, runs. With the group, `createMediaWorkerObjectStore` builds the A2a-2 store from it |
| Repository | `infrastructure/processing/processing-jobs.repository.ts` calls the four A1c functions through `DatabasePoolService`: the claim and the record are one autocommit statement each, so no transaction is open while a handler runs. A record refused with `assets_job_lease_lost` answers `lease_lost`. `expire_uploads` and `reconcile_usage_batch` run in a short READ COMMITTED transaction that first takes `pg_try_advisory_xact_lock(hashtextextended('assets.expire_uploads', 0))`; the two use the same key, so the sweep and the reconciliation never overlap (A1c-2 lock-order requirement), and a busy lock answers "not acquired" without waiting |
| Image loop | `application/processing/image-job-loop.ts` claims one job with a fresh lease token, only of the image kinds that have a registered handler (`process_upload` since A3b-2), runs the handler outside any transaction, records its outcome with the same token and then calls the handler's `afterRecord` with the recorded result. A thrown failure is classified (`processing-failure.ts`): a permanent refusal → `failed_permanent` with its code; a storage failure → `retry_storage` (`assets.storage_unavailable`); anything else → `retry_processing` (`assets.processing_error`). Retries get `available_at` from `processing-retry-delay.ts`: storage 5 s doubling to 15 min, processing 5 s, 30 s, 2 min, 10 min indexed by the claimed total `attempt_count` (A3c re-indexes it by processing errors). `infrastructure/worker/image-job-loop.runner.ts` polls again at once after a job and every `ASSETS_IDLE_POLL_MS` when idle; on shutdown it stops claiming and waits, at most one lease, for the job in flight to record before the pool closes |
| Sweeps | `ExpireUploadsJob` (every 5 minutes): `expire_uploads(500)` under the lock, then each returned staging object is removed with up to three attempts (a missing object is done); objects still undeleted are counted and left to the `in/` lifecycle rule. A failing batch is reported, the usage counters are reconciled and the batch is tried once more; a second failure logs `assets.expire_uploads.repeated_failure` (error). `ReconcileUsageJob` runs daily at 03:00 UTC, 500 organizations a batch, and retries a minute later while the sweep holds the lock |
| Sweep guards (review) | `ExpireAbandonedUploadsUseCase` deletes each returned staging object with up to three attempts, pausing 200 ms and 400 ms; an unknown staging provider is counted undeleted without a storage call. **Orphan:** the expiry commits first, so when every delete fails (or the worker stops between the commit and the delete) the staging object stays and nothing returns its key again; the `in/` lifecycle rule of the runbook (§3.4, one day) is the backstop. `ReconcileOrganizationUsageUseCase` stops with `lock_busy` (retried a minute later), `stalled` when a batch's last organization does not move past the previous one (compared as lower-case uuid text, which orders like PostgreSQL's uuid byte order used by `reconcile_usage_batch`'s `organization_id > p_after`), or `batch_cap_reached` after 10,000 batches; the job logs the last two as `assets.reconcile_usage.incomplete` (error). The loop runner re-checks the stop after every iteration and waits idle with `abortableSleep` on one `AbortController` signal, which removes its listener when the timer fires, so a shutdown during an iteration that found no job or failed returns at once and 10,000 idle waits leave no listener behind. The storage state reaches the runner and the expiry job as a typed `MediaWorkerStorageState` object (`MEDIA_WORKER_STORAGE_STATE`), and every dependency, the logger included, is injected by an explicit token |
| Placement | The stage table's paths moved to this branch's edit surface with the same content: `src/media-worker/media-worker.module.ts` is `src/worker/media-worker.module.ts`, `assets-processing.module.ts` is `assets-worker.module.ts`, and the two jobs live in `infrastructure/worker/` (not `presentation/jobs/`) with their logic in `application/processing/` use cases |
| Telemetry | `startTelemetry` now takes `ProcessEnvironmentVariables` (the "Carried to A2a-1a" row), so the media worker starts traces and metrics like the other processes. A boot of `node dist/media-worker` against a local database passed the role guard, claimed and recorded jobs and exited 0 on `SIGTERM` |
| Not built (needs SQL) | Isolating a poison upload "by id for the next run" (A1c-2 requirement) needs `expire_uploads` to skip ids or catch per row; A3a only reconciles, retries once and alerts |
| Proof | `image-job-loop.spec.ts` (the two stage titles, plus "claims nothing while no handler is registered" and the failure classes), `expire-uploads.job.spec.ts` (the stage title, `deletes the staging object of an expired upload and ignores a missing one`, and the reconcile-and-retry title), `reconcile-usage.job.spec.ts`, `image-job-loop.runner.spec.ts`, `processing-jobs.repository.integration-spec.ts` (`claims, records and expires through the worker role only`; `never runs the expiry sweep and the reconciliation at the same time`), `create-media-worker-application.spec.ts` (both stage titles), `media-worker-processing-variables.spec.ts`, `media-worker-settings.service.spec.ts`. Mutations that each fail a named title: recording before the handler runs, claiming every image kind instead of the registered ones, dropping the advisory lock |
| Review proof | `expire-abandoned-uploads.use-case.spec.ts` (three attempts on fake timers, the orphan, the unknown provider, the lock not acquired), `reconcile-organization-usage.use-case.spec.ts` (multi-batch walk, lock lost mid-run, a non-advancing cursor, the batch cap), new titles in `image-job-loop.runner.spec.ts` (stop during an idle or failed iteration, no loop without storage), `expire-uploads.job.spec.ts` (no sweep without storage), `reconcile-usage.job.spec.ts` (incomplete runs logged), and `assets-worker.module.spec.ts`, which compiles the real `AssetsWorkerModule` graph (only the pool, configuration and logger replaced) and the `MediaWorkerModule` root with its real configuration, logging and observability modules. Mutations that each fail a named title: the stop signal removed from the idle wait, the storage gate removed, the cursor guard removed, `ProcessUploadHandler` dropped from the providers |

### A3b-1 as built (verification and the master, no migration)

| Piece | As built |
|---|---|
| Dependency | `sharp` `^0.35.5` (libvips 8.18); `npm audit --omit=dev --audit-level=high` finds 0 vulnerabilities |
| Domain | `domain/media-signature.ts` (`mediaTypeBySignature`: JPEG `FF D8 FF`, PNG's eight bytes, `RIFF`…`WEBP`, and an `ftyp` box at offset 4 whose major or compatible brand is `avif` or `avis`, read only inside the declared box size); `domain/image-limits.ts` (pixels per accepted media type: 40,000,000 for JPEG, PNG and WebP and 12,000,000 for AVIF, through `maxInputPixelsOf`; 12,000 and 64 px sides, master 4,096 px at WebP q90) |
| Codec | `application/ports/image-codec.port.ts` and `infrastructure/processing/sharp-image-codec.ts`: `sharp.concurrency(1)`, `sharp.cache(false)`; `probe` reads the header only (`metadata()`, `failOn: 'warning'`, no pixel limit so an oversized image is reported as too large, not as corrupt) and maps `heif` with `av1` compression to AVIF; `encodeMaster` reads the header again and decodes with `limitInputPixels` set to the limit of the format the decoder detected (12,000,000 for AVIF, 40,000,000 otherwise; a format it does not accept is `ImageDecodeFailedError`), `failOn: 'warning'`, `autoOrient: true`, `animated: false`, `unlimited: false`, fits inside 4,096 without enlargement and encodes WebP q90 with alpha kept and no metadata (sRGB). A failure of `probe` or `encodeMaster` is `ImageDecodeFailedError` (permanent, `assets.image_corrupt`) when its message names a limit or the image, and `ImageProcessingFailedError` (retried as `assets.processing_error`) when it is a resource exhaustion (out of memory, thread creation, a non-`Error` rejection: `isResourceFailure`); every failure of `encodeRendition` is `ImageProcessingFailedError`, because the master is the service's own output (`inputFailure` and `processingFailure` in `sharp-image-codec.ts`). Since the job deadline hardening, a pipeline stopped by its timeout is `ImageCodecTimeoutError` in both |
| Failure classes (review) | `encodeMaster` and `probe` turn a sharp failure on the upload into `ImageDecodeFailedError` (permanent, `assets.image_corrupt`: truncated, corrupt header, unsupported format, pixel limit) unless its message names a resource exhaustion of the process (`vips_malloc`, `std::bad_alloc`, `out of memory`, `Cannot allocate memory`, `ENOMEM`, a thread that could not be created, or a non-`Error` rejection), which becomes `ImageProcessingFailedError`. A message that names a pixel, size, dimension or security limit stays permanent even when it also mentions memory, because a hostile HEIF or AVIF can make the decoder report its own allocation limit; `encodeRendition` works on the worker's own validated master, so every failure there is `ImageProcessingFailedError`. That error is `retry_processing` (`assets.processing_error`), counts against the five processing errors and never deletes the staging object. The message list is the best available signal: libvips reports no error codes to Node |
| Verification | `application/processing/verify-and-master.ts`: `HEAD` (missing → `StagingObjectMissingError`), stored size against the declared size (`assets.size_mismatch`), `readAtMost` capped at the declared size (a `HEAD` size that differs from the declaration is permanent `assets.size_mismatch`; once the `HEAD` agreed, a read that comes back shorter, or one byte past the cap as `StoredObjectTooLargeError`, is an inconsistent storage read and is retried as a storage error, keeping the staging object), magic bytes, declared type and decoded format must agree (`assets.content_mismatch`), dimensions (`assets.image_too_large`, `assets.image_too_small`), one decode into the master (`assets.image_corrupt` on a decoder error). A missing staging object is `retry_storage` (`assets.upload_missing`) while the upload's `expires_at` (returned by the claim since `0096`) is in the future and `failed_permanent` after it, the rule of AS7 |
| Proof | `media-signature.spec.ts`, `image-limits.spec.ts`, `verify-and-master.spec.ts` and `image-pipeline.integration-spec.ts` (the three stage titles, plus the four accepted formats, the size checks and the missing object). The polyglot (PNG signature over a JPEG body) and a truncated JPEG are `assets.image_corrupt`; an 8,000 × 5,001 PNG of 0.1 MB is `assets.image_too_large` before any decode. Mutations that each fail a named title: the signature comparison removed, the decoder comparison removed, `keepMetadata()` on the master, the pixel cap raised to 48 MP, the read cap raised past the declared size, `autoOrient` off |
| Pixel cap per format (owner decision, after the local memory measurement) | AVIF is capped at 12 MP, JPEG, PNG and WebP stay at 40 MP, in the dimension check (`dimensionRefusal(detected, width, height)`, failure `assets.image_too_large`, permanent, staging object deleted) and in the decode (`limitInputPixels` of the detected format). The declared type cannot lower the cap: the magic bytes, the declared type and the decoder's format must already agree (`assets.content_mismatch` otherwise), and the codec reads the format from the bytes itself. Proof: `image-limits.spec.ts` (`refuses an avif over 12 megapixels as too large and accepts one at exactly 12 megapixels`, `caps every accepted media type, avif at 12 megapixels and the others at 40`), `verify-and-master.spec.ts` (`refuses an avif declared as png or jpeg as a content mismatch before the decoder or the pixel limit sees it`) and `image-pipeline.integration-spec.ts` (`refuses an avif over 12 megapixels as too large, accepts one at exactly 12 megapixels and still accepts a 20 megapixel jpeg and png`, `caps the decode at the pixel limit of the format the decoder detects, whatever the caller declared`, with 4,000 × 3,001 and 4,000 × 3,000 AVIFs of under 1 KB generated in the test). Mutations that each fail a named title: AVIF back to 40 MP, the decode cap taken from a type other than the detected one, `>=` instead of `>` at the limit, the decode cap one pixel lower. The front end also resizes before uploading; the server limits do not depend on it |
| Memory (local, not the A3c gate) | macOS arm64, one process: a 40 MP noisy JPEG of 4.3 MB through probe, master and four renditions peaked at 452 MB RSS (353 MB before, which includes generating the fixture), 1.2 s; a 40 MP RGBA PNG peaked at 315 MB (110 MB before), 0.6 s. Railway's Linux memory and the 12 MP, 40 MP and AVIF matrix are A3c's `perf:assets` |

### A3b-2 as built (migration `0096_assets_claim_original_variant`, baseline revision 106)

| Piece | As built |
|---|---|
| Migration | `ready` must name the asset's existing `original` variant id and the worker reads no table, so `0096` drops and recreates `assets.claim_processing_job` with two more result columns: `original_variant_id` (the claimed asset's own original, joined by asset and organization) and `upload_expires_at` (which replaced A3b-1's attempt-count bound for a missing object). Same body, owner, `SECURITY DEFINER`, `search_path`; `EXECUTE` from `PUBLIC` revoked and granted to `ninaku_worker` only; canonical `0070_assets.sql` holds the end state. It follows Catalog's `0095` (revision 105) |
| Renditions | `application/processing/derive-renditions.ts` derives `thumbnail` and `tile` (cover, `attention`) and `card` and `detail` (inside, never enlarged) from the in-memory master, each WebP at its profile quality with its SHA-256 |
| Handler | `application/processing/process-upload.handler.ts`, registered for `process_upload` in `AssetsWorkerModule` with `SharpImageCodec` as `IMAGE_CODEC_PORT`: verify and master (A3b-1), derive the renditions, `put` the master to `s3-private` `m/<token>/original.webp` (`image/webp`, `private, no-store`) and each rendition to `r2-public` `a/<token>/p1/<name>.webp` (`image/webp`, `public, max-age=31536000, immutable`), and only then answer `ready` with every variant: the original under its existing id, each rendition and every storage object under a new UUIDv7. A failed put is a storage retry and no `ready` is recorded. `afterRecord` removes the staging object only when a `ready` or `failed_permanent` outcome was recorded `completed`; retries, a dead letter and a lost lease keep it (the `in/` lifecycle is the net). A non-public asset is refused as a processing error (the API admits only `public` today) |
| Preconditions (review) | Deterministic refusals are typed permanent failures, recorded `failed_permanent` at once instead of retried to a dead letter: a claim without the original variant (`assets.original_variant_missing`), a non-public access class (`assets.access_class_unavailable`), a job without its upload (`assets.upload_unavailable`) and an unknown staging provider (`assets.storage_provider_unknown`). A staging delete that fails after a recorded outcome is logged as `assets.processing.staging_delete_failed` (warn, job and upload ids) and left to the lifecycle rule |
| Proof | `processing-jobs.repository.integration-spec.ts` (the claim now returns the original variant id and the upload expiry), `process-upload.handler.spec.ts` (keys, content types and cache-control per bucket, the public bucket receiving only renditions, variant facts against the stored bytes, a failed master or rendition put, the staging delete per outcome and result), and `test/integration/assets-processing.integration-spec.ts`, which runs only when `ASSETS_MEDIA_WORKER_E2E_ENDPOINT`, `…_ACCESS_KEY_ID` and `…_SECRET_ACCESS_KEY` name an S3-compatible server (CI skips it): against MinIO and a fresh database it processes a rotated JPEG with GPS EXIF into a `ready` asset (upload `verified`, job `completed`, five `ready` variants with `available` primary objects, reserved bytes released, stored bytes increased by the sum of the variants, the active count unchanged), HEADs every object for its content type, length and cache-control, re-hashes each against its recorded checksum, finds no EXIF or XMP and the staging object gone; fails five bad uploads with each reservation released once and each staging object removed; retries a not-yet-arrived object as a storage error; expires an abandoned upload and deletes its staging object; it creates and deletes its own two buckets. `database/tests/contracts/assets-processing-contracts.ts`: the claim title's column list and `returns the claimed asset's own original variant and upload expiry, never another tenant's`. Mutations that each fail a named title: a rendition written without `immutable`; the rendition puts not awaited before answering `ready`; the staging delete skipped; the claim joining an original variant of another organization |
| Upgrade | A revision 105 database built from staging `61cd4d21` (with Catalog's `0095`) and seeded with an organization, seven assets and their original variants, and jobs and uploads in every state (a `ready` asset with a `verified` upload and a `completed` job, a `failed_permanent` one, a `dead_lettered` one after five processing errors, a `leased` job, a `pending` job, an `issued` upload and an `expired` one) was upgraded by `db:prepare` from the merged tree: the ledger gained only `baseline-r106` and `0096_assets_claim_original_variant`. Under `ninaku_worker` the claim then returned the pending job with its own original variant id (`uuid`) and its upload's `expires_at` (`timestamp with time zone`), and `pg_dump --schema-only` of the upgraded database, owners and privileges included, is identical to a fresh install of the merged tree at revision 106 |
| Deploy order and review proof | The migrator applies `0096` before the release's worker image starts; the old worker reads the claim's first 13 columns by name and ignores the two new ones, and the new repository selects and maps all 15 by name (`c.original_variant_id`, `c.upload_expires_at`, never by position). The join on `lower(variant_code) = 'original'` cannot see two originals: `asset_variants_code_chk` allows lower-case codes only and `asset_variants_asset_code_ci_uq` is unique on `(asset_id, lower(variant_code))`; the processing contract proves both refusals and that the worker receives `uuid` and `timestamp with time zone` (`pg_typeof`) for the two columns |

### A3c-1 as built (migration `0097_assets_requeue_dead_letters`, baseline revision 107)

| Piece | As built |
|---|---|
| Migration | `0097`, after Catalog's `0095` (revision 105) and A3b-2's `0096` (revision 106), drops and recreates `assets.claim_processing_job` with two more result columns at the end, `processing_error_count integer` and `claim_state varchar`, and creates `assets.requeue_dead_letters(uuid[]) RETURNS integer`. Same owner, `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`; the claim keeps `EXECUTE` for `ninaku_worker` only; the requeue has `EXECUTE` revoked from `PUBLIC` and granted to no role (`runtime_grants.sql` does not change). Canonical `0070_assets.sql` holds the end state |
| Poison jobs (requirement 1) | The claim of an expired lease counts as a processing error: `attempt_count + 1`, `processing_error_count + 1`, `error_class 'processing'`, `last_error_code 'assets.lease_expired'`, and the job is leased to the new claimant (`claim_state 'leased'`). When `processing_error_count + 1 >= 5` the claim dead-letters it instead, exactly as the fifth `retry_processing` does: under the usage, asset, upload and original-variant locks (the lock order of §7), the asset, its original variant and its upload fail with `assets.processing_exhausted`, the reservation and the active slot (only while the asset counts) are released, and the row comes back with `claim_state 'dead_lettered'` and no lease. A live lease is never reclaimed or counted. A job whose worker dies every time (out of memory on a huge image) is dead-lettered at its fifth expiry instead of being reclaimed forever. Other job kinds are counted the same way and only the job is dead-lettered (no such job is enqueued yet) |
| Stranded jobs (requirement 2) | At claim time, a `process_upload` job, pending or with an expired lease, whose asset is no longer `processing` or whose upload is no longer `received` is completed (`completed_at`, `last_error_code 'assets.job_abandoned'`, lease cleared) without touching the asset, its upload or the usage row, and comes back with `claim_state 'completed'`. This is the terminal path of a job whose outcome was refused with `assets_outcome_state`: it stays leased until its lease expires and the next claim completes it |
| Requeue (OD21) | `RETURNS TABLE (requeued, skipped_over_limit, skipped_ineligible)`. For 1 to 500 ids (else `23514 assets_requeue_batch`), in READ COMMITTED (else `assets_processing_isolation`), it first takes the expiry sweep's advisory lock (`pg_advisory_xact_lock(hashtextextended('assets.expire_uploads', 0))`, waiting, so it never overlaps the expiry sweep or the reconciliation, which lock usage rows in other orders) and locks every named organization's usage row up front in `organization_id` order; then it locks each `dead_lettered` `process_upload` job in id order, and its asset, upload and original variant. A job is requeued only while its asset is `failed` with `assets.processing_exhausted` and not archived, its upload is `rejected` and was **created less than a day ago**. That is the inbox lifetime of OD21 and §14: the staging object cannot be older than its upload row, and the `in/` lifecycle counts its day from the object's creation. `uploads.expires_at` cannot be the window: it is the POST policy deadline, 600 s after the reservation (`UPLOAD_POLICY_LIFETIME_SECONDS`), and a processing dead letter needs at least 5 s + 30 s + 2 min + 10 min of backoff after its first claim, so every processing dead letter has an expired upload. The object can still be gone (a lifecycle run, a manual delete): the requeued job then finds no staging object after the upload's expiry and fails for good with `assets.upload_missing` (AS7's rule), releasing its reservation again. Each candidate re-checks the organization's limits as `reserve_upload` does (`platform.effective_limit`; no row or unlimited means no ceiling): `active_asset_count + 1` against `assets.active_assets` (only while the restored asset counts, `assets.counts_as_active_asset`) and `stored_bytes + reserved_bytes + declared` against `assets.stored_megabytes`; an organization over a limit leaves that job dead-lettered and counts it in `skipped_over_limit`, and the rest of the batch goes on. A requeued job: asset and original variant `failed → processing` (failure fields cleared), upload `rejected → received` (`rejection_code` and `settled_at` cleared), `reserved_bytes` plus the declared size, `active_asset_count + 1` under the same predicate as the release, and the job `pending` with `available_at = now()`, `attempt_count = 0` and `processing_error_count = 0` (R1 requirement 1), `error_class` and `last_error_code` cleared. Every other distinct id (older, archived, unknown, completed, already requeued) counts in `skipped_ineligible` |
| Proof | `database/tests/contracts/assets-dead-letter-contracts.ts` (`NINAKU_ASSETS_DEAD_LETTERS_OK`): `counts a reclaimed expired lease as a processing attempt and dead-letters the job at the fifth` (leased at reclaims 1 to 4, dead-lettered at the fifth); `completes or dead-letters a job whose asset no longer waits for an outcome instead of reclaiming it forever`; `completes a stranded job whose fifth lease expired instead of dead-lettering it, leaving its asset and usage untouched` (abandon wins over exhaustion). Every row returned `dead_lettered` or `completed` is checked to have `lease_owner`, `lease_token` and `lease_expires_at` cleared (also enforced by `processing_jobs_lease_shape_chk`), and `image-job-loop.spec.ts` proves the loop runs no handler and records nothing for them. `database/tests/contracts/assets-requeue-contracts.ts` (`NINAKU_ASSETS_REQUEUE_OK`): `resets both the attempt count and the processing error count of a requeued dead letter`; `requeues a dead-lettered processing job of the last day only through the operator function, restoring its reservation` (function shape, `permission denied` for the worker and the runtime, batch bounds, isolation, an upload created 25 hours ago and an archived asset skipped, a repeated id counted once); `skips the dead letter of an organization at its asset or storage limit and requeues the rest of the batch` (`1,2,0`); `restores the usage counters exactly after a dead letter and its requeue` (one dead letter by record, one by the claim, usage back to the original values). `database/tests/concurrency/assets-requeue-concurrency.ts`: `requeues two overlapping batches spanning two organizations in opposite orders without a deadlock`. Shared helpers in `database/tests/shared/assets-dead-letter-fixture.ts`; both scripts registered in `run-clean-install.ts`. Mutations that each fail a named title: `EXECUTE` granted to `ninaku_worker` in `runtime_grants.sql`; a reclaimed expired lease left uncounted; a stranded job leased again; exhaustion checked before abandonment; the processing error count kept on a requeue; the limit check removed (`2,1,0`); the one-day window removed; the reservation restored twice; the advisory lock and the up-front ordered usage lock removed (`deadlock detected`). The only mutation the table catches first is a dead letter that keeps its lease (`processing_jobs_lease_shape_chk`) |
| Upgrade | A revision 106 database built from staging `94694e61` (migrations through `0096`) and seeded with jobs in every state (pending, a live lease, an expired lease with four processing errors, a dead letter whose upload is from today and one whose upload is two days old, completed), upgraded by `db:prepare` (only `0097` applied), dumps a schema identical, with owners and privileges, to a fresh install of the merged tree (revision 107). On the migrated rows the pending job is claimed `leased` with its original variant and upload expiry, the expired lease is dead-lettered at its fifth processing error (`7, 5`), and the requeue returns `2,0,1` (that job and the recent dead letter; the two-day-old one is skipped) with the usage counters restored |
| Deploy order | The migrator applies `0097` before the new worker image. The old worker reads the claim's columns by name and ignores the two new ones; in that window it would process a row the claim returned as `dead_lettered` or `completed`, and its record call answers `assets_job_lease_lost` (the row holds no lease), so nothing is recorded twice |

### A3c-2 as built (worker retry classes, backoff, metrics and the memory gate, no migration)

| Piece | As built |
|---|---|
| Classifier | `application/processing/failure-classifier.ts` (`classifyHandlerFailure`, moved from `processing-failure.ts`, which keeps the error types). Permanent: `PermanentProcessingFailure` with its §12 code, a decode error (`assets.image_corrupt`), a missing staging object after the upload's expiry. Storage (`retry_storage`, `assets.storage_unavailable`, no attempt cap): an `ObjectStorageFailedError` whose cause, or a cause up to five levels down, carries `$metadata.httpStatusCode >= 500`, any AWS SDK `$retryable` marker, the name or code `SlowDown`, `TimeoutError`, `RequestTimeout`, `RequestTimeoutException`, `ServiceUnavailable`, `InternalError`, `EPIPE`, `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `ECONNABORTED`, `ECONNRESET`, `ECONNREFUSED`, `ENOTFOUND` or `EAI_AGAIN`, or a message containing `socket hang up` or `slow down` (any case); a read longer than the declared size or shorter than its `HEAD` (`InconsistentStorageReadError`); a staging object not there yet before the upload's expiry (`assets.upload_missing`). **Any other storage failure** (`403 AccessDenied`, `SignatureDoesNotMatch`, `NoSuchBucket`, `412`, a body that ends early, an error the list does not name) is `retry_processing` with `assets.storage_refused`: it **counts towards the five processing errors**, so a storage error that persists and is not on the list dead-letters the job after five attempts (about 12.6 minutes) instead of retrying forever; the operator fixes the cause and requeues it (runbook §11.2). Everything else is `retry_processing` (`assets.processing_error`) |
| Backoff | `retryAvailableAt(outcome, job, now, random)` in the same file (`processing-retry-delay.ts` removed). Storage: `min(5 s × 2^attempt_count, 15 min)` with ±20% jitter, then capped at 14 min 30 s: the SQL limit less a 30-second margin for the clock difference between the worker and PostgreSQL, because a retry the SQL refuses leaves the job leased until its lease expires, which now counts as a processing error. No attempt cap. Processing: 5 s, 30 s, 2 min, 10 min indexed by `processing_error_count` (the claim's new column, R1 requirement 2), the last step repeated, never jittered |
| Loop | A claim answered `dead_lettered` or `completed` is reported as an iteration with `outcome: null` and that result, without running a handler or recording anything; the runner polls again at once |
| Metrics and events | `infrastructure/worker/media-worker-metrics.ts` (meter `ninaku-media-worker.assets`): counter `assets_processing_dead_letters` with one attribute, `ninaku.assets.settled_by` (`record` or `claim`), and counter `assets_storage_errors_total` (one per `retry_storage`); no job or tenant id as an attribute. The runner counts every iteration that reached a job and logs `assets.image_job.dead_lettered` (error, job id and outcome only) |
| Operator entrypoint | The SQL function of A3c-1 through runbook §11.2, as OD21 decided; no script, job or route |
| Memory gate | `npm run perf:assets` (`nest build && node tooling/assets/run-assets-benchmark.ts`; the stage table names `tooling/performance/`, outside this branch's edit surface). The fixtures are generated in a child process (12 MP and 40 MP JPEG, 40 MP PNG, 12 MP AVIF, each at most 10 MiB like an upload; the job deadline hardening added a 12 MP and a 40 MP WebP, not measured yet, and gave each job the real deadline of `ASSETS_IMAGE_LEASE_SECONDS`, because the compiled handler had needed a `ProcessingJobAttempt` since the network timeouts fix); then one fresh process runs each image five times through the compiled `ProcessUploadHandler` (verify, master, four renditions, SHA-256, puts to an in-memory store), sampling RSS every 5 ms. `tooling/assets/assets-benchmark-gate.ts` decides: go when peak RSS ≤ 60% of `ASSETS_BENCHMARK_MEMORY_LIMIT_MB` (default 1,000 MB, so 600 MB) and p99 job time × 2 ≤ `ASSETS_IMAGE_LEASE_SECONDS` (default 120); otherwise it prints `NINAKU_ASSETS_BENCHMARK_NO_GO` with its refusals and exits 1. `ASSETS_BENCHMARK_IMAGES` runs a subset. At run time nothing else changes: a worker that exceeds its memory is killed, its lease expires, and A3c-1 counts each reclaim and dead-letters the job at the fifth |
| Measured (local only: macOS arm64, Node 24.13, sharp 0.35.5, libvips 8.18.7; **not Railway**, whose `media-worker` memory limit is not known yet) | The four images in one process, in this order: 12 MP JPEG (2.3 MB) p99 1.3 s, peak 333 MB; 40 MP JPEG (7.6 MB) p99 1.6 s, 338 MB; 40 MP PNG (3.2 MB) p99 1.9 s, 370 MB; 12 MP AVIF (1.5 MB) p99 1.6 s, **670 MB**; 123 MB before the first job. Verdict at 1,000 MB: **no-go** (`peak_rss_above_ceiling`, 670 > 600); the lease condition passes by far (p99 1.9 s × 2 against 120 s). Each image alone in a fresh process: 304, 372, 372 and 572 MB, all under 600: the AVIF decoder dominates, and the process keeps memory it allocated for earlier images. Outside the stage's matrix, a 40 MP AVIF (4.9 MB, accepted when this was measured) peaked at **1,173 MB** alone (2.5 s per job); it is refused now as `assets.image_too_large`, because OD5 capped AVIF at 12 MP (`domain/image-limits.ts`; `image-limits.spec.ts` › `refuses an avif over 12 megapixels as too large and accepts one at exactly 12 megapixels` and `image-pipeline.integration-spec.ts` with a 4,000 × 3,001 AVIF). The stage's no-go order offered about 1,120 MB (the four-image gate at 60%) or about 1,960 MB (a 40 MP AVIF). **Decision recorded:** `.railway/railway.ts` gives the `media-worker` 1,536 MiB (`MEDIA_WORKER_MEMORY_BYTES`), one replica, one job at a time (the image claim loop) and one libvips thread (`sharp.concurrency(1)`), and AVIF is capped at 12 MP. **Pending:** the re-measurement on Railway's Linux at that limit, `ASSETS_BENCHMARK_MEMORY_LIMIT_MB=1536 npm run perf:assets`, expected `NINAKU_ASSETS_BENCHMARK_GO` (not run: it needs an idle machine and the real limit; the gate defaults are unchanged). The lease of 120 s stays: the slowest job is about 50 times shorter |
| Proof | `failure-classifier.spec.ts`: `classifies storage, permanent and processing failures`; `retries each transient storage signal alone as a storage error and counts an unknown storage failure as a processing error` (every listed name, code, message and SDK marker on its own); `schedules every processing retry, for processing errors 1 to 4, at most 15 minutes ahead` (5 s, 30 s, 2 min and 10 min after 17 total attempts); the storage curve with its jitter and cap. `infrastructure/processing/process-upload.handler.spec.ts` (where A3b-2 put the handler spec; the stage table names `application/processing/`): `retries a storage outage with capped backoff and no attempt cap, and dead-letters only the fifth processing error` (40 storage retries, all pending and under 15 minutes, then processing delays of 5 s, 30 s, 2 min, 10 min and 10 min, the staging object kept; its dead letter comes from a repository double that mimics the SQL, so it proves the worker side only). The SQL side is proved by `test/integration/assets-retries.integration-spec.ts` `never counts a storage retry as a processing error and dead-letters the job at its fifth processing error`, which runs the real loop against the real `claim_processing_job` and `record_processing_outcome` (no storage server needed): storage, processing, storage and four processing errors give counters 1/0, 2/1, 3/1, 4/2, 5/3, 6/4 and the dead letter at 7/5 with the asset `failed` (`assets.processing_exhausted`); a storage retry counted as a processing error, or the threshold moved to the sixth, fails it. `image-job-loop.spec.ts` (jobs settled by the claim), `image-job-loop.runner.spec.ts` (metrics and the dead-letter event), `media-worker-metrics.spec.ts`, `tooling/assets/assets-benchmark-gate.spec.ts`. `test/integration/assets-retries.integration-spec.ts`, against MinIO and PostgreSQL: a storage outage (an unreachable endpoint, `ECONNREFUSED`) is `retry_storage` with one attempt and no processing error, then the upload is processed to `ready`; a job whose worker dies five times is dead-lettered by the claim with both counters at 5, its staging object kept and its reservation released, then requeued by the operator (both counters at 0) and processed to `ready`. The A3b-2 spec now shares its bucket and store setup through `test/support/assets-media-worker-harness.ts`. Mutations that each fail a named title: storage retries capped after nine; `SlowDown` classified as processing; the processing step indexed by the total attempts; the storage cap removed after the jitter; a job settled by the claim handled anyway; the runner not counting |

### A4a as built (the library: list, rename, archive and restore, no migration)

| Piece | As built |
|---|---|
| Routes | `GET /api/v1/assets` (`AssetsLibraryController`, beside the AS3 read) and `PATCH /api/v1/assets/{assetId}`, `POST /api/v1/assets/{assetId}/archival` and `POST /api/v1/assets/{assetId}/restoration` (`AssetChangesController`), under `AssetsController` like A2b. None is capability-gated: the list needs organization-wide `assets.media.view`, the three writes `assets.media.manage` (`PermissionCheckContract`, a refusal is `403 · assets.action_forbidden`, not audited, as the A2c completion) |
| List (AS4) | `ListAssetsUseCase`, one statement (`libraryPageSql`, `infrastructure/assets/asset.queries.ts`): the page is chosen in a subquery over `assets.assets` alone with an explicit `organization_id = $1`, a literal `archived_at IS NULL` or `IS NOT NULL` (so each listing reads its own partial index of A1a-1; `EXPLAIN` shows an index-only scan of `assets_organization_active_idx`), an optional status and the keyset predicate of `foundation/pagination` on `(created_at, id)`, `limit + 1` rows; the original variant is then joined for the AS3 fields. The cursor binding is the route, the organization, the status and `archived`, so a cursor sent with other filters or by another organization answers `400 · collection.cursor_incompatible`. Items are the AS3 shape with only `renditions.thumbnail` (`AssetListItem`); `private, no-cache`, no ETag. `archived` is `true` or `false` only (default `false`): an archived listing never mixes with the active one |
| Rename (AS5) | `RenameAssetUseCase`: the asset is read with the AS3 statement plus `FOR UPDATE OF a` (the runtime's `UPDATE (display_name)` grant allows the lock), the optional `If-Match` is compared under that lock with the strong tag of the AS3 body (`presentation/dtos/asset-entity-tag.ts`, the same tag `GET /assets/{assetId}` answers; the controller hands the use case a precondition built from the header, because the tag hashes the response shape) and a mismatch is `412 · foundation.precondition_failed` (`ASSETS_FAILURE.PRECONDITION_FAILED`, the code Catalog and Organization also publish). The current name answers the locked asset and writes nothing (7 statements); a change runs `UPDATE assets.assets SET display_name` under the tenant RLS policy, records `assets.asset.renamed` with `assets.asset.display_name` before and after, and reads the answer again (10 statements). The answer carries the new tag and `private, no-store` (`HttpTaggedWriteResponse`). The name is `trimmedBoundedText(255)`, the A2b intent's rule, so a blank or NUL-bearing name is `400`; clearing the name is not offered |
| Archive and restore (AS6) | `ChangeAssetArchivalUseCase` calls `assets.archive_asset` or `assets.restore_asset` (A1b-3) and answers the asset read after it. `archived` and `restored` record `assets.asset.archived` or `assets.asset.restored` once (9 statements); `already_archived` and `not_archived` answer `200` and record nothing (8); `not_found` is `404 · assets.not_found`, another organization's asset included. The refusals raised by the functions flow unchanged to the A2a-1b mapping: `assets_status_transition` is `422 · assets.transition_invalid`, `assets_restoration_expired` `422 · assets.restoration_expired`, `assets_asset_limit` `422 · assets.asset_limit_reached`; the transaction rolls back with no audit. `Cache-Control: private, no-store` |
| Proof | `test/e2e/assets-library.e2e-spec.ts` with the four stage titles, `test/e2e/assets-archival.e2e-spec.ts` (a failed asset's restoration and a purging asset's archival and restoration refused, nothing recorded), the A1b-3 contract `assets-lifecycle-contracts.ts` › `refuses archiving or restoring a purging asset and never lets its status leave purging` (unchanged, rerun green), the unit specs of the three use cases, the repository, the query and body schemas, the list item mapping, the failure mapping and the module wiring, and the `statement-budget.e2e-spec.ts` rows: 7 for a list, 10 for a rename and 7 when unchanged, 9 for an archival or a restoration and 8 for a repeat. The e2e "ready" fixture (`processedIntoReady`) now also inserts the four profile 1 renditions, because the deferred `assert_ready_asset_renditions` trigger re-checks a ready asset on any later write (the archival); the attached image is a `catalog.item_assets` row written in SQL, because the Catalog link routes are A5. Mutations that each fail a named title: the archived filter ignored (the repository titles and the list e2e title), the `If-Match` comparison skipped (the use case precondition title and the rename e2e title), a refused transition swallowed in the use case (the rollback unit title and three e2e titles), `not_archived` treated as `restored` (the repeat unit title, the archive e2e title and the statement row). The organization filter of the list removed fails only the repository title: the tenant RLS policy on `assets.assets` still filters the rows, so over HTTP it is an equivalent mutant; the explicit filter stays because the partial indexes lead with `organization_id` |
| OpenAPI | Four operations added under the `Assets` tag; `npm run openapi:compat` against `origin/staging`: no breaking change, `path_added` `/api/v1/assets`, `/api/v1/assets/{assetId}/archival`, `/api/v1/assets/{assetId}/restoration` and `operation_added` `PATCH /api/v1/assets/{assetId}`; no component schema added or removed, the three existing operations unchanged |
| Route classification | `route-classification-assets.ts` classifies the four operations as `admin`, unmeasured (administrative media management, D24); with the three Catalog image routes of A5b the typed table counts 164 operations (14 hot, 40 frequent, 108 admin, 2 not measurable, 41 measured) |

### A4b-1 as built (migration `0098_assets_purge`, baseline revision 108)

| Piece | As built |
|---|---|
| Migration | `0098` (provisional: A3c's `0097` is not merged yet), expand only: four functions, three indexes, no table, policy or other function changed. `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`, `EXECUTE` revoked from `PUBLIC` and granted to `ninaku_worker` only (migration and `runtime_grants.sql`; the canonical `0070_assets.sql` holds the end state and only revokes). All three refuse a transaction outside READ COMMITTED (`assets_processing_isolation`) |
| Indexes | `assets_purge_archived_idx (archived_at)` partial on `archived_at IS NOT NULL AND status IN ('ready','failed','purging')`, `assets_purge_failed_idx (failed_at)` partial on `status IN ('failed','purging')`, so the hourly scan is a `BitmapOr` of the two (measured with 50,000 assets: 190 buffers, 0.7 ms); `processing_jobs_asset_kind_idx (asset_id, job_kind, completed_at)`, which serves the "no purge job in the last 30 days" probe and the `processing_jobs` foreign key check when an asset row is deleted (there was no index leading with `asset_id` on that table) |
| `enqueue_purge_candidates(p_batch)` | 1–500 (`assets_sweep_batch`). Inserts a `purge_asset` job (`uuidv7()`) for up to `p_batch` assets that are `ready`, `failed` or `purging` and archived more than 30 days ago, or `failed` or `purging` and failed more than 7 days ago, with no open (`pending`, `leased`) purge job and no purge job completed, or dead-lettered (by `updated_at`), in the last 30 days; `ON CONFLICT … DO NOTHING` on `processing_jobs_one_open_uq`, so two sweeps never open two jobs. Returns `(enqueued, needing_cdn)`: the jobs inserted and how many of them belong to an asset that was ever `public` and `ready` (the worker logs those as deferred while the CDN purge is unconfigured). No row lock: `begin` re-checks |
| `purge_asset_begin(p_job_id, p_lease_token)` | Locks the job (a job of another kind, not leased, with another token or an expired lease is `assets_job_lease_lost`), then the usage row and the asset (lock order of §7). Eligibility is a positive test: `v_eligible := FOUND AND ((archived_at IS NOT NULL AND status IN ('ready','failed','purging') AND archived_at <= now() - 30 days) OR (failed_at IS NOT NULL AND status IN ('failed','purging') AND failed_at <= now() - 7 days))`, every comparison guarded so no NULL reaches the branch, and the probe runs only on `v_eligible IS TRUE`. A missing asset row (or a job whose organization is not the asset's) answers `not_found` and completes the job with `assets.purge_asset_missing`; an asset no longer due (restored and re-archived, a `purging` asset without dates) answers `ineligible` and completes it with `assets.purge_ineligible`; neither changes the asset. Otherwise a nested block deletes, in the stage's order, every `processing_jobs` row of the asset (the running job included), its storage objects, variants, upload and the asset, then raises the private SQLSTATE `NKP01`, which rolls the block back; a `foreign_key_violation` instead answers `referenced` and completes the job with `assets.asset_referenced`, the asset untouched. On success the asset becomes `purging` (skipped when it already is, so a reclaimed job answers `deletable` again) and the function returns `deletable`, the storage objects' keys and provider codes (ordered by key), the upload's staging provider and key, the public rendition names (the profile's four when the asset is `public` and was ever `ready`; empty otherwise, since a never-ready asset never had a delivered URL), the delivery token and the profile. Storage rows are untouched |
| `purge_asset_finish(p_job_id, p_lease_token)` | Same lease check and locks; an asset that is not `purging` is `assets_purge_not_begun`. Computes the bytes to release with the reconciliation's predicates (`ready` variants with an `available` primary object; declared bytes of an `issued` or `received` upload), deletes storage objects, variants, upload, every job of the asset and the asset in one transaction, subtracts exactly those bytes and answers `purged`. Neither `begin` nor `finish` touches `active_asset_count`: every purgeable prior state is uncounted by `counts_as_active_asset` (an eligible asset is archived, or `failed`, or already `purging`), which the safety contract proves against `reconcile_usage_batch` for each of them. A `foreign_key_violation` keeps the asset `purging`, completes the job with `assets.asset_referenced_after_purge` and answers `referenced_after_purge`. Deviation: the spec says "calls `assets.reconcile_usage`"; no per-organization reconciliation function exists (A1c-2 left it to this stage), so the finish subtracts the asset's own contribution and the contract proves the result equals the reconciled counters |
| `purge_asset_dead_letter(p_job_id, p_lease_token, p_error_code)` | Lease check as `begin`; the code must match `^assets\.[a-z0-9_.]{1,72}$` (else `assets_outcome_shape`); sets the job `dead_lettered` with `error_class 'permanent'`, the code, `attempt_count + 1` and the lease cleared, and touches nothing else. The worker calls it when a purge cannot go on safely (`assets.purge_urls_unavailable`, `assets.cdn_unconfigured_expired`). `assets.requeue_dead_letters` (A3c) only requeues `process_upload` jobs, so a dead-lettered purge job named to it is counted in `skipped_ineligible` (proved); the recovery is automatic: the enqueue retries the asset 30 days after the dead letter |
| Deviations | (1) `enqueue` also picks `purging` assets (spec: "not `purging`"): a purge job that dead-letters would otherwise leave its asset `purging` forever with half-deleted objects; with this rule it is re-enqueued 30 days after its dead letter, and a `referenced_after_purge` asset monthly (accepted by the coordinator). (2) `begin` answers two more outcomes, `ineligible` and `not_found`, and returns `job_created_at` (for the worker's 14-day CDN cap). (3) No row lock in `enqueue` and no advisory lock: `begin` is the only writer that decides |
| Proof | `database/tests/contracts/assets-purge-contracts.ts` (`NINAKU_ASSETS_PURGE_OK`, registered in `run-clean-install.ts` after the requeue concurrency script), the four stage titles. Inside them: the three functions' shape and grants, the runtime refused (`permission denied`), a foreign lease, REPEATABLE READ, a finish before the begin; the referenced asset's rows, jobs and usage byte-identical before and after; the begin row exactly (five keys, providers, staging key, four names, token, profile 1), storage rows unchanged but the status; a reclaimed lease answering `deletable` again; after the finish no row of the asset left (asset, variants, objects, upload, jobs) and usage `stored_bytes` lower by the asset's 6,000 bytes and equal to the reconciliation; a failed asset 8 days old purged with no keys or names and usage unchanged; an asset no longer due answering `ineligible`; restore refused (`assets_status_transition`) and the attach lock reading `purging`; a reference inserted after the begin answering `referenced_after_purge` with nothing deleted; the enqueue bounds and isolation, a batch of 1, due (archived 31 days, failed 8 days, completed 31 days ago, a `purging` asset with a dead-lettered job) and not due (archived 29 days, failed 6 days, ready, completed 10 days ago, already open) assets, and a second sweep enqueuing nothing. Mutations, each failing a named title: `purging` set before the reference test (the referenced title); the asset row deleted before its variants in `begin` (the deletion title and the purging title answer `referenced`) and in `finish` (the deletion title); the 30-day completed-job window dropped (the enqueue title); the stored bytes not released (the deletion title); the archive age not re-checked in `begin` (the deletion title) |
| Review proof | `database/tests/contracts/assets-purge-safety-contracts.ts` (`NINAKU_ASSETS_PURGE_SAFETY_OK`, registered after the purge contract): `begins a purge only when the asset is provably due and answers ineligible or not_found without changing anything otherwise` (a `purging` asset with no dates, archived 29 against 31 days, failed 6 against 8 days, an asset row removed under `session_replication_role = replica` after its job was leased, a job moved to another organization the same way; a `failed` asset without `failed_at` is refused by `assets_status_state_chk`, so it cannot exist); `leaves the active count, stored bytes and reserved bytes equal to the reconciliation after a purge from every purgeable prior state` (failed, failed then archived, archived ready, archived processing and archived pending, both `ineligible`, archived then failed; each on its own organization, a reclaimed second `begin` before `finish`, and the counters compared with `reconcile_usage_batch` run over every organization); `never duplicates a dead-lettered purge job across consecutive sweeps, refuses its operator requeue and retries it after 30 days`. Mutations, each failing a named title: the original NULL-prone predicate with the `not_found` branch removed (the eligibility title: the undated `purging` asset, the vanished asset and the foreign lease answer `deletable`); `finish` releasing an active slot (the reconciliation title); the dead-letter suppression removed from the enqueue (the dead-letter title) |
| Upgrade | A revision 107 database built from `feat/assets-retries` `b9553600` (`db:fresh`), seeded with one DO block through the real functions (pending, processing, ready, failed, dead-lettered, archived 40 days, failed 10 days, a `purging` asset, and an asset archived 40 days that a catalog item uses), upgraded by `db:prepare` from this branch: the ledger gained only `baseline-r108` and `0098_assets_purge`; `pg_dump --schema-only` (owners and privileges) is identical to a fresh install of this branch. Repeated after the review fixes with the same result. On the migrated rows, as `ninaku_worker`: the enqueue answered `(3,2)` (the archived, the failed and the referenced asset; two were ever public), the archived asset `deletable` (five keys, four names) then `purged`, the failed asset `deletable` with no keys then `purged`, the referenced asset `referenced`; usage went from `4,24000,4096` to `4,18000,4096` |
| Renumbering | A new number changes: the file name `database/migrations/0098_assets_purge.sql`; its `ninaku_migrations.claim('0098_assets_purge', …)` id; `baseline_revision` 108 and the "Revision 108" scope paragraph in `database/manifest/baseline.json`; the 108 and `0098` mentions in `database/README.md`, this note, the A4b-2 note and the §16 "As built" line of `ASSETS_STAGE_SPECS.md`; the regenerated `database/review/*` |

### A4b-2 as built (the purge job, the CDN purge and the hourly sweep, no migration)

| Piece | As built |
|---|---|
| Loop | The purge runs in the image loop (§6.3: one job at a time). `ProcessingJobHandler.handle(job, leaseToken)` now receives the claimed lease token, and a handler may answer `{ settledResult }` (`isSettledByHandler`) when its own SQL already settled the job: the loop then records nothing and calls no `afterRecord`. `ProcessUploadHandler` is unchanged |
| Handler | `application/processing/purge-asset.handler.ts` (`PurgeAssetHandler`, registered for `purge_asset`): `begin`; `referenced` and `ineligible` settle at once; on `deletable` it builds the URLs `<ASSETS_PUBLIC_BASE_URL>/a/<token>/p1/<name>.webp` of the returned names (at most four), refuses to go on while the CDN purge is unconfigured and there is a URL (nothing is deleted yet), removes every returned key, the staging key and, for erasure completeness, the keys derived from the token (`m/<token>/original.webp` and the four `a/<token>/p1/*.webp`, which catches an object written by a put whose outcome was never recorded; a missing key counts as done), then purges the URLs in one call, then `finish`. **Fail closed** (`purge-targets.ts`): when the asset has any `r2-public` object or public rendition names, the URL list must be derivable completely (a valid delivery token, profile 1, a public base URL, known names, and every `r2-public` key among the derived ones); otherwise `PurgeNeedsOperatorError` (`assets.purge_urls_unavailable`) is raised before any delete and the job is dead-lettered through `purge_asset_dead_letter`, never finished. While the CDN purge is unconfigured a job that needs it retries without deleting, and once the job is 14 days old (`job_created_at`) it is dead-lettered with `assets.cdn_unconfigured_expired` instead. The settled event is emitted after the `try`, so a failing log or metric never turns a finished purge into a retry. Any other failure is answered `retry_storage` (the only retry the SQL accepts on a purge job, A1c-1) with a code: `assets.cdn_unconfigured`, `assets.cdn_purge_failed`, `assets.storage_provider_unknown`, the A3c classifier's `assets.storage_unavailable` or `assets.storage_refused` for a storage failure, `assets.purge_error` otherwise. Deviation: the A3c processing class (five attempts) cannot apply because `record_processing_outcome` refuses `retry_processing` on a purge job; a purge therefore retries without a cap at the storage backoff (at most about 15 minutes), and a worker that dies on it is still dead-lettered by the claim at its fifth lost lease |
| CDN port | `application/ports/cdn-cache-purger.port.ts` (`CdnCachePurgerPort`: `isConfigured()`, `purgeUrls(urls)`). `infrastructure/purge/cloudflare-cdn-cache-purger.ts`: `POST https://api.cloudflare.com/client/v4/zones/<ASSETS_CDN_ZONE_ID>/purge_cache` through `OutboundHttpService` with `authorization: Bearer <token>`, `content-type: application/json`, body `{"files":[…]}`, 10 s timeout, at most 100 URLs per call (S22); anything but a 2xx with `"success": true`, or a transport error, is `CdnCachePurgeFailedError` whose cause carries the status only. `infrastructure/purge/unconfigured-cdn-cache-purger.ts` when the group is absent: the module logs `assets.media_worker.cdn_purge_unconfigured` once at boot |
| Environment | CDN purge group of the `media-worker` (`ASSETS_CDN_ZONE_ID`, `ASSETS_CDN_PURGE_TOKEN`, `MEDIA_WORKER_CDN_PURGE_SHAPE`), all or nothing as its own channel (`assets CDN purge`), refused in the `api` and `worker` roles; `MediaWorkerSettingsService.cdnPurge()`. The token joins the known secrets (`collectEnvironmentSecrets` and `KnownSecretsRegistryService`), so the redactor masks it in any log line |
| Sweep | `infrastructure/worker/enqueue-purge-candidates.job.ts`: every hour, `enqueue_purge_candidates(500)` repeated until a batch inserts fewer than 500, at most 20 batches (10,000 jobs) a run; logs `assets.purge.enqueued` (count and batches) or `assets.purge.enqueue_failed`, and, once per run when the CDN purge is unconfigured, `assets.purge.deferred_without_cdn` with the number of enqueued purges that need it; not started without the storage group. It is not gated on the CDN: a never-public asset needs none. The UTC hour arithmetic of `ReconcileUsageJob` moved to `next-utc-hour.ts` |
| Repository | `infrastructure/purge/asset-purge.repository.ts`: the three functions, one autocommit statement each, so no transaction is open while objects are deleted or Cloudflare is called |
| Metrics and logs | Counter `assets_purge_attempts` (meter `ninaku-media-worker.assets`) with one attribute, `ninaku.assets.purge_result` (`purged`, `referenced`, `ineligible`, `not_found`, `referenced_after_purge`, `retried`, `dead_lettered`); logs `assets.purge.settled` (info), `assets.purge.referenced_after_purge` (error, the alert of §6.8) `assets.purge.retried` (warn) and `assets.purge.dead_lettered` (error), each with the job id, the code and the failure's name and code without its message. A purge retry is also counted in `assets_storage_errors_total` |
| Erasure entry (OD23) | Not built: the stage and OD23 define the trigger path with the personal-data policy; until then an erasure is "archive, then the purge 30 days later" (runbook §11.3, §11.4) |
| Proof | `purge-asset.handler.spec.ts`: the two stage titles plus the unconfigured CDN (nothing deleted), a never-ready asset purged without the CDN, `referenced` and `ineligible` settled without storage or CDN calls, `referenced_after_purge` reported, an unknown provider retried before any delete. `cloudflare-cdn-cache-purger.spec.ts` against a fake `OutboundHttpService`: the exact request (URL, method, headers, body, timeout), no call for an empty list and two calls for 101 URLs, a `403`, `"success": false`, a non-JSON body, a `429` and a timeout each failing without the token or `Bearer` in the error. `image-job-loop.spec.ts` (`hands the handler the claimed lease token and records nothing for a job the handler settled itself`), `purge-events.spec.ts`, `enqueue-purge-candidates.job.spec.ts`, `assets-worker.module.spec.ts` (the handler map, the Cloudflare adapter with the group, the unconfigured adapter and its boot log without it), `environment-variables.schema.spec.ts`, `media-worker-settings.service.spec.ts` and the three secret specs. `test/integration/assets-purge.integration-spec.ts` against MinIO and PostgreSQL (skipped without `ASSETS_MEDIA_WORKER_E2E_*`): a processed JPEG archived 31 days ago is enqueued and purged, its five objects are gone from both buckets, no row is left, the recording CDN received exactly its four URLs and the usage counters fell by its ready bytes and equal the reconciliation; an asset archived 45 days ago that a catalog item uses answers `referenced`, keeps its five objects and rows, is not enqueued again the same month and is enqueued once its purge job is 31 days old; a corrupt upload failed 8 days ago is purged without a CDN call and with usage unchanged. Mutations, each failing a named title: the finish before the CDN purge (the first stage title); the unconfigured-CDN gate removed (the unconfigured title); `retry_processing` instead of `retry_storage` (the retry title and two more); the `"success": true` check dropped (the adapter refusal title); a settled job recorded (the loop title). The unit specs of the handler, the adapter, the loop and the job were seen failing before their code existed; the events spec and the MinIO end-to-end spec were written after the code they cover |
| Review proof | `purge-targets.spec.ts`: the four URLs, none for a never-public asset, and `refuses for an operator instead of answering an incomplete list` for a missing or malformed token, profile 2, an unknown name, no base URL, a public object without names and a public object outside the derived URLs. `purge-asset.handler.spec.ts` (doubles in `test/support/purge-asset-handler-harness.ts`): the dead letter without any delete, the 14-day cap (one minute before: retry; at 14 days: dead letter), a failing dead letter retried, a failing settled event not retried, `not_found` settled. `asset-purge.repository.spec.ts` against a fake pool (row mapping with `job_created_at`, null arrays, unknown outcome and result, unpaired keys and providers, no row, the dead-letter answer, the enqueue counts coerced to numbers). `asset-purge.repository.integration-spec.ts` against PostgreSQL through the worker role: begin and finish of an archived ready asset with the counters equal to the reconciliation, and a dead letter left out of two sweeps. `enqueue-purge-candidates.job.spec.ts`: hourly runs, batches until a short one, the 20-batch bound, a failure keeping the schedule, the deferred log. Mutations, each failing a named title: an incomplete URL list answered as empty (the seven `refuses for an operator` titles and the handler dead-letter title); the 14-day cap disabled (the cap title); the settled event inside the `try` (the settled-event title); a full batch treated as short (the three sweep titles); the key/provider pairing check dropped (the repository refusal title) |
| Job deadline (network timeouts fix) | Every attempt carries a deadline: the lease minus `IMAGE_JOB_LEASE_MARGIN_MS` (10 s, built in `foundation/config/schema/image-job-lease-margin.ts` from the 5 s default statement timeout, a 2 s lock and connection allowance and 3 s of headroom). The media-worker refuses to boot when `DATABASE_STATEMENT_TIMEOUT_MS` plus that allowance reaches the margin. The purge checks the deadline before the CDN call and before `finish`, and the Cloudflare call is capped at the smaller of 10 s and the time left. A job that runs out of time is answered `retry_storage` with the reused code `assets.storage_unavailable`, not a code of its own: the code list lives in `domain/processing-job.ts`, outside the scope of that fix. Operators therefore see a job that ran out of time exactly like an unreachable storage provider (`assets.purge.retried` with that code and the failure name `JobDeadlineExceededError`, or a storage error named `TimeoutError` or `AbortError`), retried at the storage backoff. The purge keeps that answer on purpose: all of its time goes to storage and Cloudflare calls. Image processing (`process_upload`) has had its own code since the job deadline hardening (`assets.job_deadline_exceeded`, see "Job deadline hardening as built") |
| Only the real services can prove | The Cloudflare request against the real API with the A0 token (status, `"success": true`, the `403` of a token without Cache Purge), that a purged URL answers `HTTP/2 404` afterwards (runbook §10.5), the S3 `DeleteObject` permission of the worker key on `m/` and `in/` and the R2 token's on `a/` (§13.4), and the latency of a purge on Railway |

### A5a as built (published contracts, no migration, no route)

| Piece | As built |
|---|---|
| Surface | `contracts/asset-usage.contract.ts` (`AssetUsageContract`, `ASSET_MEDIA_KIND`, `ASSET_ACCESS_CLASS`, `ATTACHMENT_REFUSAL` and their types, `AttachableAsset { assetId, imageRef }`), `contracts/asset-delivery.contract.ts` (`AssetDeliveryContract`, `ASSET_DELIVERY_LIMIT.ASSET_IDS = 2000`, `AssetDeliveryEntry { status, imageRef, aiGenerated, urls }`, and the re-exported `ASSET_STATUS` and `RENDITION_NAME`, so a consumer never imports `assets/domain`), `contracts/asset-contract.errors.ts` (`AssetNotAttachableError { refusals: [{ assetId, reason }] }`, `AssetDeliveryRequestTooLargeError`) and `contracts/assets-contracts.module.ts`, which re-exports `AssetsModule` as `catalog/contracts/catalog.module.ts` does. `AssetsModule` binds both abstract classes to `application/usage/asset-usage.ts` and `application/delivery/asset-delivery.ts` and exports them. No type carries a key, bucket, provider or variant id; a type-level test in `asset-delivery.contract.spec.ts` pins the keys of the three result shapes |
| `assertAttachable` | One statement (`infrastructure/usage/asset-usage.repository.ts`): `SELECT … FROM assets.assets WHERE id = ANY($1) ORDER BY id FOR SHARE` in the caller's tenant transaction, so RLS answers a foreign asset as absent. Repeated ids are collapsed to their first occurrence before the lock. Refusals per distinct requested id, in that order, first match wins: `not_found`, `purging`, `archived`, `not_ready`, `wrong_media_kind`, `wrong_access_class`; any refusal throws `AssetNotAttachableError` with all of them. Otherwise it returns `{ assetId, imageRef }` per distinct id in the order of the request (type `AttachableAssetsInFirstRequestedOrder`), not in the lock's id order. An empty list returns `[]` with no statement. `wrong_media_kind` is unreachable today (`assets_media_kind_chk` allows only `image`); `not_approved` joins with A8d |
| `describe` | One statement (`infrastructure/delivery/asset-delivery.repository.ts`): primary key lookups `WHERE id = ANY($1)` under RLS, no `organization_id` parameter (the contract receives only the transaction context; RLS filters the tenant). More than 2,000 ids throws `AssetDeliveryRequestTooLargeError` before any SQL; an empty list returns an empty map with no statement; unknown and foreign ids are absent from the map. A `ready` `public` asset gets `imageRef` (`<token>/p<profile>`) and one URL per requested rendition, `<ASSETS_PUBLIC_BASE_URL>/` + `publicRenditionKey(...)`; every other asset (also `purging`, `processing`, `failed`, private) keeps its `status` and `aiGenerated` with `imageRef` and `urls` `null`. Archived assets still resolve (§6.6) |
| Template and fingerprint | `tileUrlTemplate()` → `<ASSETS_PUBLIC_BASE_URL>/a/{imageRef}/tile.webp`; `fingerprint()` → the first 8 hex of SHA-256 of the base URL, a vertical bar and the profile list, that list being the profile numbers joined by commas (`1` today) |
| Base URL | `infrastructure/delivery/asset-delivery-settings.ts` reads `AppConfigService.assetsStorageSettings()?.publicBaseUrl` (A2a-1a; no new variable). When the API group is absent (local, test), `urls` and `tileUrlTemplate()` are `null` and the fingerprint hashes an empty base URL, so a consumer never fails because Assets is unconfigured and its ETag still changes once the base URL is set |
| Grant | `assets-lifecycle-contracts.ts`: `lets the runtime lock assets for share, which needs its column update grant on display_name` locks a ready asset `FOR SHARE` as the runtime, then revokes `UPDATE (display_name)` inside an aborted transaction and observes `permission denied for table assets` |
| Proof | `asset-usage.repository.integration-spec.ts` and `asset-delivery.repository.integration-spec.ts` (through the application classes, seeded by `test/support/assets-fixture.ts`, which writes every variant of profile 1 so a ready asset passes `assets_ready_requires_renditions`), `application/usage/asset-usage.spec.ts` and `application/delivery/asset-delivery.spec.ts` (fake ports: refusal precedence and aggregation, request order with duplicates, empty and 2,000-id short circuits, masking of non-ready or non-public assets, no URLs without a base URL, `deliveryFingerprint(baseUrl, profiles)` stable and changing with the profile list), the delivery integration spec also requesting another organization's ready public asset, which RLS leaves out of the map, `asset-delivery.contract.spec.ts` (the fingerprint title, the template matching the tile URL, the type-level surface) and `assets-contracts.module.spec.ts` (the module exports exactly the two contracts; a `Test.createTestingModule` with the database, audit, identity and platform modules overridden resolves both contracts to the same `AssetUsage` and `AssetDelivery` instances and runs each through its real repository on a fake session and the configured base URL; replacing a `useExisting` alias with `useClass` fails it). Mutations that each fail a named title: `FOR SHARE` dropped (the usage title: another writer's `FOR UPDATE NOWAIT` is no longer refused inside the transaction); URLs returned for `processing` (the delivery title); the archived check dropped (the usage title) |

### A5b as built (migration `0095_catalog_item_images`, baseline revision 105)

| Piece | As built |
|---|---|
| Migration (A5b-1) | Expand only: `GRANT SELECT, INSERT, UPDATE, DELETE ON catalog.item_assets TO ninaku_runtime` (no `TRUNCATE`); the statement-level triggers `item_assets_catalog_version_{insert,update,delete}_trg` over `changed_rows` executing `catalog.bump_catalog_version()`; `item_assets_organization_asset_idx (organization_id, asset_id)` (SG11); the audit actions `catalog.item.images_set` and `catalog.item.variant_images_set` (catalog, configuration, tenant, medium, before and after, `administration.platform`). Canonical end state in `0080_catalog.sql`, `0380_audit.sql` and `runtime_grants.sql`; the K1 unreachable list and the prepare contract's unreachable tables lose `item_assets`. Upgrading a revision 104 database that already holds an `item_assets` row keeps the row and the catalog version, and its `pg_dump --schema-only` (grants included) is identical to a fresh install |
| Routes (A5b-2) | `PUT /catalog/items/{itemId}/images` and `PUT /catalog/items/{itemId}/variants/{variantId}/images` as built in A5b; since API v7 one route, `PUT /catalog/items/{itemId}/images` with `{ images, variants? }`, sets the item list and the lists of its variants (`ItemImagesController`, one `SetItemImagesUseCase` for both scopes), `{ images: [{ assetId }] }`, 0–10, distinct, optional `If-Match` (the C3 tag). Order: permission → `catalog_versions FOR UPDATE` → item `FOR UPDATE` (`lockItemForWrite`) → archived item `422`, unknown variant `404`, archived variant `422 · catalog.variant_archived` → same list answers `200` with nothing written → capability only when the list adds or reorders (a removal only is ungated, the C13 rule, so a lapsed plan can always detach) → `assertAttachable` for the ids the list adds only (`attachable-images.ts`) → three set-based statements (`infrastructure/items/item-image.queries.ts`): delete the rows not in the list, demote the old primary when it stays (the one-primary indexes are not deferrable), and `INSERT … ON CONFLICT (item_id, asset_id) WHERE item_variant_id IS NULL DO UPDATE … WHERE (media_role, sort_order) IS DISTINCT FROM …` (the variant twin conflicts on `(item_variant_id, asset_id) WHERE item_variant_id IS NOT NULL`), `sort_order` = position, the first `primary` → one audit with the ids before and after (`catalog.item.images`, or `catalog.item.variant` and `catalog.item.variant.images`) → the item read. Catalog names no `assets.*` table; `lint:sql-ownership` keeps its zero catalog baseline |
| Refusals | `AssetNotAttachableError` becomes `CatalogImagesRefusedError` and, at the edge, `422 · catalog.image_not_attachable` with one `meta.errors[]` entry per refused asset in the shared field-error shape: `field` `items.<itemId>.images.<assetId>`, `code` the reason (`not_found`, `not_ready`, `archived`, `purging`, `wrong_media_kind`, `wrong_access_class`), and a message naming both ids (deviation: the shared `ProblemDetailsMeta` admits only field errors, so `{ assetId, reason }` travels as `field` and `code`) |
| C3 (AC3) | `Item` and every `ItemVariant` gain `images[]` (`assetId`, `isPrimary`, `sortOrder`, `status`, `thumbnailUrl`, `cardUrl`, `detailUrl`). The item statement carries them as two scalar JSON subqueries on `item_assets`, so no catalog statement is added; `ItemRepository` (read and lock) then calls `AssetDeliveryContract.describe` once for every image of the item, only when it has images (an item without images costs what it cost before). Every answer that carries the C3 shape (create, edit, lifecycle, classification, offers, variants, barcodes, images) therefore carries the images with their URLs, and the item tag, a hash of that whole representation, changes with the images and with the delivery base URL |
| Statements | 17 for the measured `PUT` on an item that already has an image (session, membership, `BEGIN`, tenant context, permission, version lock, the item statement and one `describe`, the capability, `assertAttachable`, delete, demote, upsert, audit, the item statement and one `describe`, `COMMIT`); 25 before API v7 PR 6, when each item read was five statements |
| Proof | `test/e2e/catalog-item-images.e2e-spec.ts` (the five A5b titles of section 18), `database/tests/contracts/catalog-item-images-contracts.ts` (both titles), `test/e2e/statement-budget.e2e-spec.ts` (`sets an item's images in the measured statements`), `set-item-images.use-case.spec.ts`, `item-image.repository.integration-spec.ts`, `item-response.spec.ts`, `domain/items/item-image.spec.ts`. Mutations, each failing a named title: `assertAttachable` skipped for the new ids or run on the kept ones (the use-case order title), the asset locked before the version row (the same), the delete bump trigger dropped (the contract's bump title), the demotion removed (the integration title, `item_assets_one_item_primary_uq`) |
| Review hardening | Duplicates never reach SQL: the request schemas answer `400 · foundation.validation_failed` for a repeated asset (ids are lowercased first, so two cases of one id repeat), more than 10 images, and in the bulk for zero or more than 100 items or a repeated item; both use cases repeat the check before opening a transaction and refuse with the catalog refusal `image_list_invalid`, answered with the same `400` (refused rather than deduplicated, because position is meaning: the first image is the primary). The three follow-up statements of the item read (`ITEM_CATEGORIES_OF`, `ITEM_OFFERS_OF`, `ITEM_BARCODES_OF`, `infrastructure/items/item-read.queries.ts`) are builders that take `readonly string[]` and bind it as their single `ANY($1::uuid[])` parameter, so no caller can bind one id. A failing `describe` propagates and rolls the command back. The DB contract also proves write isolation (a foreign `UPDATE` and `DELETE` change nothing, a foreign `INSERT` is refused by the row policy) and one bump per statement kind through its own trigger, none for a no-op |

### A5c as built (no migration)

| Piece | As built |
|---|---|
| Route | `PUT /catalog/item-images` (`SetManyItemImagesUseCase`), `{ items: [{ itemId, images: [{ assetId }] }] }`, 1–100 items, each once, each list as AC1. One transaction: permission → version row → `LOCK_ITEMS_FOR_IMAGES` (every item `FOR UPDATE` in id order with its current item image ids, one statement) → unknown items `404 · catalog.not_found`, then archived items `422 · catalog.item_archived`, each with `meta.errors[]` naming every item (`field` `items.<itemId>`) → items whose list did not change are left out → capability unless every change only removes → one `assertAttachable` for every new id of every item → the AC1 delete, demotion and upsert, each once over all items → one `catalog.item.images_set` per changed item → one set-based read of every item sent (`READ_ITEMS` and the four follow-up statements by `ANY`) and one `describe` → `200 { data: { items: [{ itemId, etag }] } }` in request order, each tag the one `GET` answers; `Cache-Control: private, no-store` |
| Statements and version | 19 plus one audit per changed item, the same for 1 and for 100 items; the version moves by 1 to 4 whatever the number of items |
| Proof | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the images of many items in one request moving the catalog version at most four times whatever the number of items, and refuses the whole request when one asset is not attachable`; `test/e2e/statement-budget.e2e-spec.ts` › `sets images for 100 items with a statement count independent of the number of items except the audits`; `set-many-item-images.use-case.spec.ts`. Mutations: per-item write statements (the budget title: 316 against 19), a refusal swallowed so the rest commits (the use-case refusal title) |

### A6 as built (till read with images, no migration)

| Piece | As built |
|---|---|
| Shape | C17 and C19 items carry `imageRef` (`<token>/p<profile>` or `null`) and `aiGenerated` (false without an image; always false until A8 writes AI assets); a variant carries both keys only when it has a primary image of its own (no keys otherwise, so a variant without one adds zero bytes and the till shows the item image); `meta.media = { tileUrlTemplate }` once per answer (`null` while `ASSETS_PUBLIC_BASE_URL` is unset). An attached asset that is not `ready` and `public` answers `imageRef: null`, `aiGenerated: false`; an archived attached asset keeps delivering (§6.6). No asset id, URL, key or bucket in the till body |
| SQL | The shared enriched page of C17 and C19 (`infrastructure/till/till-read.queries.ts`) left-joins `catalog.item_assets` on `organization_id`, `item_id`, `item_variant_id IS NULL` and `media_role = 'primary'` (the predicate of `item_assets_one_item_primary_uq`) and, inside the variant aggregate, on `item_variant_id`, `IS NOT NULL` and `primary` (`item_assets_one_variant_primary_uq`); both unique indexes guarantee at most one row, so no item or variant repeats. `EXPLAIN (ANALYZE, BUFFERS)` on the 925-item till with 1,000 primaries: the planner hashes the organization's primaries (one sequential pass, 54 buffers, 0.1 ms) for the item join and probes `item_assets_one_variant_primary_uq` per variant (875 loops, 0 rows); with `enable_seqscan = off` the item join uses an index too. Still one read statement; Catalog names no `assets.*` table |
| Delivery | `ReadWhatAnOutletSellsUseCase` injects `AssetDeliveryContract`; `application/till/sellable-images.ts` collects the distinct primary asset ids of the answered items and variants (after the 2,000-item cut) and calls `describe` once with no renditions (in chunks of 2,000 ids, so one call in practice); no ids, no call. The chunked call moved from `infrastructure/items/item-reads.ts` to `application/images/asset-descriptions.ts`, shared by C3 and the till |
| ETag | `application/till/sellable-entity-tag.ts`: `"c<catalogVersion>-s<structureVersion>-<businessUnitId or none>-d<fingerprint>"`; `fingerprint()` and `tileUrlTemplate()` run no SQL, so the `304` is still decided after the outlet state and before any item or image read. An image command already moves the catalog version (SG11 triggers), so an image change gives a new tag; another organization's change never does |
| ETag invariant | The C17 body now depends on delivery state while the tag is `c-s-bu-d<fingerprint>`; that is sound because every way that state can change for a listed image already moves one of the tag parts: (a) only `ready` `public` assets are ever linked (`assertAttachable`, AS13; `access_class` is immutable, `assets_protect_access_class_trg`); (b) every link change (attach, detach, reorder, primary moved) bumps the catalog version through the `item_assets` statement triggers of migration `0095` (SG11); (c) a `ready` asset can only become `purging` (`asset-status.ts` transitions; the runtime's only column grant on `assets.assets` is `display_name`), and archiving changes neither its status nor its delivery (§6.6); (d) the purge of A4b (built: "A4b-1 as built") answers `referenced` on the `item_assets_asset_organization_fk` violation and never removes a linked asset; (e) the media host is the fingerprint. **Obligation on A4b and later stages:** a purge must never remove a referenced asset; if it ever could, it must bump the catalog version in the same transaction (the requirement line of A4b in `ASSETS_STAGE_SPECS.md`). Proven by `catalog-till-images.e2e-spec.ts` › `moves the till tag on every image link change and keeps the tag and the image reference when an attached asset is archived` |
| Join fan-out | The item and variant joins repeat the predicates of `item_assets_one_item_primary_uq` and `item_assets_one_variant_primary_uq`, so neither can match two rows: `till-read.repository.integration-spec.ts` proves PostgreSQL refuses a second primary for an item and for a variant (`23505` naming each index) and that an item with a primary, three gallery images and a variant with its own primary is listed once by C17 (also at a `limit` cut below the number of items) and once across C19 pages of one item |
| Wire contract | `presentation/dtos/till/sellable-response.spec.ts`: a variant without a primary of its own has no image keys, an undeliverable own image answers `null` and `false`, an item always carries both keys, the strict schemas accept the shape and refuse a missing `imageRef` or a non-boolean `aiGenerated`, and `meta.media.tileUrlTemplate` is a string or `null` but never absent. The use-case spec also covers the outlet no unit operates (empty lists, the template, a `-none-d…` tag, no read, no `describe`) and a `describe` limited to the 2,000 answered items |
| Review mutations | Each fails a named title: the tag ignoring the catalog version (two `catalog-till-images` titles), the item join without `media_role = 'primary'` (the fan-out integration title), a variant always carrying image keys (the wire title), `describe` over the 2,001st item (the 2,000 title) |
| Statements | Pinned in `statement-budget.e2e-spec.ts` › `reads what an outlet sells with images in 9 statements and answers 304 in 7`: C17 `200` 9, `304` 7, C19 9 with images; the table rows without images stay 8, 7 and 8 |
| Proof | `test/e2e/catalog-till-images.e2e-spec.ts` (the stage title plus the variant, media host, not-ready/private/archived and C19 titles), `sellable-entity-tag.spec.ts` (the stage title, with two real `AssetDelivery` fingerprints), `read-what-an-outlet-sells.use-case.spec.ts` (one `describe`, none without images, the `304` before any read, the page). Existing till e2e titles updated for the tag suffix, `meta.media` and the two item keys. Mutations, each failing a named title: fingerprint dropped from the tag (the entity tag title, two use-case titles, the media host e2e title); a `describe` call with no ids (`describes nothing when no listed item or variant has an image`; the former `assetIds.length > 0` guard was an equivalent mutant because the chunked call already sends nothing for no ids, so it was removed); a non-deliverable entry keeping `aiGenerated` (two use-case titles); the item join without `media_role = 'primary'` (the stage e2e title: the gallery image duplicates the item); the variant join without `item_variant_id = v.id` (the variant e2e title); the `304` decided after the read (the use-case `304` title and the statement title) |
| OpenAPI | `npm run openapi:compat` against `origin/staging`: no breaking change; ten `property_added` (`imageRef`, `aiGenerated` on items and variants and `meta.media` on both routes) |
| `perf:http` gate | Paired runs on one loaded development machine (Apple M4 Pro, load average 25.6 and 27.3 at start, other agents running), staging `94694e61` then A6, each on a fresh `ninaku_a6`; A6's till world gives every odd-numbered item (every item both tills list) a ready public primary image. 1 client, p50 / p90 / p95 / p99 / max ms: `catalog-till-sellables` 27.68 / 29.33 / 31.10 / 36.77 / 43.12 → 31.06 / 32.40 / 33.12 / 36.76 / 41.97, 417,908 → 473,066 bytes (+12 % p50, +13 % bytes); `catalog-till-sellables-restaurant` 18.81 / 19.89 / 20.23 / 22.07 / 28.74 → 20.16 / 21.28 / 21.80 / 24.48 / 28.81, 214,237 → 231,658 bytes (+7 % p50, +8 % bytes); `catalog-till-sellables-304` 1.05 → 0.95 ms; C19 page of 50 7.91 → 8.24 ms, 21,677 → 24,860 bytes. 16 clients (informative): 71.95 / 80.89 / 83.55 / 91.13 / 94.22 → 94.54 / 107.86 / 112.55 / 124.48 / 143.20 and 51.89 / 66.47 / 71.18 / 79.48 / 87.66 → 54.69 / 63.93 / 66.20 / 70.48 / 76.81. **Go:** the restaurant's p90/p95/p99 meet 35/50/100 ms and the `304` stays near 1 ms; `aiGenerated` stays in the body. The change is +7 to +12 % at p50, under the 25 % tolerance; the recorded K7 baselines (23.99 and 15.24 ms at load average 4.6) failed the gate on this machine even for staging's own code (27.68 and 18.81 ms), so `KNOWN_BREACHES` is re-baselined with the A6 numbers above and their cause. Local evidence only, never production capacity |

### Job deadline hardening as built (no migration)

| Piece | As built |
|---|---|
| Problem | The job deadline (lease minus 10 s) only reached the storage calls. `probe`, `encodeMaster` and `encodeRendition` took no deadline, nothing checked it between stages, and an image that took too long in the codec made the next storage `put` fail at once as a storage error: `retry_storage` with `assets.storage_unavailable`, which never counts toward the five processing errors, so a slow image was retried until its staging object expired (`assets.upload_missing`) |
| Between stages | `application/processing/`: `verifyAndMaster` checks the deadline after the staging read and after `probe`; `deriveRenditions` checks it before each rendition; `ProcessUploadHandler` checks it once more after the last rendition, before the first `put`. A check that finds the deadline gone throws `JobDeadlineExceededError`, so no rendition is encoded and no object is written after it |
| Running codec work | The codec port takes the deadline instant: `encodeMaster(bytes, deadlineAt)` and `encodeRendition(master, geometry, deadlineAt)`. `SharpImageCodec` sets sharp's `timeout({ seconds })` on both pipelines with `pipelineTimeoutSeconds`: the time left rounded up to whole seconds, at least 1 (0 would switch the timeout off) and at most 3,600 (sharp's maximum). sharp `0.35.5` implements it in libvips' progress callback (`src/common.cc`, `VipsProgressCallBack`): once the pipeline has run that many whole seconds it is killed and rejects with `timeout: N% complete`, which the codec turns into `ImageCodecTimeoutError` (never `ImageDecodeFailedError`, which would fail the image for good as corrupt). Because the seconds are rounded up, the pipeline never stops before the deadline; it can run up to one second past it. The clock starts when libvips opens the input, so time spent waiting for a libuv thread is not counted, and `probe` (`metadata()`, a header read) has no timeout |
| Not built | A codec in a `worker_threads` worker or a child process that could be killed at any instant: it would cost a redesign of the media-worker (memory accounting, crash handling) for one second of precision. The between-stage checks and sharp's timeout bound a job to its deadline plus about one second |
| Classification | `failure-classifier.ts`, in this order: a permanent refusal stays permanent; `JobDeadlineExceededError` or `ImageCodecTimeoutError` is `retry_processing` with `assets.job_deadline_exceeded`; the storage errors keep their classes (a storage request that stalled until the deadline is still `retry_storage`, because the time went to the network); then, when the job's deadline signal has fired, any other failure (an image error raised while the pipeline was killed) is `retry_processing` with `assets.job_deadline_exceeded` instead of `assets.image_corrupt`. The loop passes the deadline signal to the classifier. `retry_processing` counts toward the five processing errors, waits 5 s, 30 s, 2 min then 10 min, and the fifth dead-letters the job like any processing failure |
| Proof | `process-upload.deadline.spec.ts`: the deadline instant reaches the master and the four renditions; a deadline that expires during the staging read, the probe, the master, the second rendition and the last rendition stops at that stage, writes no object and is answered `retry_processing` `assets.job_deadline_exceeded`; a storage outage within the deadline is still `retry_storage` `assets.storage_unavailable`. `failure-classifier.spec.ts` (six deadline titles), `image-job-loop.deadline.spec.ts` (`records an image failure raised after the deadline expired as the deadline, counted as a processing error`), `sharp-image-codec.spec.ts` (the timeout message and `pipelineTimeoutSeconds`), `process-upload.handler.spec.ts` (`counts a job that ran out of its deadline as a processing error: it retries at three earlier errors and dead-letters at four`), and `sharp-image-codec.timeout.integration-spec.ts` (integration suite, no storage needed, kept out of `npm test` for its memory): a real 6,300 by 6,300 PNG with an alpha channel, the heaviest master input accepted (about 3.7 s to encode on the development machine), is stopped by the timeout after about 1 s with a deadline 1 ms away and rejects with `ImageCodecTimeoutError`; it tries up to three fresh images and fails loudly, with the measured times, if none is stopped. The test pinned before, `fails the next put fast as a storage retry when the deadline expires during codec work`, was replaced: it recorded the defect |
| Mutations | Each fails a named title: the check before each rendition removed (the master and second-rendition titles); the checks of `verifyAndMaster` removed (the read and probe titles); the check before the first `put` removed (the last-rendition title); the deadline errors not mapped (the two classifier deadline titles); the deadline signal ignored (the classifier image-failure title and the loop title); the loop not passing the signal (the loop title); sharp's timeout not set (the real-image timeout title, which then reports three encodings of about 3.7 s); the deadline answered as `retry_storage` (the dead-letter title) |

### Upload limits in the contract as built (no migration)

| Piece | As built |
|---|---|
| Problem | The OpenAPI description of `POST /assets/uploads` stated only the 10 MiB limit. The format list, the side limits (64 to 12,000 pixels) and the pixel limits (40 megapixels, 12 for AVIF) were nowhere in the contract, and a client learnt about them only when the asset ended `failed` later |
| Derived text | `presentation/controllers/upload-limits-documentation.ts` builds the text from the domain constants (`ACCEPTED_IMAGE_MEDIA_TYPE` and `MAX_UPLOAD_BYTES` in `domain/media-type.ts`, `IMAGE_LIMIT`, `maxInputPixelsOf` and `MASTER_ENCODING` in `domain/image-limits.ts`), grouping the formats that share a pixel limit; no number is typed twice |
| Where the client reads it | The operation description of `POST /assets/uploads` (the limits, that the dimensions are checked only after completion by the media worker, that the asset then moves to `failed` with `failureCode` `assets.image_too_large` or `assets.image_too_small`, that `GET /assets/{assetId}` shows it, and that the client must check the format and dimensions and scale down before uploading, the master being at most 4,096 pixels on its longer side); the `mediaType` and `byteSize` descriptions of the request body; the completion route's description; and a new `failureCode` description on every asset schema |
| OpenAPI | `npm run openapi:compat` against `origin/staging`: no breaking change (descriptions only), so `OPENAPI_DOCUMENT_VERSION` stays `9.0.0` |
| Proof | `upload-limits-documentation.spec.ts`: the media type list, the size and the side and pixel limits exactly as derived, and the asynchronous refusal text. Mutation: `IMAGE_LIMIT.MAX_SIDE` changed to 12,001 fails `states the side and pixel limits from the domain, grouping the formats that share a pixel limit` |

### Behaviours product should know (A5 findings, no change)

| Behaviour | As built | Evidence |
|---|---|---|
| The `original` variant is not the uploaded file | It is the master: the upload re-encoded as WebP q90, fitted inside 4,096 pixels, without metadata, kept in the private S3 bucket (`m/<token>`, `private, no-store`). The uploaded bytes are deleted from the staging prefix once the job settles; no untouched copy is kept, so a product feature that promises "download the original" can only offer this master | `ProcessUploadHandler.handle` (`ORIGINAL_VARIANT_CODE` with `image/webp` and `masterKey`), `afterRecord` (staging delete) |
| Archived assets stay displayable but cannot be newly linked | Archiving changes neither the status nor the delivery (§6.6): an item already linked keeps its image in the till and the catalog. A new link of an archived asset is refused with the reason `archived` | `AssetUsageContract` (`ATTACHMENT_REFUSAL.ARCHIVED`), `asset-delivery.repository.integration-spec.ts` (an archived ready public asset is still delivered) |
| The storage quota counts every stored variant, and processing may pass the plan cap | Admission checks `stored + reserved + declared upload bytes` against `assets.stored_megabytes`. When the job is ready, `assets.record_processing_outcome` (introduced by migration `0088`, current text `database/canonical/0070_assets.sql` around line 2064) releases the reservation and adds the bytes of **all** variants (the private master and the four public renditions) to `stored_bytes` without checking the cap again; the reconciliation (`reconcile_usage_batch`, `0089`) recomputes the same sum. An organization near its cap can therefore end slightly over it; the next upload is then refused | `0070_assets.sql` lines 1497 and 2064-2068 |
| Proposal for the cap | Do not refuse at processing time: the upload was admitted, the work is done, and failing a finished image would only waste it. The overshoot is bounded by the open uploads (at most 20) times the derived bytes of one image (a master of at most 4,096 pixels and four small renditions, a few MB). If product wants no overshoot, reserve an estimate of the derived bytes at admission instead of the declared upload size; that is an SQL change for a later stage | — |
| Renditions are public by design | The four renditions are served from the public R2 bucket through the CDN (`public, max-age=31536000, immutable`) under an unguessable delivery token: anyone with the URL can fetch them, and a purge removes them from the CDN by URL. They are not private; only the master is | OD2, `RENDITION_CACHE_CONTROL` in `process-upload.handler.ts` |
| Images only | Assets stores images (JPEG, PNG, WebP, AVIF in; WebP out). Private documents (A7) and AI-generated images (A8) are blocked until the personal-data policy is published (OD15, OD19) | §14.2, `ASSETS_STAGE_SPECS.md` §27 |

### 14.2 Plan completeness checklist

The single readiness table (stage, PRs, READY or blocked, by what) is
[`ASSETS_STAGE_SPECS.md` §27](ASSETS_STAGE_SPECS.md#27-readiness-table).
In short: every stage is specified; A0 is ready (OD1 and OD13 confirmed);
A7 and the A8 stages are blocked until the personal-data policy is published
(OD15; A7 also needs the owner to confirm which private files will be
stored, and A8 the approved AI-label wording, OD19); the spikes and gates are A0's
smoke test (storage bound, `HEAD` 404/403, R2 enabled, SES after the key
swap, latency), A2a-2's storage contract (S3 and R2), A3c's memory gate and
A6's `perf:http` gate.

## 15. Owner decisions

**Owner answers of 2026-10-06:** OD1 (option D) and OD13 (`media.ninaku.io`, `media-staging.ninaku.io`) are confirmed, OD12 is answered, OD15 is owner-reported with conditions (the personal-data policy is being drafted by counsel). **Still open:** the publication of that policy (gates A7 and A8 in production) and OD23 (the erasure trigger path, defined with the policy). OD12 is answered; every other row keeps its recommended value unless the owner objects, so "yes, use the recommended values" closes them. Rows marked *(changed)* differ from the previous version of this document (owner clarification of 2026-10-06: AWS S3 and SES already in use, Cloudflare in front, OpenAI GPT Image to be used); *(new)* rows did not exist.

| # | Needed before | Decision | Options and consequences | Recommended |
|---|---|---|---|---|
| **OD1** *(answered 2026-10-06: confirmed D)* | A0 | Storage, CDN and region | (A) S3 + Cloudflare in front: Cloud Connector needs a public bucket (S23), a private bucket needs a signing Worker billed per request (≈ $129/month at platform scale). (B) S3 + CloudFront OAC, DNS-only in Cloudflare (≈ $916/month at platform scale, South American egress). (C) R2 only (≈ $30, the cheapest, but no storage-side upload size bound and no S3). (D) S3 for uploads, masters and private renditions + R2 for public renditions behind Cloudflare (≈ $46, plus S3 egress on master re-reads: ≈ $126 once per platform-wide regeneration) | **(D)**: not the cheapest; chosen because the owner wants S3 and only S3 bounds the upload size with a POST policy. S3 in the owner's existing AWS account, region `us-east-1`, one bucket per environment with `in/`, `m/`, `p/` prefixes and per-principal IAM; R2 public bucket per environment, location hint `enam`. Fallbacks without R2: (A), or (B) for AWS-only delivery. Each stage specification states its plan B delta: only the public-rendition write path, the delivery host and the purge call differ ([`ASSETS_STAGE_SPECS.md`](ASSETS_STAGE_SPECS.md) §1.7). **Owner answer:** option D confirmed (S3 private + R2 public + Cloudflare CDN); plan B stays documented only as a fallback |
| **OD2** *(changed)* | A2b | Default delivery for product images | (a) Public immutable URLs on the R2 custom domain behind Cloudflare, unguessable tokens, **automatic Cloudflare purge-by-URL on erasure**. (b) Signed URLs: breaks till ETags and offline caching. (c) WAF HMAC tokens: needs a Cloudflare Pro plan and still expires | (a); private/sensitive signed from S3 `p/` (A7) |
| **OD3** | A3b | Accepted inputs and output formats | (a) JPEG, PNG, WebP, AVIF in; WebP renditions (profile 1 of §5.5). (b) Also GIF/HEIC: HEIC needs a custom libvips build. (c) AVIF output now: slower encodes | (a); HEIC converted by the client |
| **OD4** | A2b | SVG | (a) Forbid. (b) Sanitize: a whole sanitizer to maintain for XSS/XXE | (a) Forbid |
| **OD5** *(changed)* | A2b, A3b | Maximum upload size, pixels and policy lifetime | 5, 10 or 20 MiB; 40 or 50 MP; POST policy 5 or 10 minutes | 10 MiB, enforced by S3's `content-length-range` (no gate needed); **40 MP for JPEG, PNG and WebP and 12 MP for AVIF** (owner decision after measuring locally that a 12 MP AVIF alone peaks at 572 MB RSS and a 40 MP AVIF at 1,173 MB, against about 372 MB for JPEG and PNG at 40 MP, WebP not measured yet; `media-worker` memory 1,536 MiB in `.railway/railway.ts`, the re-measurement on Railway's Linux still pending); minimum 64 px; policy valid 10 minutes |
| **OD6** | A1b | Plan: capability and limits for Pionero | (a) Included with 5,000 assets and 10,240 MiB. (b) Unlimited (like the catalog, catalog OD6). (c) Paid add-on | (a): storage is cheap but unbounded uploads are an abuse vector; limits are overridable per organization |
| **OD7** | A1a | Processing queue | (a) Module-owned `assets.processing_jobs` with `organization_id` and the shape of `events.outbox_records`: jobs are work items per asset (attempts, leases, kinds), not integration events other modules consume; the Identity OB2 precedent (no `organization_id`) does not apply, so the departure from `AGENTS.md` §5.4 is recorded as D31 and §5.4 is amended in A1a. (b) `events.outbox_records` + domain event `assets.upload_completed`: matches §5.4 literally but needs event types, contract versions and a JSONB payload for an internal job | (a), with D31 and the §5.4 amendment in the same PR |
| **OD8** | A4b | Archive and retention | Restore window and purge delay | Restorable 30 days; purge unreferenced archived assets after 30 days, failed after 7 |
| **OD9** *(changed)* | A8a | AI-generated images | (a) Build the full design of §6.7 (generate and enhance, server-built prompts, monthly quota, mandatory human approval, disclosure) after the first delivery. (b) Hook only. (c) Build now | (a), after OD15 and OD17 |
| **OD10** | A3b | Malware scanning for images | (a) Out of scope with documented residual risk (decode and re-encode everything). (b) Add a scanner service now | (a); mandatory for documents |
| **OD11** | A1b | Who uploads | (a) Owner and organization-wide managers. (b) Also business-unit managers | (a), as catalog OD3 |
| **OD12** *(answered 2026-10-06)* | A0 | Accounts, billing, cost ceilings and alerts for AWS, Cloudflare and OpenAI | Who owns each account and pays; ceilings and alerts | The company account owner holds all three (§13.2); AWS Budgets on the whole account at 50/80/100% of $50/month and an S3-filtered budget; Cloudflare billing and R2 usage notifications (100 GB per environment); OpenAI project hard limits ($20 staging, $200 production) with a 50% alert; revisited with real usage. **Owner answer:** the owner, a single person, owns the AWS, Cloudflare and OpenAI accounts and the domains; billing, MFA, alerts and key rotation are theirs ([`ASSETS_INFRASTRUCTURE_RUNBOOK.md`](../operations/ASSETS_INFRASTRUCTURE_RUNBOOK.md) §0.3, §7, §11.1) |
| **OD13** *(answered 2026-10-06)* | A0 | The media domain | The domain the platform really serves is `ninaku.io`: `api-staging.ninaku.io` serves live traffic and `api.ninaku.io` is reserved (`docs/operations/RAILWAY_OPERATIONS.md`, "Custom domains"), the docs are `docs.ninaku.io` and `docs-staging.ninaku.io` (`.railway/railway.ts:24-27`), problem types use `https://api.ninaku.io/problems/…` (`FOUNDATION_HTTP_AND_OBSERVABILITY.md` §5), and its DNS is on Cloudflare (`RAILWAY_OPERATIONS.md:102`). `ninaku.com` appears only in test fixtures (`src/main.spec.ts:53`, `environment-variables.schema.spec.ts:678`) and `ninaku.app` only as an example in `AWS_SES_CREDENTIALS.md` | **`media.ninaku.io` (production) and `media-staging.ninaku.io` (staging)**: same registrable domain as the API and the docs (one zone, one Cloudflare account, which R2 custom domains require, and no new domain to buy or verify; cookies are not involved because the API uses bearer tokens); a host of its own, never the API host, so cache and transform rules apply only to images and the API's `Cache-Control` rules never mix with immutable images. **Owner answer:** confirmed `media.ninaku.io` (production) and `media-staging.ninaku.io` (staging) |
| **OD14** | A0, A3a | Where images are processed | (a) A separate Railway service `media-worker` (same image, own entrypoint, own memory budget). (b) Share the existing worker that delivers OTP codes and invitations: an image out-of-memory crash delays security messages | (a) |
| **OD15** *(owner-reported 2026-10-06; policy being drafted by counsel)* | A7, A8a | Data residency and legal sign-off | Counsel confirms (a) private or sensitive files in S3 `us-east-1`, and (b) sending tenant product data (names, descriptions, photos) to OpenAI under its data controls (S35) | Required before A7 and A8; both stay blocked until answered. Public product images proceed. **Owner report (not a verified legal opinion):** the owner's lawyer is drafting the personal-data policy; it includes OpenAI as a data processor and lets data subjects request deletion of their data; the owner considers product data not sensitive and sees no legal issue. **Conditions:** the published policy must name S3 `us-east-1` storage and OpenAI; A8 does not reach production before that policy is published and the owner approves the AI-label wording (OD19); A7 stays gated until the same policy is published **and** the owner confirms which private files will be stored |
| **OD16** | A5b | Image writes bump the till version | (a) Accept: every image command is a catalog command and forces one full till re-read per organization; add the bulk command AC5 for wizards and imports. (b) A separate image version for the till: a second version row, a second lock, a second ETag component | (a) with AC5 |
| **OD17** *(new)* | A8a | AI provider, model and OpenAI account | Provider; model as configuration: `gpt-image-1.5` (published per-image price, medium $0.034; OpenAI calls it its previous model), `gpt-image-2` and the `gpt-image-2.5` variants (current, token-priced), `gpt-image-1-mini` (medium $0.011); who owns the OpenAI organization, billing and its verification (S30) | OpenAI; start with `gpt-image-1.5` medium 1024×1024 for its known price while A8a compares it against the 2.5 models (all ZDR-eligible, S35) and switches the configuration if better; the company account owner verifies the organization and owns one project per environment |
| **OD18** *(new)* | A8a | Monthly generation limits per plan and the price model | Free allowance only; allowance plus paid credits; no AI in Pionero | Pionero: 20 generations per organization per month (≈ $0.68 at the recommended model); no paid credits in v1; higher allowances belong to future plans |
| **OD19** *(new)* | A8d | Disclosure of AI-generated images | (a) A label on every customer-facing surface (storefront, digital menu, receipts with images), not switchable off in v1. (b) Configurable per organization. (c) No label | (a) "Imagen ilustrativa generada con IA" shown with every `aiGenerated` image to customers; the till (staff-facing) shows a small marker; switchable later only with legal advice |
| **OD20** *(new)* | A8a | Do AI-generated assets count toward the asset and storage limits? | (a) Yes, as normal assets, checked at request time and again at creation (soft by one asset when OpenAI was already paid). (b) A separate AI-image allowance | (a) |
| **OD21** *(new)* | A3c | How dead-lettered processing jobs are recovered | (a) Runbook query calling `assets.requeue_dead_letters` with the migrator's credentials, no route in v1. (b) A staff route in Platform with step-up (OX-O2 shape) now | (a); (b) when the staff console needs it |
| **OD22** *(new)* | A0 | Cloudflare R2 enablement | The owner confirms R2 is enabled on the Cloudflare account (a payment method may be required, *unverified*) and that the media zone lives in the same account | Confirm before step 5 of §13.7 |
| **OD23** *(new, open until the policy exists)* | A4b, policy publication | Data-subject erasure | A data subject's erasure request under the personal-data policy (OD15) | It triggers the purge of that subject's assets through the existing path (archive → purge, AS10, with the automatic CDN purge); the trigger path (who receives the request, how the subject's assets are identified, the staff or owner action that archives them and skips the 30-day wait) is defined when the policy exists |

***

## 16. Risks and what the existing SQL gets wrong or leaves open

| # | Finding | Evidence | Recommendation |
|---|---|---|---|
| R1 | Runtime and worker cannot reach `assets` | `runtime_grants.sql:36`, `:177-181` | A1a–A1c grants, with negative contract tests |
| R2 | No asset-level status, so readiness needs a three-table join | `0070:84-133` | SG1–SG3 |
| R3 | Header still names BullMQ | `0070:25` | X1: D26 wins; D31 records the module-owned queue |
| R4 | `catalog.item_assets` writes would not invalidate the till `304` | `0075_catalog_foundations.sql:60-112` | SG11 |
| R5 | `item_assets` FK checks tenant only, not readiness or access class | `0080:660-668` | `assertAttachable` under `FOR SHARE` (AS13) and the invariant of §2.4 |
| R6 | Default `access_class = 'private'` vs public product images | `0070:91` | Explicit `accessClass` in AS1, `public` only until A7, immutable (SG17) |
| R7 | Upload size must be bounded at the storage layer | R2 has no POST policy (S2); S3 has `content-length-range` (S11) | Uploads go to S3 with a POST policy (§6.2); verified again in AS7; reserve quota; staging cleanup on every outcome; `in/` lifecycle |
| R8 | Log redaction misses `X-Amz-Signature` | `redaction.ts:27-33`, `sensitive-field-names.ts:1-14` | SEC-1 in A2a |
| R9 | Decoder vulnerabilities in libvips | §11 | Keep sharp updated; isolated `media-worker` with least privilege |
| R10 | Media worker single replica; bulk imports take hours | §12.4 | Scale replicas; `SKIP LOCKED` is safe |
| R11 | Purging storage objects of a ready variant breaks the deferred triggers | `0070:657-672`, `:883-949` | SG16: `purging` status, storage rows deleted with variants and asset in one transaction (§6.8) |
| R12 | A consumer that attaches without `assertAttachable` defeats erasure | §2.4 invariant | Test per consumer slice; `purge_asset_finish` detects it, keeps `purging` and alerts |
| R13 | `audit.export_artifacts` accepts only `sensitive` assets | `0380_audit.sql:2148-2152` | Consistent with A7; nothing to change |
| R14 | `ASSETS_PUBLIC_BASE_URL` change silently alters URLs | §6.4 | Delivery fingerprint in ETags |
| R15 | Public objects are cached with `immutable` for a year | §6.4 | Automatic CDN purge in AS10; device caches are an accepted residual |
| R16 | Every image write forces a full till re-read | §6.6 | AC5 bulk command (OD16) |
| R17 | Keys carry no organization prefix | §5.2 | Database-driven offboarding purge |
| R18 | An S3 client built without explicit credentials would use the SES key | `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` are the SES variables (`environment-variables.schema.ts:213-216`) and the SDK's default chain reads them | Explicit credentials in every Assets client, asserted by a unit test (A2a); the SES principal has no S3 permission (A0) |
| R19 | AI generation cost grows with use | §13.8 | Per-plan monthly limit (OD18), OpenAI project hard limit (S37), quota released on failures |
| R20 | AI food images may mislead customers | §6.7 | Mandatory human approval and the disclosure label (OD19) |
| R21 | Prompt text is tenant data sent to a third party | §6.7 | OD15 sign-off; ZDR if eligible (S35); prompt never logged; prompt text nulled after 90 days |
| R22 | Third-party outages | §13.9 | Degradation per provider as registered in §13.9 |

**What this document could not verify:** the exact cache-hit ratio, object
sizes and upload volume behind the cost table of §6.1; Railway's egress
pricing for the worker's writes; real
image sizes, processing times and peak memory on Railway (A3c); Railway's
base image libc (glibc assumed, which decides sharp's default concurrency,
S15); whether iOS Safari transcodes HEIC on upload; legal residency
requirements in Colombia and Ecuador (OD15); which product domain is on
Cloudflare (OD13); the per-image price of `gpt-image-2` and the 2.5 models (token-priced, calculator only); the text of OpenAI's usage policies (the page refused the fetch); S3 prices in `sa-east-1` and real latencies from Colombia and Ecuador to `us-east-1`; whether the Cloudflare plan in use includes a rate-limiting rule; whether MinIO or LocalStack images are currently available for local development; the value of `AWS_REGION` (the SES region).

***

## 17. Planned case matrix

**[`ASSETS_CASE_MATRIX.md`](ASSETS_CASE_MATRIX.md) exists since stage A2b.**
`npm run lint:case-matrix` checks every `docs/reference/*_CASE_MATRIX.md` it
finds (`tooling/repository/check-case-matrix.ts:12-13`, `:43-52`), and a
matrix that cites a planned test fails because the spec file does not exist
(`case-matrix-references.ts`, `formatBrokenReferenceFailure`). So each stage
adds its rows when it adds the tests that prove them; A2b and A2c added AS1,
AS2 and AS3.

**The single source of the proving test titles of built cases is
[`ASSETS_CASE_MATRIX.md`](ASSETS_CASE_MATRIX.md)**, which
`npm run lint:case-matrix` checks against the spec files;
[`ASSETS_STAGE_SPECS.md`](ASSETS_STAGE_SPECS.md) keeps the titles each
stage planned. An earlier copy of the titles here drifted and was removed;
in particular the expiry of abandoned uploads (AS9) is proven by
`src/modules/assets/infrastructure/worker/expire-uploads.job.spec.ts`,
`test/integration/assets-processing.integration-spec.ts` and the A1c
contracts, and the `HEAD` on a missing key is not a test with a fixed
answer but the A0 smoke test and A2a-2's `assets:storage-contract`, which
record `404` or `403` (either is accepted).

***

## 18. Sources

Verified on 2026-10-06 unless marked.

| # | Source | Used for |
|---|---|---|
| S1 | Cloudflare R2 pricing — https://developers.cloudflare.com/r2/pricing/ | Storage $0.015/GB-month, Class A $4.50/M, Class B $0.36/M, free egress, free tier |
| S2 | Cloudflare R2 presigned URLs — https://developers.cloudflare.com/r2/api/s3/presigned-urls/ | GET/HEAD/PUT/DELETE only, no POST; 1 s–7 days; `Content-Type` in the signature; not on custom domains |
| R2-PB | Cloudflare R2 public buckets — https://developers.cloudflare.com/r2/buckets/public-buckets/ | Custom domain with Cloudflare cache and WAF; `r2.dev` rate-limited, non-production |
| S4 | Cloudflare R2 object lifecycles — https://developers.cloudflare.com/r2/buckets/object-lifecycles/ | Delete by age/prefix, abort multipart, applied within ~24 h |
| S5 | Cloudflare R2 data location — https://developers.cloudflare.com/r2/reference/data-location/ | Location hints (`enam`, …), jurisdictions `eu`, `fedramp`, `us`; immutable after creation |
| S6 | sharp output options — https://sharp.pixelplumbing.com/api-output | Metadata removed by default, sRGB conversion, WebP q80 / AVIF q50 defaults |
| S7 | sharp constructor — https://sharp.pixelplumbing.com/api-constructor | `limitInputPixels` default 268,402,689, `failOn` default `warning`, `autoOrient` default false, `animated`, input formats |
| S8 | sharp installation — https://sharp.pixelplumbing.com/install | Prebuilt platforms, Node ≥ 20.9, formats of prebuilt binaries (no HEIC) |
| S9 | OWASP File Upload Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/File\_Upload\_Cheat\_Sheet.html | Allow-lists, untrusted Content-Type, signatures, generated names, size limits, separate storage, scanning/CDR, SVG/XML, CSRF |
| S10 | Amazon S3 pricing — https://aws.amazon.com/s3/pricing/ | $0.023/GB-month, PUT $0.005/1k, GET $0.0004/1k, egress $0.09/GB after 100 GB |
| S11 | Amazon S3 POST policy — https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html | POST policy `expiration` and `conditions`; `content-length-range` ("The minimum and maximum allowable size for the uploaded content"); exact and `starts-with` matching for `key` and `Content-Type` (fetched as https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-HTTPPOSTConstructPolicy.md) |
| S12 | Railway Storage Buckets — https://docs.railway.com/storage-buckets and https://docs.railway.com/storage-buckets/billing | Private only, presigned URLs, no lifecycle/versioning/SSE, $0.015/GB-month, free operations and egress |
| S13 | Railway public networking limits — https://docs.railway.com/networking/public-networking/specs-and-limits | 15 min with data flowing, 5 min idle, uploads within 5 minutes, 32 KB headers |
| S14 | Cloudflare R2 S3 API compatibility — https://developers.cloudflare.com/r2/api/s3/api/ | `PutObject` supports `Content-MD5` and conditional headers; `x-amz-checksum-*` not implemented; `PostObject` absent; lifecycle and CORS implemented |
| S15 | sharp utilities — https://sharp.pixelplumbing.com/api-utility | `concurrency` default (1 on glibc without jemalloc), `cache` defaults 50 MB/20 files/100 items |
| S16 | Express body-parser — https://expressjs.com/en/resources/middleware/body-parser.html | JSON/raw `limit` default `100kb` |
| S17 | Cloudflare WAF token authentication — https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/ | HMAC tokens need Pro, Business or Enterprise |
| S18 | Cloudflare R2 limits — https://developers.cloudflare.com/r2/platform/limits/ | 5 GiB single PUT, 1,024-byte keys, 1 write/s per key |
| S19 | RFC 8246 (HTTP Immutable Responses) — https://www.rfc-editor.org/rfc/rfc8246 | `Cache-Control: immutable` (not fetched in this task; standard reference) |
| S20 | RFC 9110 (HTTP Semantics, conditional requests §13) — https://www.rfc-editor.org/rfc/rfc9110 | Strong/weak ETag comparison already applied by `conditional-request.ts` (not fetched in this task; standard reference) |
| S21 | Cloudflare R2 API tokens — https://developers.cloudflare.com/r2/api/tokens/ | Object Read & Write and Object Read tokens can be scoped to a set of buckets, not to prefixes |
| S22 | Cloudflare purge cache — https://developers.cloudflare.com/cache/how-to/purge-cache/ (and the single-file page https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-single-file/) | Purge by URL: Free plan 800 URLs/s and 100 URLs per call (Pro and Business 1,500/s); purge by tag, prefix and hostname exist on every plan (Free: 5 requests per minute) |
| S23 | Cloudflare Cloud Connector — https://developers.cloudflare.com/rules/cloud-connector/ | Routes traffic to AWS and others on all plans; "Your object storage bucket must be publicly accessible"; does not configure caching |
| S24 | Cloudflare Workers pricing — https://developers.cloudflare.com/workers/platform/pricing/ | Free 100,000 requests/day; Paid $5/month minimum, 10 M requests, $0.30/M after; Workers run before the cache and cached requests are billed |
| S25 | Amazon CloudFront pricing — https://aws.amazon.com/cloudfront/pricing/ and https://aws.amazon.com/cloudfront/pricing/pay-as-you-go/ | Free tier 1 TB and 10 M requests/month; US $0.085/GB, South America $0.110/GB; HTTPS $0.0100 (US) and $0.0220 (South America) per 10k; origin fetches from AWS origins free; flat plans Free $0, Pro $15, Business $200, Premium $1,000 |
| S26 | Amazon S3, uploading objects with presigned URLs — https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html | A presigned upload is limited by the signer's permissions; same key replaces the object; SDK presigned URLs up to 7 days |
| S27 | Amazon S3 default encryption — https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-bucket-encryption.html | SSE-S3 on every new object since 2023-01-05 at no cost; SSE-KMS subject to KMS charges and quotas; Bucket Keys |
| S28 | Amazon S3 Object Ownership — https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html | "Bucket owner enforced" with ACLs disabled is the default for new buckets |
| S29 | Amazon S3 lifecycle configuration elements — https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.html | Filters by key prefix, tags and size; `Expiration`; `AbortIncompleteMultipartUpload`; up to 1,000 rules per bucket |
| S30 | OpenAI image generation guide — https://developers.openai.com/api/docs/guides/image-generation | Models, endpoints, sizes, quality, output formats, transparency, `moderation` `auto`/`low`, organization verification, "up to 2 minutes" |
| S31 | OpenAI pricing — https://developers.openai.com/api/docs/pricing | GPT Image model list; token prices (`gpt-image-2` image output $30/1M, `gpt-image-1` $40/1M); calculator for per-image cost |
| S32 | OpenAI `gpt-image-1-mini` — https://developers.openai.com/api/docs/models/gpt-image-1-mini | Per-image prices by quality and size; 20/150/250 images per minute by tier |
| S33 | OpenAI `gpt-image-1.5` — https://developers.openai.com/api/docs/models/gpt-image-1.5 | Per-image prices ($0.034 medium 1024×1024); snapshot `gpt-image-1.5-2025-12-16` |
| S34 | OpenAI `gpt-image-2` — https://developers.openai.com/api/docs/models/gpt-image-2 | Endpoints (generations, edits, batch), inpainting, snapshot `gpt-image-2-2026-04-21`, 20/150/250 IPM |
| S35 | OpenAI data controls — https://developers.openai.com/api/docs/guides/your-data | No training by default; 30-day abuse-monitoring logs; ZDR eligibility of image endpoints for some models; CSAM exception |
| S36 | OpenAI C2PA and SynthID — https://help.openai.com/en/articles/8912793 (search snippet only), with secondary coverage (TNW; https://openai.com/index/advancing-content-provenance) | API images carry C2PA metadata and a SynthID watermark: **secondary sources, primary page not fetched** |
| S37 | OpenAI spend limits — https://developers.openai.com/api/docs/guides/spend-limits | Organization and project monthly limits, alerts or enforced hard limits returning `429` |
| S38 | Expo ImagePicker — https://docs.expo.dev/versions/latest/sdk/imagepicker/ | `preferredAssetRepresentationMode` (`automatic`, `compatible`, `current`), `quality`, `mimeType`, `fileSize`, `exif` |
| S39 | Expo ImageManipulator — https://docs.expo.dev/versions/latest/sdk/imagemanipulator/ | Resize and save as JPEG, PNG or WebP with compression |
| S40 | sharp and HEIF — https://github.com/lovell/sharp/issues/3346 and https://github.com/strukturag/libheif/blob/master/README.md | Prebuilt binaries exclude patent-encumbered HEVC; HEIC needs a custom libvips with libheif/libde265 (search results of these pages; not fetched in full) |
| S41 | OpenAI usage policies — https://openai.com/policies/usage-policies/ | Not fetched (`403`); not quoted |
| S42 | Cloudflare Workers VPC, private S3 bucket example — https://developers.cloudflare.com/workers-vpc/examples/private-s3-bucket/ | The documented way for a Worker to reach a private S3-compatible origin (search result; not fetched in full) |

**Verified facts vs assumptions.** Every price, limit and default above comes
from S1, S2, R2-PB, S4–S18 and S21–S39 as fetched; S40 and S42 are search results, S41 was not readable. Image sizes per rendition, processing times, worker
memory, upload volume, Railway egress, iOS
upload format and legal residency are assumptions, each named where used and
assigned to the slice that measures or confirms it.

***

## 19. Glossary

| Term | Meaning here |
|---|---|
| **Asset** | One logical file of an organization (`assets.assets`); referenced by id from business domains; never changes content |
| **Master** | The `original` variant: the sanitized, re-encoded, metadata-free copy kept privately for regeneration |
| **Rendition** | A derived variant of a profile (`p1-tile`, …), immutable once ready |
| **Rendition profile** | The set of rendition codes an asset has; a ready asset has all of its profile |
| **Upload intent** | The `assets.uploads` row and its S3 presigned POST policy, valid 10 minutes |
| **Staging object** | The raw uploaded bytes at `in/<token>` in the S3 bucket; never a variant, never served, deleted on every terminal outcome |
| **POST policy** | The signed S3 form policy of an upload: exact key, exact content type, size range, expiry |
| **Generation request** | An `assets.generation_requests` row: one AI image asked for, its provenance, its review state |
| **Purging** | The terminal asset status between the reference test and the final deletion; nothing can attach or restore it |
| **Media worker** | The `media-worker` Railway service that processes images, separate from the worker that sends security messages |
| **Image reference** | `imageRef`, `<delivery token>/p<profile>`, which the till combines with one URL template |
| **Delivery token** | The random 128-bit identifier in a public URL path |
| **Delivery fingerprint** | A short hash of the delivery configuration added to ETags of reads that embed URLs |
| **Access class** | `public` (CDN URL), `private` (signed short URL), `sensitive` (signed, audited, shortest TTL) |
| **Attachable** | Same organization, `ready`, not archived, of the media kind and access class the consumer requires |
| **Purge** | Physical deletion of an unreferenced archived or failed asset by the worker |
