---
url: /reference/ASSETS_STAGE_SPECS.md
---
# Assets Stage Specifications

One specification per delivery stage of the Assets module, precise enough
that a writer needs no further question and an independent reviewer can
check the result line by line. The design, the use cases and every decision
live in [`ASSETS_USE_CASES.md`](ASSETS_USE_CASES.md) (cited below as `UC`
plus a section or case id); this document does not repeat them. The owner
runs the infrastructure stage A0 from
[`ASSETS_INFRASTRUCTURE_RUNBOOK.md`](../operations/ASSETS_INFRASTRUCTURE_RUNBOOK.md).

**Status: A1a to A6 are built; A7 and A8 are blocked on OD15.** Every stage
was specified on 2026-10-06, before any code; a built stage carries an
"As built" line pointing at its note in UC §14.1, and where this
specification and that note differ (a migration number, a file path), the
note says what the code does. On
2026-10-06 the owner confirmed option D (OD1) and the media hostnames
`media.ninaku.io` / `media-staging.ninaku.io` (OD13), and reported that
counsel is drafting the personal-data policy (OD15, owner-reported, not a
verified legal opinion). What remains open is named where a stage depends on
it (UC §15): the publication of that policy (A7, A8 in production) and the
erasure trigger path (OD23).

## Contents

1. [Conventions shared by every stage](#1-conventions-shared-by-every-stage)
2. [Stage A0 — Infrastructure (owner)](#2-stage-a0--infrastructure-owner)
3. [Stage A1a-1 — Asset columns, triggers, first grants, D31](#3-stage-a1a-1--asset-columns-triggers-first-grants-d31)
4. [Stage A1a-2 — Upload, job and usage tables](#4-stage-a1a-2--upload-job-and-usage-tables)
5. [Stage A1b — Admission functions and seeds](#5-stage-a1b--admission-functions-and-seeds)
6. [Stage A1c — Processing functions](#6-stage-a1c--processing-functions)
7. [Stage A2a-1 — Module skeleton and configuration](#7-stage-a2a-1--module-skeleton-and-configuration)
8. [Stage A2a-2 — Storage adapters and the storage contract](#8-stage-a2a-2--storage-adapters-and-the-storage-contract)
9. [Stage A2b — Request an upload and read an asset](#9-stage-a2b--request-an-upload-and-read-an-asset)
10. [Stage A2c — Complete an upload](#10-stage-a2c--complete-an-upload)
11. [Stage A3a — The media-worker process](#11-stage-a3a--the-media-worker-process)
12. [Stage A3b-1 — Verification and the master](#12-stage-a3b-1--verification-and-the-master)
13. [Stage A3b-2 — Renditions and the outcome](#13-stage-a3b-2--renditions-and-the-outcome)
14. [Stage A3c — Retries, dead letters, memory gate](#14-stage-a3c--retries-dead-letters-memory-gate)
15. [Stage A4a — Library](#15-stage-a4a--library)
16. [Stage A4b — Purge](#16-stage-a4b--purge)
17. [Stage A5a — Published contracts](#17-stage-a5a--published-contracts)
18. [Stage A5b — Catalog image link](#18-stage-a5b--catalog-image-link)
19. [Stage A5c — Bulk image command](#19-stage-a5c--bulk-image-command)
20. [Stage A6 — Till read with images](#20-stage-a6--till-read-with-images)
21. [Stage A7 — Private and sensitive delivery](#21-stage-a7--private-and-sensitive-delivery)
22. [Stage A8a — AI schema and functions](#22-stage-a8a--ai-schema-and-functions)
23. [Stage A8b — AI request from the catalog](#23-stage-a8b--ai-request-from-the-catalog)
24. [Stage A8c-1 — OpenAI client](#24-stage-a8c-1--openai-client)
25. [Stage A8c-2 — Generation job](#25-stage-a8c-2--generation-job)
26. [Stage A8d — Review, approval and disclosure](#26-stage-a8d--review-approval-and-disclosure)
27. [Readiness table](#27-readiness-table)

***

## 1. Conventions shared by every stage

### 1.1 Migrations (every SQL stage)

| Rule | Source |
|---|---|
| A migration is `database/migrations/<NNNN>_<snake_case_intent>.sql`, wrapped exactly as `0082`: `BEGIN;` → `SELECT ninaku_migrations.claim('<file name>', :'migration_checksum') AS should_apply \gset` → `\if :should_apply` … `\endif` → `COMMIT;` | `database/migrations/0082_catalog_contract_step_and_offers_by_group.sql:1-20` |
| No SQL comments in a new migration (`lint:no-comments`, shrink-only allowlist) | `AGENTS.md` §4 |
| **Expand only**: add columns, tables, functions, grants, seeds; never drop or rename in the same stage; a later contract step does that, as `0080` → `0082` did. **Explicit exceptions**, each named in its stage: A1a-1 replaces `assets_organization_active_idx` (no query has ever used it: no Assets code exists), A8a-1 replaces `processing_jobs_kind_chk` and relaxes `processing_jobs.asset_id` to nullable, and A8a-2 drops and recreates `claim_processing_job` (a `RETURNS TABLE` change cannot be `CREATE OR REPLACE`d); all touch objects created earlier in this same Assets release and read by no running code but the media worker, which is redeployed with the migration | `CATALOG_USE_CASES.md` "Contract step and where-used as built" |
| The canonical file of every touched schema is updated to its **end state** in the same change (`database/canonical/0070_assets.sql`, and `0060_platform.sql`, `0380_audit.sql`, `0080_catalog.sql` when seeds or catalog objects change); grants also land in `database/access/runtime_grants.sql` | `AGENTS.md` §8; `runtime_grants.sql:116-129` (catalog precedent) |
| **A fresh install never runs migrations**: it installs the canonical baseline and only records the migrations in the ledger (`database/README.md:217`). So every data seed, including a new plan version, must exist in the canonical file too, as the catalog's Pionero version 4 block does in `0060_platform.sql:4270-4312`; the migration and the canonical block are the same idempotent `DO` block | `database/README.md:211-219` |
| Seeds live where the owner of the rows lives: permissions and role mapping in the module's canonical file (`0080_catalog.sql:4359-4392` precedent → `0070_assets.sql`), capabilities and limit definitions in `0060_platform.sql` (`:4248`), audit resource kinds and action types in `0380_audit.sql` (`:2511-2513`, `:3160-3175`) | as cited |
| `database/manifest/baseline.json`: `baseline_revision` + 1, `scope` describing the change, `revised_from_commit`, and the `sha256`, `git_blob_sha`, `bytes` of every changed canonical file (`npm run db:review` checks them); `database/README.md` gains the revision paragraph, as revision 92 did (`database/README.md:7`) | `baseline.json` keys; `database/README.md:7` |
| A new contract script is registered in `database/tests/run-clean-install.ts` (`:435-455` precedent) | as cited |
| RLS policies use the scalar subquery: `(SELECT identity.has_active_tenant_context()) AND organization_id = (SELECT identity.current_organization_id())` (`npm run db:sweep`) | `API_AND_SCHEMA_DISCIPLINE.md` §4; `0070:966-1048` |
| SECURITY DEFINER functions: `SET search_path = pg_catalog, pg_temp` (schema-qualify every object), `REVOKE ALL ON FUNCTION … FROM PUBLIC`, explicit `GRANT EXECUTE` to exactly the roles named; functions called by `ninaku_runtime` first call `identity.assert_tenant_context()` and take the organization only from `identity.current_organization_id()`, never from a parameter; functions that lock assert `current_setting('transaction_isolation') = 'read committed'` like `identity.assert_membership_seat_available` | `0040_identity.sql:2103-2129`; `runtime_grants.sql:57-62` |
| **Migration numbers.** Numbers below are the expected ones for the first stages only (A1a-1 `0083`, revision 93, verified); every later migration is named **next free number** and takes the matching revision when it is written | UC §14 |

### 1.2 Code

Module layers, R1–R9 and the code rules of `AGENTS.md` §4 apply (no
ternaries, no `any`, no `!`, no blind `as`, erasable TypeScript, `.ts`
relative imports, no comments). Errors: each new public code is added to the
error-code registry and mapped in `assets/presentation/failures`, as Catalog
does (`src/modules/catalog/presentation/failures/catalog-failures.ts`). The
worker's database access follows the Identity dispatch pattern: an
infrastructure repository using `DatabasePoolService` (`src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.ts:3,153`)
against SECURITY DEFINER functions, so R9's runner allowlist
(`tooling/repository/check-module-boundaries.ts:113-117`) needs no change.

### 1.3 Tests and their files

| Kind | Location and runner |
|---|---|
| Unit | `src/**/<name>.spec.ts`, `npm test` |
| Integration (PostgreSQL 18, serial) | `src/**/<name>.integration-spec.ts`, `npm run test:integration` |
| End to end | `test/e2e/<name>.e2e-spec.ts`, `npm run test:e2e` |
| Database contract | `database/tests/contracts/<name>.ts`, `npm run db:test` |
| Database concurrency | `database/tests/concurrency/<name>.ts`, `npm run db:test` |
| Statement budget | `test/e2e/statement-budget.e2e-spec.ts` (one row per route) |
| **Mutation targets** | Hand-made mutations the reviewer applies to the new code; each named mutation must make at least one named test fail (the repository's practice, `CATALOG_USE_CASES.md` "Idempotent creates as built", "Proof") |

Test titles below are exact; they join `ASSETS_CASE_MATRIX.md` when it is
created in A2b (UC §17).

### 1.4 Statement counts

Counted the repository way: session (1), membership (1), `BEGIN` (1),
tenant context (1), authorization (1), the use case's statements,
`COMMIT` (1) (`CATALOG_USE_CASES.md` "K7 as built": C19 = 8).

### 1.5 Verification and done

**The writer** runs, for the files it touches: `npm run typecheck`, the
focused tests (`npx vitest run <files>`, the matching integration or e2e
config), `npx prettier --write` on touched `src/`, `test/`, `database/`,
`tooling/` and `.railway/` TypeScript (never on `docs/reference` or
`docs/contracts`), and for SQL stages `npm run db:fresh && npm run db:test`.
**The integrator** runs once per PR `npm run verify` ending in
`NINAKU_VERIFY_OK` from a fresh `npm run db:fresh` (`AGENTS.md` §8).

**Common definition of done** (every stage adds its own): all listed tests
exist with the exact titles and pass; every mutation target fails a test;
`npm run verify` prints `NINAKU_VERIFY_OK`; the "as built" table of the
stage is added to UC; `ASSETS_CASE_MATRIX.md` rows for the stage cite the
tests (from A2b on); no secret, presigned field, prompt or response body is
logged (checked by the redaction specs); ≤ 400 changed lines per PR or the
named split is used.

### 1.6 Rollout and rollback

Migrator first: `database-migrator` applies the migration before the
`api`/`media-worker` code that needs it deploys (`RAILWAY_OPERATIONS.md`,
"migration first when the new application version depends on it"). Every
migration is expand-only, so the application rollback is a redeploy of the
previous image while the new objects stay unused; a database rollback is
never needed for these stages. A seed that publishes a plan version is
reversible only by a new plan version (staff tooling), never by deletion.

### 1.7 Plan B delta convention

Option D (UC §6.1) is the specified path. If the owner chooses plan B
(S3 + CloudFront) in OD1, **only three things change**: the public
rendition write path (S3 prefix `a/` in the same bucket instead of the R2
public bucket), the delivery host (a CloudFront distribution with Origin
Access Control aliased to the media hostname, DNS-only in Cloudflare,
instead of the R2 custom domain), and the purge call (CloudFront
`CreateInvalidation` instead of Cloudflare purge-by-URL). Each stage states
its delta in one line, or "none".

***

## 2. Stage A0 — Infrastructure (owner)

**Goal.** Every external resource the later stages need exists, in staging
and production, with least privilege, alerts and a recorded smoke test.
Delivers no use case.
**Who.** The owner, alone (owner of the AWS, Cloudflare and OpenAI accounts
and of the domains, OD12). Steps an agent can script are marked in the
runbook; console steps are the owner's.
**Preconditions.** OD1 confirmed (D) and OD13 confirmed (`media.ninaku.io`,
`media-staging.ninaku.io`) on 2026-10-06; OD14 accepted (separate
`media-worker`). OpenAI steps (runbook §9) wait for the published
personal-data policy (OD15) and OD17.
**Deliverables.**

| Area | Deliverable |
|---|---|
| Runbook | Executed in order: [`ASSETS_INFRASTRUCTURE_RUNBOOK.md`](../operations/ASSETS_INFRASTRUCTURE_RUNBOOK.md) §1–§9 |
| `.railway/railway.ts` (agent-written PR, owner-applied) | New `media-worker` service (same repository and build as `worker`, `start: 'node dist/media-worker'`, `replicas: 1`, worker database role); the variables of UC §13.6 declared with `preserve()` for secrets and literals for public values (`ASSETS_PUBLIC_BASE_URL`, bucket names, region); nothing else changes (`railway.ts:151-184` is the template) |
| Records (owner's password manager, never the repository) | The entries of runbook §0.3 |
| Evidence | The smoke-test transcript of runbook §10 pasted into the PR that closes A0, with every secret value removed |

**Spikes decided here (runbook §10).**

| Experiment | Pass | Fail → fallback |
|---|---|---|
| R2 enabled on the Cloudflare account (OD22) | The R2 bucket can be created | Owner adds the payment method R2 asks for; if refused, OD1 falls back to plan B |
| `HEAD` on a missing key in `in/` with the media-worker key | `404` | `403`: the worker treats `403` on `in/*` as "missing" (A3b-1 failure matrix row), and the IAM statement `ListTheAssetsBucketSoMissingKeysAnswer404` is kept anyway |
| S3 POST over the bound | `400` (`EntityTooLarge`) | Any `2xx`: stop; the design's size bound is broken; escalate before A2b |
| SES after the key swap | Test OTP and invitation delivered | Revert the Railway variables to the old key (runbook §2 rollback) |

**Security checks.** No IAM policy with `*` in `Action` or `Resource`; the
SES key's user has only `NinakuSendVerificationEmail`
(`docs/operations/AWS_SES_CREDENTIALS.md` §3); root has MFA and no keys;
Block Public Access on; `r2.dev` off in production; the cache-purge token is
zone-scoped; no secret in the repository (`git grep` for `AKIA` returns
nothing).
**Measurements.** Round-trip from a phone on the till network to S3
`us-east-1` and to the media hostname (runbook §10.7), recorded in the PR.
**Rollback.** Per runbook step.
**Definition of done.** Runbook §10 passes in staging; production resources
exist; `railway config plan` reports zero drift after the owner applies.
**PR split.** One PR: `.railway/railway.ts` (≈ 80 lines). Docs updates ride
with it.
**Verification.** `npm run typecheck` (the IaC is TypeScript), `railway
config plan` (owner).
**Plan B delta.** CloudFront distribution with OAC on the bucket's `a/`
prefix instead of the R2 bucket; the media-worker IAM policy adds `a/*`;
the purge token becomes `cloudfront:CreateInvalidation` on that
distribution.

***

## 3. Stage A1a-1 — Asset columns, triggers, first grants, D31

**Goal.** `assets.assets` and `assets.asset_variants` carry the lifecycle
the design needs (UC §4.2 SG1–SG5, SG9, SG16–SG18), the runtime can read the
schema, and the module-owned queue decision is recorded. No use case yet.
**Preconditions.** OD7 accepted. Staging at baseline revision 92 with
`0082` as the latest migration (verified on `staging` 591a60c1).
**Migration.** `0083_assets_lifecycle_columns.sql`, baseline revision 93.

**Draft DDL (design text; the migration is comment-free).**

```sql
ALTER TABLE assets.assets
    ADD COLUMN status varchar(20) NOT NULL DEFAULT 'pending',
    ADD COLUMN ready_at timestamptz,
    ADD COLUMN failed_at timestamptz,
    ADD COLUMN failure_code varchar(80),
    ADD COLUMN delivery_token varchar(26),
    ADD COLUMN rendition_profile smallint NOT NULL DEFAULT 1,
    ADD COLUMN media_kind varchar(20) NOT NULL DEFAULT 'image',
    ADD COLUMN source_kind varchar(20) NOT NULL DEFAULT 'upload',
    ADD COLUMN created_by_membership_id uuid;

-- the table is empty in every environment (no writer exists): assert it, then require the token
-- (DO block raising 23514 'assets_lifecycle_requires_empty_table' if a row exists)
ALTER TABLE assets.assets ALTER COLUMN delivery_token SET NOT NULL;

ALTER TABLE assets.assets
    ADD CONSTRAINT assets_status_chk
        CHECK (status IN ('pending','processing','ready','failed','purging')),
    ADD CONSTRAINT assets_status_state_chk CHECK (
        (status IN ('pending','processing') AND ready_at IS NULL AND failed_at IS NULL AND failure_code IS NULL)
        OR (status = 'ready' AND ready_at IS NOT NULL AND ready_at >= created_at AND failed_at IS NULL AND failure_code IS NULL)
        OR (status = 'failed' AND failed_at IS NOT NULL AND failed_at >= created_at AND ready_at IS NULL AND failure_code IS NOT NULL)
        OR (status = 'purging')
    ),
    ADD CONSTRAINT assets_failure_code_chk
        CHECK (failure_code IS NULL OR failure_code ~ '^[a-z][a-z0-9_.]{0,79}$'),
    ADD CONSTRAINT assets_delivery_token_chk CHECK (delivery_token ~ '^[a-z2-7]{26}$'),
    ADD CONSTRAINT assets_delivery_token_uq UNIQUE (delivery_token),
    ADD CONSTRAINT assets_rendition_profile_chk CHECK (rendition_profile > 0),
    ADD CONSTRAINT assets_media_kind_chk CHECK (media_kind IN ('image')),
    ADD CONSTRAINT assets_source_kind_chk CHECK (source_kind IN ('upload','ai_generated')),
    ADD CONSTRAINT assets_created_by_membership_fk
        FOREIGN KEY (created_by_membership_id, organization_id)
        REFERENCES identity.memberships (id, organization_id);

ALTER TABLE assets.asset_variants
    ADD COLUMN failure_code varchar(80),
    ADD CONSTRAINT asset_variants_failure_code_chk
        CHECK ((status = 'failed') = (failure_code IS NOT NULL));

DROP INDEX IF EXISTS assets.assets_organization_active_idx;   -- explicit exception to expand-only (§1.1): never used by any code
CREATE INDEX assets_organization_active_idx
    ON assets.assets (organization_id, created_at DESC, id DESC) WHERE archived_at IS NULL;
CREATE INDEX assets_organization_archived_idx
    ON assets.assets (organization_id, created_at DESC, id DESC) WHERE archived_at IS NOT NULL;

CREATE FUNCTION assets.profile_rendition_codes(p_profile smallint) RETURNS text[]
    LANGUAGE sql IMMUTABLE SET search_path = pg_catalog
    AS $$ SELECT CASE p_profile WHEN 1 THEN ARRAY['p1-thumbnail','p1-tile','p1-card','p1-detail'] END $$;

CREATE FUNCTION assets.guard_asset_status() RETURNS trigger ...   -- BEFORE UPDATE OF status, archived_at ON assets.assets
    -- allowed: pending→processing, pending→failed, processing→ready, processing→failed,
    --          failed→processing, ready→purging, failed→purging; same status always allowed;
    -- purging: status frozen, archived_at frozen; anything else: 23514 'assets_status_transition'
CREATE FUNCTION assets.protect_access_class() RETURNS trigger ... -- BEFORE UPDATE OF access_class: any change → 23514 'assets_access_class_immutable'
CREATE FUNCTION assets.assert_ready_asset_renditions() RETURNS trigger ...
    -- CONSTRAINT TRIGGER AFTER INSERT OR UPDATE ON assets.assets and AFTER INSERT OR UPDATE OR DELETE
    -- ON assets.asset_variants, DEFERRABLE INITIALLY DEFERRED: an asset whose status is 'ready' has
    -- 'original' and every code of assets.profile_rendition_codes(rendition_profile) with status 'ready';
    -- violation: 23514 'assets_ready_requires_renditions'
```

The `-- …` lines above describe behaviour for the reviewer; the migration
itself carries none. `DROP INDEX … CREATE INDEX` replaces an index with a
superset of its purpose (same name), which is expand-safe: no reader exists.

**Grants (migration and `runtime_grants.sql`).** `GRANT USAGE ON SCHEMA
assets TO ninaku_runtime`; `GRANT SELECT ON assets.assets,
assets.asset_variants, assets.storage_providers TO ninaku_runtime`;
`GRANT UPDATE (display_name) ON assets.assets TO
ninaku_runtime` (archive and restore go through definer functions that
also keep the usage counters, §5.2 and §15); `GRANT EXECUTE ON FUNCTION
assets.profile_rendition_codes(smallint) TO ninaku_runtime`. Nothing to
`ninaku_worker`. No `INSERT`, no `UPDATE (status)`, no `DELETE`.
**Canonical end state.** `0070_assets.sql` gains the columns, constraints,
indexes, three trigger functions and their triggers; `runtime_grants.sql`
gains the lines above.

**Docs in the same PR (OD7).**

* `docs/reference/DECISIONS.md`: new **D31**, in Spanish, titled "Las tareas
  pesadas de un módulo usan una cola propia del módulo con
  `organization_id`", stating: the shape of `events.outbox_records`
  (lease, attempts, dead letter), claimed only by `ninaku_worker` through
  SECURITY DEFINER functions; `events.*` stays for integration events; the
  first user is `assets.processing_jobs` (UC §4.2 SG7).
* `AGENTS.md` §5.4: the sentence listing the outbox tables gains "o una cola
  propia del módulo para sus tareas pesadas (D31), como
  `assets.processing_jobs`".

**Tests.** `database/tests/contracts/assets-lifecycle-contracts.ts`
(registered in `run-clean-install.ts`):

* `refuses an asset status transition outside the allowed table and never lets purging change`
* `refuses changing the access class of an asset after creation`
* `refuses a ready asset at commit when one rendition of its profile is not ready, and accepts it when all are`
* `refuses a failed variant without a failure code and a ready one with a failure code`
* `lets the runtime rename an asset but not insert, delete, archive directly or set its status or access class`
* `refuses a membership of another organization as the creator of an asset`
* `keeps delivery tokens unique and lowercase base32 of 26 characters`

**Mutation targets.** Allow `ready → pending` in `guard_asset_status`;
drop one code from `profile_rendition_codes`; remove the
`access_class` trigger; grant `UPDATE (status)` or `UPDATE (archived_at)`.
**Security checks.** Privileges equal exactly the grant list
(`information_schema.role_table_grants` and `role_column_grants` asserted
in the contract script); trigger functions are not SECURITY DEFINER.
**Measurements.** None.
**Definition of done.** Common DoD; D31 and the `AGENTS.md` sentence
merged in the same PR.
**PR split.** One PR ≈ 350 lines (migration ≈ 110, canonical ≈ 110,
contract ≈ 110, docs ≈ 20).
**Verification.** `npm run db:fresh && npm run db:test`, `npm run
db:review`, `npm run db:sweep`, `npm run lint:no-comments`, `npm run
lint:docs-stack`.
**Plan B delta.** None.

***

## 4. Stage A1a-2 — Upload, job and usage tables

**Goal.** UC SG6, SG7, SG8: the tables the admission and processing
functions write. No use case yet.
**Preconditions.** A1a-1 merged.
**Migration.** `0085_assets_uploads_jobs_and_usage.sql`, baseline revision 95 (as built: `0084` and revision 94 went to Catalog K9a, merged first). As built in UC "A1a-2 as built".

**Draft DDL.**

```sql
CREATE TABLE assets.uploads (
    id                        uuid PRIMARY KEY,
    organization_id           uuid NOT NULL,
    asset_id                  uuid NOT NULL,
    staging_provider_id       uuid NOT NULL REFERENCES assets.storage_providers(id),
    staging_object_key        varchar(1024) NOT NULL,
    declared_media_type       varchar(255) NOT NULL,
    declared_byte_size        bigint NOT NULL,
    state                     varchar(20) NOT NULL DEFAULT 'issued',
    expires_at                timestamptz NOT NULL,
    received_at               timestamptz,
    settled_at                timestamptz,
    rejection_code            varchar(80),
    creation_idempotency_key  uuid NOT NULL,
    creation_request_hash     text NOT NULL,
    created_at                timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT uploads_asset_organization_fk FOREIGN KEY (asset_id, organization_id)
        REFERENCES assets.assets (id, organization_id),
    CONSTRAINT uploads_asset_uq UNIQUE (asset_id),
    CONSTRAINT uploads_id_organization_uq UNIQUE (id, organization_id),
    CONSTRAINT uploads_staging_key_uq UNIQUE (staging_provider_id, staging_object_key),
    CONSTRAINT uploads_idempotency_uq UNIQUE (organization_id, creation_idempotency_key),
    CONSTRAINT uploads_staging_key_chk CHECK (staging_object_key ~ '^in/[a-z2-7]{26}$'),
    CONSTRAINT uploads_media_type_chk CHECK (declared_media_type IN ('image/jpeg','image/png','image/webp','image/avif')),
    CONSTRAINT uploads_byte_size_chk CHECK (declared_byte_size BETWEEN 1 AND 10485760),
    CONSTRAINT uploads_request_hash_chk CHECK (creation_request_hash ~ '^[0-9a-f]{64}$'),
    CONSTRAINT uploads_state_chk CHECK (state IN ('issued','received','verified','rejected','expired')),
    CONSTRAINT uploads_state_shape_chk CHECK (
        (state = 'issued' AND received_at IS NULL AND settled_at IS NULL AND rejection_code IS NULL)
        OR (state = 'received' AND received_at IS NOT NULL AND settled_at IS NULL AND rejection_code IS NULL)
        OR (state = 'verified' AND received_at IS NOT NULL AND settled_at IS NOT NULL AND rejection_code IS NULL)
        OR (state = 'rejected' AND settled_at IS NOT NULL AND rejection_code IS NOT NULL)
        OR (state = 'expired' AND received_at IS NULL AND settled_at IS NOT NULL AND rejection_code IS NULL)
    ),
    CONSTRAINT uploads_expiry_chk CHECK (expires_at > created_at)
);
CREATE INDEX uploads_open_expiry_idx ON assets.uploads (expires_at) WHERE state IN ('issued','received');
CREATE INDEX uploads_organization_open_idx ON assets.uploads (organization_id) WHERE state IN ('issued','received');

CREATE TABLE assets.processing_jobs (
    id                uuid PRIMARY KEY,
    organization_id   uuid NOT NULL,
    asset_id          uuid NOT NULL,
    job_kind          varchar(40) NOT NULL,
    state             varchar(20) NOT NULL DEFAULT 'pending',
    available_at      timestamptz NOT NULL DEFAULT now(),
    lease_owner       varchar(200),
    lease_token       uuid,
    lease_expires_at  timestamptz,
    attempt_count     integer NOT NULL DEFAULT 0,
    error_class       varchar(20),
    last_error_code   varchar(80),
    completed_at      timestamptz,
    created_at        timestamptz NOT NULL DEFAULT now(),
    updated_at        timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT processing_jobs_asset_organization_fk FOREIGN KEY (asset_id, organization_id)
        REFERENCES assets.assets (id, organization_id),
    CONSTRAINT processing_jobs_kind_chk CHECK (job_kind IN ('process_upload','render_profile','purge_asset')),
    CONSTRAINT processing_jobs_state_chk CHECK (state IN ('pending','leased','completed','dead_lettered')),
    CONSTRAINT processing_jobs_attempts_chk CHECK (attempt_count >= 0),
    CONSTRAINT processing_jobs_error_class_chk CHECK (error_class IS NULL OR error_class IN ('storage','processing','permanent')),
    CONSTRAINT processing_jobs_lease_shape_chk CHECK (
        (state = 'pending' AND lease_owner IS NULL AND lease_token IS NULL AND lease_expires_at IS NULL AND completed_at IS NULL)
        OR (state = 'leased' AND lease_owner IS NOT NULL AND lease_token IS NOT NULL AND lease_expires_at IS NOT NULL AND completed_at IS NULL)
        OR (state = 'completed' AND lease_owner IS NULL AND lease_token IS NULL AND lease_expires_at IS NULL AND completed_at IS NOT NULL)
        OR (state = 'dead_lettered' AND lease_owner IS NULL AND lease_token IS NULL AND lease_expires_at IS NULL AND completed_at IS NULL)
    )
);
CREATE INDEX processing_jobs_claim_idx ON assets.processing_jobs (job_kind, available_at)
    WHERE state IN ('pending','leased');
CREATE UNIQUE INDEX processing_jobs_one_open_uq ON assets.processing_jobs (asset_id, job_kind)
    WHERE state IN ('pending','leased');
CREATE INDEX processing_jobs_dead_letters_idx ON assets.processing_jobs (created_at)
    WHERE state = 'dead_lettered';

CREATE TABLE assets.organization_usage (
    organization_id     uuid PRIMARY KEY REFERENCES organization.organizations(id),
    active_asset_count  integer NOT NULL DEFAULT 0 CHECK (active_asset_count >= 0),
    stored_bytes        bigint  NOT NULL DEFAULT 0 CHECK (stored_bytes >= 0),
    reserved_bytes      bigint  NOT NULL DEFAULT 0 CHECK (reserved_bytes >= 0),
    updated_at          timestamptz NOT NULL DEFAULT now()
);
-- trigger AFTER INSERT ON organization.organizations creating the usage row (assets.create_organization_usage(),
-- SECURITY DEFINER, owner migrator, no grant needed: it runs as the trigger of the inserting role),
-- then INSERT … SELECT id FROM organization.organizations ON CONFLICT DO NOTHING (backfill)
```

The usage-row trigger copies the `catalog_versions` precedent ("K1 as
built": created by an organization insert trigger and backfilled, never
lazily). The function is SECURITY DEFINER because `ninaku_runtime` has no
`INSERT` on `organization_usage`; it inserts only `(NEW.id)`.

**RLS.** Enable on the three tables; one `FOR SELECT` tenant policy each
(scalar subquery). No insert, update or delete policy: only definer
functions write.
**Grants.** `GRANT SELECT ON assets.uploads, assets.organization_usage TO
ninaku_runtime`. `processing_jobs`: no grant to anyone (claims arrive in
A1c).
**Canonical end state.** `0070_assets.sql` gains the three tables, indexes,
policies and the usage trigger (the trigger on `organization.organizations`
is declared in `0070_assets.sql`, which already requires `0020`,
`0070:54-60`).

**Tests.** `database/tests/contracts/assets-tables-contracts.ts`:

* `creates a usage row for every new organization and backfills the existing ones exactly once`
* `refuses an upload whose state and timestamps disagree, a staging key outside in/ and a size over 10 MiB`
* `refuses a second open job of the same kind for one asset and a leased job without its lease`
* `hides another organization's uploads and usage from the runtime and gives it no access to processing jobs`
* `refuses any runtime insert, update or delete on uploads, processing jobs and usage`

**Mutation targets.** Drop `processing_jobs_one_open_uq`; make the usage
trigger `ON CONFLICT DO UPDATE`; add a runtime `INSERT` grant.
**Security checks.** RLS enabled (`relrowsecurity`) on all three; no grant
on `processing_jobs`.
**Definition of done.** Common DoD.
**PR split.** One PR ≈ 330 lines.
**Verification.** As A1a-1.
**Plan B delta.** None.

***

## 5. Stage A1b — Admission functions and seeds

**Goal.** The only write path the runtime has into Assets (UC §6.2 items
1–2, AS1, AS2 at the SQL level), and the vocabulary every later stage
names: permissions, roles, capability, limits, providers, audit actions.
**Preconditions.** A1a-2 merged; OD6 and OD11 accepted (defaults: 5,000
assets, 10,240 MiB; owner and organization-wide managers).
**Split.** Three PRs, three migrations (next free numbers): **A1b-1 seeds**
(`<next>_assets_vocabulary.sql`, ≈ 320 lines: migration ≈ 120, canonical
≈ 120 across `0070`, `0060`, `0380`, contracts ≈ 80), **A1b-2 admission**
(`reserve_upload`, `complete_upload`; ≈ 380 lines: migration ≈ 140,
canonical ≈ 140, contracts ≈ 100) and **A1b-3 library functions**
(`archive_asset`, `restore_asset`; ≈ 250 lines).

### 5.1 A1b-1 seeds (draft)

As built: `0086_assets_vocabulary.sql`, baseline revision 96 (UC "A1b-1 as built"). Two refinements of the draft below: the new Pionero number is the highest version number of the plan + 1 (a staff draft would collide with "active + 1"), and the copied offerings keep their visibility and target organization.

| Seed | Rows | Canonical end state |
|---|---|---|
| `assets.storage_providers` | `(id gen_random_uuid(), code 's3-private', name 'Amazon S3 assets bucket', adapter_code 's3', configuration_ref 'ASSETS_S3', status 'active')`, `(id gen_random_uuid(), code 'r2-public', name 'Cloudflare R2 public renditions', adapter_code 'cloudflare_r2', configuration_ref 'ASSETS_R2', status 'active')`, `ON CONFLICT (lower(code)) DO NOTHING` | `0070_assets.sql` |
| `identity.permissions` | `assets.media.manage` ("Manage images and files": request uploads, complete them, rename, archive, restore; checked organization-wide), `assets.media.view` ("View images and files": read the media library) | `0070_assets.sql` (the `0080_catalog.sql:4359` precedent) |
| `identity.role_permissions` | `organization_owner` and `manager` (system roles, `organization_id IS NULL`) get both; no other role (UC §10.2) | `0070_assets.sql` |
| `platform.capabilities` | `assets.media` ("Images and files") | `0060_platform.sql` |
| `platform.limit_definitions` | `assets.active_assets` (name "Active assets", `unit_code 'assets'`, `reset_period 'none'`, `value_kind 'integer'`); `assets.stored_megabytes` ("Stored megabytes", `'megabytes'`, `'none'`, `'integer'`) | `0060_platform.sql` |
| Pionero next version | The `0075` pattern (`database/migrations/0075_catalog_foundations.sql:281-340`) with two corrections: the current version is **the active version with the highest `version_number`**, read at migration time, and the new number is that + 1 (never a literal; staff may have created versions, `runtime_grants.sql:91`); offerings are **copied** from the current version's active offerings (market, currency, interval, amount, trial days) instead of hard-coded amounts. The new version gets every capability and limit of the current one plus `assets.media`, `assets.active_assets = 5000`, `assets.stored_megabytes = 10240`; the current version is retired. Idempotent: if a Pionero version already has `assets.media`, the block returns | **the same `DO` block in the migration and in canonical `0060_platform.sql`** (after the catalog's version-4 block, `0060_platform.sql:4270-4312`), because a fresh install runs only the canonical files (§1.1); on a fresh install it resolves "current maximum + 1" there |
| `audit.resource_kinds` | `assets.asset` (owner module `assets`, sensitivity `internal`, "A file of the tenant Organization: an image and its renditions") | `0380_audit.sql` |
| `audit.action_types` | `assets.asset.upload_requested`, `assets.asset.renamed`, `assets.asset.archived`, `assets.asset.restored`: owner module `assets`, category `configuration`, scope `tenant`, risk `medium`, actor `required`, `requires_reason false`, `requires_target true`, `requires_authorization_decision true`, `allows_before_after true`, retention class `administration.platform` (the catalog values, `0380_audit.sql:3160-3168`) | `0380_audit.sql` |

**Existing organizations (fact, not a gap).** Grandfathering means an
organization subscribed to an older Pionero version does not gain
`assets.media` until staff move it, exactly as the catalog rollout
documented (`SUBSCRIPTION_PLANS_USE_CASES.md`, "Catalog capability rollout",
`:719-733`); reads, rename, archive and restore never need the capability.

### 5.2 A1b-2 functions (draft)

As built: `0087_assets_admission_functions.sql`, baseline revision 97 (UC "A1b-2 as built"). Three refinements of the draft below: both functions refuse a transaction outside READ COMMITTED with `assets_admission_isolation` (§1.1), `complete_upload` also locks the asset `FOR UPDATE` before writing (UC AS2), and the concurrency title runs inside the admission contract script. The archive and restore title is A1b-3's.

```sql
CREATE FUNCTION assets.reserve_upload(
    p_asset_id uuid, p_original_variant_id uuid, p_upload_id uuid,
    p_delivery_token varchar, p_staging_token varchar,
    p_media_type varchar, p_byte_size bigint, p_access_class varchar,
    p_display_name varchar, p_original_filename varchar,
    p_idempotency_key uuid, p_request_hash text, p_expires_at timestamptz)
RETURNS TABLE (outcome varchar, asset_id uuid, upload_id uuid, upload_state varchar,
               request_hash text, staging_object_key varchar, expires_at timestamptz)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp
```

Behaviour, in order: `identity.assert_tenant_context()`; READ COMMITTED
assertion; `v_org := identity.current_organization_id()`; lock
`assets.organization_usage WHERE organization_id = v_org FOR UPDATE`; if an
upload with `(v_org, p_idempotency_key)` exists, return it with outcome
`existing` (the application compares `request_hash`); refuse
`p_access_class <> 'public'` (`23514`, `assets_access_class_unavailable`;
A7 widens it); read `platform.effective_limit(v_org, 'assets.active_assets')`
and `…'assets.stored_megabytes'` (no row or unlimited = no ceiling, the
`identity.assert_membership_seat_available` rule, `0040_identity.sql:2114-2120`);
refuse `active_asset_count + 1 > limit` (`assets_asset_limit`),
`stored_bytes + reserved_bytes + p_byte_size > limit × 1048576`
(`assets_storage_limit`), 20 or more open uploads of the organization
(`assets_open_uploads_limit`); insert the asset (`status 'pending'`,
`created_by_membership_id = identity.current_membership_id()`), the
`original` variant (`pending`), the upload (`issued`, provider `s3-private`,
key `'in/' || p_staging_token`); add `p_byte_size` to `reserved_bytes` and 1
to `active_asset_count`; return outcome `created`. All refusals use SQLSTATE
`23514` with the named constraint, which the repository maps.

```sql
CREATE FUNCTION assets.complete_upload(p_upload_id uuid)
RETURNS TABLE (outcome varchar, asset_id uuid)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp
```

Behaviour: tenant assertion; lock the upload `FOR UPDATE` **filtered by
`organization_id = identity.current_organization_id()`** (a definer
function bypasses RLS, so the filter is explicit); outcomes: `not_found`;
`expired` when `issued` and `expires_at <= now()` (the sweep moves it);
`rejected`; `already_received` for `received` or `verified`; `completed`:
upload `received` (`received_at = now()`), asset and original variant
`processing`, insert `processing_jobs (id uuidv7(), organization_id, asset_id,
'process_upload')`.

**A1b-3** creates `assets.archive_asset(p_asset_id uuid)` and
`assets.restore_asset(p_asset_id uuid)`, specified with A4a (§15), so the
library stage needs no migration.
As built: `0094_assets_archive_restore.sql`, baseline revision 104 (UC "A1b-3 as built"). Refinements of the addendum: both refuse a transaction outside READ COMMITTED with `assets_admission_isolation`; both lock the usage row before the asset (UC §7 lock order); the refusals are raised as `23514` with `assets_status_transition` (purging, and failed on restore), `assets_restoration_expired` and `assets_asset_limit`, while `not_found`, `already_archived`, `not_archived`, `archived` and `restored` are the returned outcomes; the archive and restore title lives in `assets-archive-contracts.ts` and the race in `database/tests/concurrency/assets-archive-concurrency.ts`.

**Grants.** `EXECUTE` on the four functions to `ninaku_runtime` only.
**Tests.**

* `database/tests/contracts/assets-admission-contracts.ts`:
  `archives and restores through the definer functions only, keeping the active asset count, and refuses restoring past 30 days or over the asset limit`;
  `reserves an upload with its asset, original variant and reserved bytes, and replays the same key without a second row`;
  `refuses a private access class, the asset limit, the storage limit and a 21st open upload with their named constraints`;
  `treats a missing or unlimited plan limit as no ceiling`;
  `completes an issued upload into processing with one queued job, and answers repeats, expired, rejected and foreign uploads without writing`;
  `takes the organization only from the tenant context, never from a parameter`.
* `database/tests/concurrency/assets-reservation-concurrency.ts` (as built: no separate script; the title runs inside `database/tests/contracts/assets-admission-contracts.ts`):
  `admits exactly one of two concurrent reservations at the asset limit and serializes them on the usage row`.
* `database/tests/contracts/assets-seed-contracts.ts`:
  `seeds the two providers, the two permissions on the owner and the manager only, the capability and both limits on the newest Pionero version, and the four audit actions`;
  `publishes the new Pionero version with the offerings copied from the previous one and retires the previous one, once`.
  **Mutation targets.** Remove the usage-row lock; read the organization
  from a parameter; skip the `existing` lookup; count `reserved_bytes` out of
  the storage check; hard-code the version number.
  **Security checks.** Both functions filter by the context organization;
  no `EXECUTE` for `ninaku_worker`; `search_path` pinned; `REVOKE … FROM PUBLIC`.
  **Definition of done.** Common DoD; `SUBSCRIPTION_PLANS_USE_CASES.md` gains
  an "Assets capability rollout" note mirroring the catalog one (owned by the
  integrator of this PR, docs only).
  **Verification.** As A1a-1, plus `npm run lint:docs-stack`.
  **Plan B delta.** The provider seed `r2-public` becomes `s3-public`
  (adapter `s3`, configuration ref `ASSETS_S3`).

***

## 6. Stage A1c — Processing functions

As built (A1c-1): `0088_assets_processing_outcome_functions.sql`, baseline revision 98 (UC "A1c-1 as built"). Refinements of the draft below: both functions refuse a transaction outside READ COMMITTED with `assets_processing_isolation` (§1.1); a lease outside 30–900 seconds is `assets_job_lease_seconds` and a backoff beyond 15 minutes `assets_retry_backoff`; only `retry_storage` is accepted on a job that is not `process_upload`; the writing outcomes lock the usage row, the asset, its upload and its original variant `FOR UPDATE` and refuse an asset that is not `processing` (`assets_outcome_state`); a failure releases the active asset slot only while the asset is not archived; the concurrency title runs inside the processing contract script. As built (A1c-2): `0089_assets_upload_expiry_and_usage_reconciliation.sql`, baseline revision 99 (UC "A1c-2 as built"). Refinements: both functions refuse a transaction outside READ COMMITTED (`assets_processing_isolation`) and a batch outside 1–500 (`assets_sweep_batch`; the draft names no bound for `reconcile_usage_batch`); `expire_uploads` picks its oldest expired `issued` candidates without a lock and then locks, in the lock order of UC §7, the usage row, the asset, the upload (`FOR UPDATE SKIP LOCKED`, re-checking `issued` and expired) and the original variant, so it never takes the upload before the usage row; it releases the active asset slot only while the asset still counts; the counting predicate is one helper, `assets.counts_as_active_asset(status, archived_at)`, used by both sweeps and by `record_processing_outcome` (replaced in `0089` with no change of behaviour); `reconcile_usage_batch` returns `NULL` only when no organization follows `p_after`; its titles run in `assets-expiry-contracts.ts` (the processing script stays as A1c-1 left it but for the four-function grant list). Two known limits (a reclaimed expired lease is not counted as an attempt; a job whose asset is no longer waiting stays leased) are requirements of A3c (§14). As built (R1, architect review of staging `29ba81bf`): `0093_assets_processing_error_budget.sql`, baseline revision 103 (UC "R1 as built"). Storage retries used to increment the same `attempt_count` that the dead-letter rule compared with 5, so four storage retries followed by the first processing error dead-lettered a possibly valid image. **Decided:** two budgets. `attempt_count` stays the total of every retry (metrics and backoff); a new `processing_error_count integer NOT NULL DEFAULT 0` (`processing_jobs_processing_errors_chk`, `>= 0`) counts processing errors only and alone drives the 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). The backfill is a conservative approximation (UC "R1 as built"): `attempt_count` for jobs whose latest `error_class` is `processing`, 0 for the rest, so a job whose latest error was a storage retry after earlier processing errors starts from 0; harmless, because no worker has claimed any job yet and every existing `attempt_count` is 0.

**Goal.** The worker's only database surface: claim one job, record its
outcome, expire uploads, reconcile usage (UC AS7–AS9 at the SQL level,
§6.3 retry classes).
**Preconditions.** A1a-2 and **A1b-1** merged (the contracts need the
seeded `s3-private` and `r2-public` providers for storage objects).
**Split.** Two PRs (next free numbers): **A1c-1** claim and record
(≈ 400 lines: migration ≈ 150, canonical ≈ 150, contracts and the
concurrency script ≈ 100) and **A1c-2** expire and reconcile (≈ 200 lines).

```sql
assets.claim_processing_job(p_lease_owner varchar, p_lease_token uuid,
                            p_lease_seconds integer, p_job_kinds varchar[])
RETURNS TABLE (job_id uuid, organization_id uuid, asset_id uuid, job_kind varchar,
               attempt_count integer, delivery_token varchar, access_class varchar,
               rendition_profile smallint, upload_id uuid, staging_provider_code varchar,
               staging_object_key varchar, declared_media_type varchar, declared_byte_size bigint)
```

Claims **at most one** job whose kind is in `p_job_kinds` and that is
`pending` with `available_at <= now()` or `leased` with
`lease_expires_at <= now()`, ordered by `available_at, id`,
`FOR UPDATE SKIP LOCKED`; sets `leased`, owner, token,
`lease_expires_at = now() + p_lease_seconds`; `p_lease_seconds` between 30
and 900 (else `23514`). Two loops pass different kinds (UC §6.7: image
loop `{process_upload, render_profile, purge_asset}`, generation loop
`{generate_image}` from A8a).

```sql
assets.record_processing_outcome(p_job_id uuid, p_lease_token uuid, p_outcome varchar,
    p_error_code varchar, p_available_at timestamptz,
    p_variant_codes varchar[], p_media_types varchar[], p_byte_sizes bigint[],
    p_checksums bytea[], p_widths integer[], p_heights integer[],
    p_variant_ids uuid[], p_storage_object_ids uuid[], p_provider_codes varchar[], p_object_keys varchar[])
RETURNS varchar
```

Parallel arrays, not a composite or JSON (no JSONB in `assets`,
`0070:49-50`); all arrays must have the same length (else `23514`
`assets_outcome_shape`). The lease must be live (`leased`, same token,
`lease_expires_at > now()`), else `assets_job_lease_lost`, recorded nothing.
**Variant ids (S3):** the arrays carry **every** variant of the outcome,
the `original` included: for `original` `p_variant_ids` holds the id that
already exists (from `reserve_upload`), which is **updated**; for each
rendition it holds a new UUIDv7 generated by the worker, which is
**inserted**. A mismatch (an `original` id that is not the asset's, or a
rendition id that already exists) is `assets_outcome_shape`. **Every move
of a variant to `failed`** sets `failure_code` and `failed_at = now()` (the
A1a-1 checks `asset_variants_failure_code_chk` and `asset_variants_state_chk`
require both); every move of an upload to `rejected` sets `rejection_code`
and `settled_at`.

| `p_outcome` | Effect | Returns |
|---|---|---|
| `ready` | Arrays must name `original` and every code of `profile_rendition_codes(rendition_profile)`; the original variant becomes `ready` with its facts; rendition variants are inserted `ready`; one `available` primary storage object per variant (`available_at = verified_at = now()`); asset `ready`; upload `verified`; usage: `reserved_bytes -= declared`, `stored_bytes += sum(p_byte_sizes)`; job `completed` | `completed` |
| `retry_storage` | `attempt_count + 1` (`processing_error_count` untouched: never counted, never reset), `error_class 'storage'`, `last_error_code`, `available_at = p_available_at` (worker's capped backoff, ≤ 15 minutes ahead, else `23514`), lease cleared, `pending`; **no attempt cap** | `pending` |
| `retry_processing` | As above with `error_class 'processing'` and `processing_error_count + 1`; when `processing_error_count + 1 >= 5` (five processing errors, consecutive or interleaved with storage retries; R1, `0093`): `dead_lettered` (both counters incremented), asset `failed` (`assets.processing_exhausted`), original variant `failed` (`failure_code 'assets.processing_exhausted'`, `failed_at`), upload `rejected` (`rejection_code 'assets.processing_exhausted'`, `settled_at`), `reserved_bytes -= declared`, `active_asset_count -= 1` | `pending` or `dead_lettered` |
| `failed_permanent` | Asset `failed` (`p_error_code`), original variant `failed` (`failure_code = p_error_code`, `failed_at`), upload `rejected` (`rejection_code = p_error_code`), `reserved_bytes -= declared`, `active_asset_count -= 1`, job `completed` | `completed` |

`assets.expire_uploads(p_batch integer) RETURNS TABLE (upload_id uuid,
staging_provider_code varchar, staging_object_key varchar)`: `issued`
uploads with `expires_at <= now()`, oldest first, `FOR UPDATE SKIP LOCKED`,
at most `p_batch` (1–500): upload `expired`, asset `failed`
(`assets.upload_expired`), original variant `failed` (same code, `failed_at`), reservation released,
`active_asset_count -= 1`.

`assets.reconcile_usage_batch(p_after uuid, p_batch integer) RETURNS uuid`:
for up to `p_batch` organizations with id greater than `p_after`, recomputes
`active_asset_count` (assets not archived in `pending`, `processing`,
`ready`), `stored_bytes` (sum of `byte_size` of `ready` variants whose primary
storage object is `available`) and `reserved_bytes` (sum of
`declared_byte_size` of `issued` and `received` uploads), each under the
usage row lock; returns the last id processed (`NULL` when done).

`render_profile` (N2) is a valid job kind and the image loop claims it,
but **no stage builds its handler**: UC AS11 is a staff-run backfill for a
future profile 2, designed when that profile is; until then no row of that
kind is ever inserted (no function enqueues it).

**Grants.** `USAGE ON SCHEMA assets TO ninaku_worker`; `EXECUTE` on the
four functions to `ninaku_worker` only.
**Tests.**

* `database/tests/contracts/assets-processing-contracts.ts`:
  `claims at most one job of the requested kinds and never a job another worker holds under a live lease`;
  `refuses recording an outcome with a lease that expired or was reclaimed`;
  `records a ready outcome only with the original and every rendition of the profile, and moves reserved bytes to stored bytes`;
  `re-queues storage errors without an attempt cap and dead-letters the fifth processing error`;
  `keeps a job pending at its first processing error after four storage retries`;
  `never resets the processing error count on a storage retry`;
  `dead-letters at the fifth processing error when storage retries are interleaved`;
  `dead-letters after five accumulated processing errors, failing the asset and releasing its reservation once`;
  `fails the asset and releases its reservation on a permanent failure`;
  `expires only issued uploads past their expiry and releases their reservations`;
  `reconciles an organization's usage counters from its rows`;
  `refuses every processing function to the runtime and every table to the worker`.
* `database/tests/concurrency/assets-claim-concurrency.ts` (as built: no separate script; the title runs inside `database/tests/contracts/assets-processing-contracts.ts`):
  `gives two concurrent claimers disjoint jobs and lets a reclaimed lease win only after expiry`.
  **Mutation targets.** Drop `SKIP LOCKED`; skip the lease-expiry check;
  cap storage retries; forget `active_asset_count` on a permanent failure;
  count a storage retry as a processing error; reset the processing count on
  a storage retry; compare `attempt_count` instead of `processing_error_count`
  with 5 (R1).
  **Security checks.** No table privilege for `ninaku_worker`; functions
  never read the tenant context (they act by job id and lease only).
  **Definition of done.** Common DoD.
  **Verification.** As A1a-1.
  **Plan B delta.** None (provider codes are data).

***

## 7. Stage A2a-1 — Module skeleton and configuration

**Goal.** `src/modules/assets/` exists with its layers, its configuration,
its domain rules and its failure mapping, with no route yet.
**Preconditions.** A1b merged (codes to map exist).
**Deliverables.**

| File or area | Content |
|---|---|
| `src/modules/assets/assets.module.ts` | Nest module registered in `AppModule`; imports `DatabaseModule`, the `identity`, `platform`, `audit` contract modules only (R1) |
| `src/modules/assets/domain/delivery-token.ts` | `generateDeliveryToken()`: 16 bytes from `node:crypto` `randomBytes`, lowercase RFC 4648 base32 without padding, 26 characters; `isDeliveryToken(value)` |
| `src/modules/assets/domain/media-type.ts` | `ACCEPTED_IMAGE_MEDIA_TYPE` const object (`image/jpeg`, `image/png`, `image/webp`, `image/avif`) and its derived type; `MAX_UPLOAD_BYTES = 10_485_760` |
| `src/modules/assets/domain/rendition-profile.ts` | Profile 1: `{ thumbnail: 'p1-thumbnail', tile: 'p1-tile', card: 'p1-card', detail: 'p1-detail' }` and geometry (UC §5.5); `renditionName(code)` and `variantCode(profile, name)` |
| `src/modules/assets/domain/object-key.ts` | `stagingKey(token)` → `in/<token>`; `masterKey(deliveryToken)` → `m/<token>/original.webp`; `publicRenditionKey(token, profile, name)` → `a/<token>/p<profile>/<name>.webp`; `privateRenditionKey(...)` → `p/...` (UC §5.2) |
| `src/modules/assets/domain/asset-status.ts` | Status const object and the transition table of SG18, mirrored for early refusals |
| `src/foundation/config/values/process-role.ts` (new) — **third process role (B6)** | `PROCESS_ROLE = { API: 'api', WORKER: 'worker', MEDIA_WORKER: 'media-worker' }` and its derived type; `databaseUserFor(role)` (`api` → `ninaku_runtime`, `worker` and `media-worker` → `ninaku_worker`); `serviceNameFor(role)` (`ninaku-api`, `ninaku-worker`, **`ninaku-media-worker`**, adding `SERVICE_NAME.MEDIA_WORKER` to `src/foundation/config/values/configuration.ts:12-14`). Today the schema takes a `DatabaseProcessRole` (`environment-variables.schema.ts:153-157`, `:393-395`) and requires `CURSOR_SIGNING_KEY`, `ACCESS_TOKEN_SIGNING_KEY`, `TOTP_SECRET_ENCRYPTION_KEY` and `SECURITY_OUTBOX_ENCRYPTION_KEY` for every process (`:190-193`); with the process role those four are required for `api` and `worker` only and are **not read** for `media-worker`. Callers move to the process role: `src/main.ts` (`api`), `src/worker.ts:13` and `src/foundation/config/worker-config.module.ts:14` (`worker`), and the database role guard (`src/foundation/database/role-guard/database-runtime-role-guard.service.ts:5`) checks `databaseUserFor(role)` |
| `src/foundation/config/schema/environment-variables.schema.ts` — **exact groups (S4)** | Each process role validates only its own groups; a variable of another role's group present in a process is refused at boot (a misplaced secret is noticed, never silently used). All groups are all-or-nothing like the SES group (`reportPartiallyConfiguredChannel`, `:297-309`). **API upload-signing group** (`api`): `ASSETS_S3_REGION`, `ASSETS_S3_BUCKET`, `ASSETS_S3_API_ACCESS_KEY_ID`, `ASSETS_S3_API_SECRET_ACCESS_KEY`, `ASSETS_PUBLIC_BASE_URL` (absolute `https` origin, no path). **Media-worker storage group** (`media-worker`): `ASSETS_S3_REGION`, `ASSETS_S3_BUCKET`, `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`. Added later by their stages: **CDN purge group** (`media-worker`, A4b): `ASSETS_CDN_ZONE_ID`, `ASSETS_CDN_PURGE_TOKEN`; **OpenAI group** (`media-worker`, A8c-1): `OPENAI_API_KEY`, `OPENAI_PROJECT_ID`, `OPENAI_IMAGE_MODEL`, `OPENAI_IMAGE_QUALITY`. The `worker` role reads none of them |
| `src/foundation/config/app-config.service.ts` | `assetsStorageSettings()` returning `undefined` when the group is absent (as `emailChannelSettings`) |
| `src/foundation/logging/redaction/sensitive-field-names.ts` | `signature` added to `SENSITIVE_FIELD_NAME_WORDS` (SEC-1, UC §11; also covers the `x-amz-signature` form field); `policy` added as an exact field name (case-insensitive, so `retentionPolicy`-style names elsewhere are not affected; as a field name it also covers the query parameter, `redaction.ts:27-33`); `redaction.ts` masks the value of the `X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token` and `Signature` query parameters inside any logged string (after `?`, `&`, the start of the text, whitespace, a quote, a comma or a semicolon, and in the percent-encoded `%3F`/`%26`/`%3D` form), keeping the rest of the text (A2a-1a review) |
| `src/modules/assets/presentation/failures/` | `assets-failures.ts`, the interceptor and `to-public-assets-failure.ts`, mapping the constraints of A1b (`assets_asset_limit` → `422 · assets.asset_limit_reached`, `assets_storage_limit` → `422 · assets.storage_limit_reached`, `assets_open_uploads_limit` → `422 · assets.too_many_open_uploads`, `assets_access_class_unavailable` → `422 · assets.access_class_unavailable`) and the plain refusals (`403 · assets.action_forbidden`, `404 · assets.not_found`, `503 · assets.storage_unavailable`, `422 · assets.asset_refused`) |
| Error registry | The codes above plus `assets.media_type_unsupported`, `assets.file_too_large`, `assets.upload_expired`, `assets.upload_rejected`, `assets.transition_invalid`, `assets.restoration_expired` (A4a) and `assets.ai_generation_limit_reached` (A8b), registered now so every later stage only maps them |

**Tests (unit).**

* `src/modules/assets/domain/delivery-token.spec.ts`: `generates 26 lowercase base32 characters from 128 random bits and recognizes only that shape`
* `src/modules/assets/domain/object-key.spec.ts`: `builds the staging, master and rendition keys from tokens and fixed codes only`
* `src/modules/assets/domain/rendition-profile.spec.ts`: `maps each profile 1 variant code to its rendition name and back`
* `src/foundation/config/schema/environment-variables.schema.spec.ts`: `refuses a partial assets storage group and accepts none or all of it`; `refuses a variable of another process role's group`; `boots the media-worker role without the identity signing and encryption keys and with the ninaku-media-worker service name`; `still requires the identity keys for the api and worker roles`
* `src/foundation/logging/redaction/redaction.spec.ts`: `redacts X-Amz-Signature, X-Amz-Credential and X-Amz-Security-Token from a logged URL`; `redacts a policy parameter and an x-amz-signature field`
* `src/modules/assets/presentation/failures/to-public-assets-failure.spec.ts`: `maps every assets constraint to its public code and anything else to the generic refusal`
  **Mutation targets.** 15 random bytes; a key built from a display name;
  drop `signature` from the list; require the identity keys for
  `media-worker`; let the `api` role read the media-worker group.
  **Security checks.** No storage SDK yet; no secret has a default value.
  **PR split.** ≈ 450 lines → split **A2a-1a** process role and environment
  groups (≈ 200) and **A2a-1b** module skeleton, domain and failures (≈ 250).
  **Verification.** `npm run typecheck`, the unit specs above,
  `npm run lint:module-boundaries`.
  **Plan B delta.** The media-worker storage group drops the four
  `ASSETS_R2_*` names (S3 credentials suffice) and the CDN purge group becomes
  `ASSETS_CDN_DISTRIBUTION_ID` (A4b).

***

## 8. Stage A2a-2 — Storage adapters and the storage contract

**Goal.** The API can sign S3 POST policies and the worker can read, write
and delete objects in S3 and R2, always with explicit credentials; the
storage-side bound is proven against a real S3-compatible server.
**Preconditions.** A2a-1 merged; A0 done in staging (bucket, keys).
**Deliverables.**

| File | Content |
|---|---|
| `package.json` | Dependencies `@aws-sdk/client-s3` and `@aws-sdk/s3-presigned-post` (same major as `@aws-sdk/client-sesv2`, `package.json:67`) |
| `src/modules/assets/application/ports/upload-policy-signer.port.ts` | `signUploadPolicy({ key, mediaType, maxBytes, expiresInSeconds }) → { url, fields, expiresAt }` |
| `src/modules/assets/application/ports/object-store.port.ts` | `head(provider, key) → { byteSize } | null`; `readAtMost(provider, key, maxBytes) → Buffer` (aborts beyond the cap); `put(provider, key, bytes, { contentType, cacheControl })`; `remove(provider, key)` (missing counts as done) |
| `src/modules/assets/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 }`, `Expires: 600` |
| `src/modules/assets/infrastructure/storage/s3-object-store.ts` | `HeadObject`, `GetObject` streamed with a byte counter, `PutObject`, `DeleteObject`; maps `NotFound`/404 to `null` and, per the A0 experiment, `403` on `in/*` to `null` only if A0 recorded 403 |
| `src/modules/assets/infrastructure/storage/object-storage.factory.ts` | Builds one `S3Client` per provider code with **explicit** `credentials` (never the default chain: the environment's `AWS_ACCESS_KEY_ID` is the SES key, UC §13.1); R2 client: endpoint `https://<ASSETS_R2_ACCOUNT_ID>.r2.cloudflarestorage.com`, region `auto` (*assumption* from R2's S3 compatibility page, S14; proven by the storage contract) |
| `tooling/assets/check-storage-contract.ts` and the npm script `assets:storage-contract` (not part of `verify`) | Runs against the bucket named by the environment: POST exactly `maxBytes` (expect `204`), POST `maxBytes + 1` (expect `400 EntityTooLarge`), POST with another `Content-Type` (expect `403`), POST to another key with the same policy (expect `403`), `HEAD` of a missing `in/` key with the worker key (prints `404` or `403`), put/get/delete a `m/` object, and **put/get/delete an `a/` object on the R2 public bucket with the media-worker's R2 client** (recent AWS SDK versions add default checksum headers that R2 may not implement, S14: the R2 client is built with `requestChecksumCalculation: 'WHEN_REQUIRED'` and `responseChecksumValidation: 'WHEN_REQUIRED'`, and this step proves R2 accepts the client as configured); exits non-zero on any unexpected status |

**Spike (pass/fail).** Run `assets:storage-contract` against (a) a local
MinIO container (`docker run -p 9000:9000 minio/minio server /data`,
*assumption*: image available and POST policies supported; fallback
LocalStack's S3) and (b) the staging bucket. Pass: every expectation holds
on staging. The HEAD result is written into UC "A2a as built"; the adapter
keeps the 403 mapping only if staging answers 403.
**Tests.**

* `src/modules/assets/infrastructure/storage/object-storage.factory.spec.ts`: `builds every storage client with explicit credentials and never from the default credential chain` (sets `AWS_ACCESS_KEY_ID` in the test environment and asserts the client's resolved credentials are the assets ones)
* `src/modules/assets/infrastructure/storage/s3-upload-policy-signer.spec.ts`: `signs a policy whose conditions are the exact key, the exact content type and a content length range up to the declared size, valid ten minutes`
* `src/modules/assets/infrastructure/storage/s3-object-store.spec.ts`: `stops reading an object one byte past the cap and treats a missing object as absent`
* `src/modules/assets/infrastructure/storage/object-storage.factory.spec.ts`: `builds the r2 client with its account endpoint, region auto and checksums only when required`
  **Mutation targets.** Remove `content-length-range`; use `starts-with`
  for the key; let the factory fall back to the default chain.
  **Security checks.** No credential in logs (the AWS SDK logger is not
  enabled); policy fields never logged.
  **PR split.** One PR ≈ 380 lines.
  **Verification.** Unit specs; `npm run assets:storage-contract` with
  staging credentials, transcript in the PR (secrets removed).
  **Plan B delta.** No R2 client; public renditions use the S3 client with
  the `a/` prefix. The storage contract supports this mode as built: with
  no R2 variable it runs S3 only, prints the R2 step as
  `SKIPPED (no R2 credentials)` and round-trips an `a/` rendition on the S3
  bucket instead; against any non-local endpoint it requires
  `ASSETS_STORAGE_CONTRACT_KEY_PREFIX` (UC "A2a-2 as built").

***

## 9. Stage A2b — Request an upload and read an asset

**Goal.** AS1 and AS3 over HTTP (UC §7.1). Creates
`docs/reference/ASSETS_CASE_MATRIX.md` with the rows of the tests it adds.
**Preconditions.** A2a-2 merged.

### 9.1 `POST /api/v1/assets/uploads` (AS1)

| Item | Specification |
|---|---|
| Headers | Session, `X-Organization-Id`, **`Idempotency-Key` required** (UUID v4/v7, `IdempotencyKeyHeadersSchema`) |
| Authorization | `OperationAuthorizationContract` with `assets.media.manage` and capability `assets.media`, organization-wide (no anchor); refusals recorded as denied tenant audits like catalog ("K2 as built") |
| Rate limit | `@Throttle({ default: { limit: 30, ttl: 60_000 } })` |
| Request (strict Zod) | `{ "mediaType": string (type/subtype, ≤ 255), "byteSize": integer ≥ 1, "accessClass": "public" | "private" | "sensitive", "displayName"?: trimmedBoundedText ≤ 255, "originalFilename"?: trimmedBoundedText ≤ 255 }` |
| Domain refusals before SQL | media type not accepted → `422 · assets.media_type_unsupported`; `byteSize > 10485760` → `422 · assets.file_too_large`; `accessClass` not `public` → `422 · assets.access_class_unavailable` |
| Main path | **Configuration first (S5):** when the API upload-signing group is absent, answer `503 · assets.storage_unavailable` before authorizing or reserving anything, so no reservation is ever left behind → authorize → `reserve_upload(...)` (new UUIDv7 ids, new delivery and staging tokens, `expires_at = now + 15 min`, the SHA-256 request hash of `request-fingerprint.ts`) → on `created`: audit `assets.asset.upload_requested` (target the asset) → commit → sign the policy (`maxBytes = byteSize`, 600 s) |
| Replay | Outcome `existing` with the same hash → `201`, `Idempotent-Replayed: true`, the asset as it is now and a **fresh** policy when the upload is still `issued` and unexpired, else `"upload": null`; another hash → `409 · idempotency.key_reused`; lock timeout on the key → `409 · idempotency.in_progress` |
| Response `201` | `Cache-Control: no-store`; body below |
| Other statuses | `400 · foundation.validation_failed`; `401`; `403 · assets.action_forbidden`; `403 · platform.capability_not_entitled`; `403 · tenant.context_rejected`; `409` as above; `422 · assets.asset_limit_reached`, `assets.storage_limit_reached`, `assets.too_many_open_uploads`; `429`; `503 · assets.storage_unavailable` when the storage group is not configured |
| Statements | 8 (session, membership, `BEGIN`, tenant context, authorization, `reserve_upload`, audit, `COMMIT`); replay 8 (the upload read replaces the audit) |
| Audit | `assets.asset.upload_requested`, after-attributes `mediaType`, `byteSize`, `accessClass`; never the policy |

```json
{
  "data": {
    "asset": {
      "id": "01929c3e-7d4e-7a51-9f00-5c2b3c1a9e10",
      "status": "pending",
      "accessClass": "public",
      "mediaKind": "image",
      "sourceKind": "upload",
      "displayName": "Hamburguesa clásica",
      "createdAt": "2026-10-06T14:03:11.402Z"
    },
    "upload": {
      "id": "01929c3e-7d4e-7a51-9f00-5c2b3c1a9e11",
      "method": "POST",
      "url": "https://ninaku-staging-assets.s3.us-east-1.amazonaws.com/",
      "fields": {
        "key": "in/k7q2m4x9p3r8t6v1w5y2z7a4bc",
        "Content-Type": "image/jpeg",
        "bucket": "ninaku-staging-assets",
        "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
        "X-Amz-Credential": "<redacted-in-docs>",
        "X-Amz-Date": "20261006T140311Z",
        "Policy": "<base64 policy>",
        "X-Amz-Signature": "<hex signature>"
      },
      "maxBytes": 2483211,
      "expiresAt": "2026-10-06T14:13:11.402Z"
    }
  }
}
```

The client posts every `fields` entry, then the `file` field last, as
`multipart/form-data` (S11 field rules).

### 9.2 `GET /api/v1/assets/{assetId}` (AS3)

| Item | Specification |
|---|---|
| Authorization | `PermissionCheckContract` with `assets.media.view`, organization-wide; never capability-gated |
| Response `200` | `{ "data": { "id", "status", "accessClass", "mediaKind", "sourceKind", "displayName", "originalFilename", "width", "height", "byteSize", "createdAt", "readyAt", "archivedAt", "failureCode", "aiGenerated": false, "renditions": { "thumbnail": { "url" } | null, "tile": …, "card": …, "detail": … } } }`; `width`, `height`, `byteSize` are the master's; URLs only for `ready` and `public` (UC §6.4 key layout) |
| ETag | Strong SHA-256 of the representation (`content-entity-tag.ts`); `Cache-Control: private, no-cache`; `If-None-Match` match → `304` empty |
| Statuses | `200`, `304`, `400`, `401`, `403 · assets.action_forbidden`, `404 · assets.not_found` |
| Statements | 7 (session, membership, `BEGIN`, tenant context, permission, one read joining the original variant, `COMMIT`) |

**Tests.**

* `test/e2e/assets-uploads.e2e-spec.ts`:
  `issues an upload for a public image with a presigned POST bound to its exact key, content type and declared size, a pending asset and a reserved quota, and records the request once`;
  `replays the same Idempotency-Key and body with the asset as it is now and a fresh upload policy, and refuses the same key with another body`;
  `refuses an svg, a heic and an unknown media type, a file over 10 MiB and a private or sensitive access class before writing anything`;
  `refuses a new upload once the plan's asset or storage limit is reached, and lets two concurrent intents at the limit admit exactly one`;
  `caps the open uploads of an organization at 20`;
  `answers 503 when the storage is not configured, before authorizing and without reserving anything`.
* `test/e2e/assets-access.e2e-spec.ts`:
  `refuses a cashier, a waiter and a manager of one business unit the plain denial and records it, and a lapsed plan the capability refusal`.
* `test/e2e/assets-library.e2e-spec.ts`:
  `reads a ready public asset with its rendition urls, never the master, under a strong tag and private no-cache, and 304 to that tag`;
  `reads a pending, processing or failed asset with no urls and its failure code`;
  `answers an asset of another organization exactly like an unknown id`.
* `test/e2e/statement-budget.e2e-spec.ts`: rows `requests an upload in 8 statements` and `reads an asset in 7 statements`.
  **Mutation targets.** Skip the hash comparison; emit URLs for a pending
  asset; drop the `@Throttle`.
  **Security checks.** Response never cached (`no-store`); the policy is
  absent from logs and audit (log capture in the e2e); RLS hides foreign assets.
  **Measurements.** None (administrative routes, D24).
  **Definition of done.** Common DoD; OpenAPI regenerated
  (`npm run openapi:check`); `ASSETS_CASE_MATRIX.md` created with totals and
  these rows (`lint:case-matrix` passes).
  **PR split.** One PR ≈ 400 lines; if over, split AS3 out.
  **Verification.** `npm run test:e2e -- assets-uploads assets-access assets-library statement-budget`.
  **Plan B delta.** None.

***

## 10. Stage A2c — Complete an upload

**Goal.** AS2 (UC §7.1) and the upload journey up to `processing`.
**Preconditions.** A2b merged.

| Item | Specification |
|---|---|
| Route | `POST /api/v1/assets/uploads/{uploadId}/completion`, no body |
| Authorization | `PermissionCheckContract` with `assets.media.manage`, organization-wide; not capability-gated |
| Main path | `complete_upload(uploadId)`; outcome `completed` or `already_received` → read the asset → `202` with the AS3 body; **no storage call** |
| Statuses | `202`; `404 · assets.not_found` (`not_found`); `422 · assets.upload_expired`; `422 · assets.upload_rejected` |
| Statements | 8 (session, membership, `BEGIN`, tenant context, permission, `complete_upload`, asset read, `COMMIT`) |
| Audit | None (UC AS2) |

**Tests.**

* `test/e2e/assets-uploads.e2e-spec.ts`:
  `completes an issued upload into processing, queues one processing job and answers repeats without writing`;
  `refuses completing an expired or rejected upload, and an upload of another organization like an unknown id`;
  `takes a photo from intent to processing with the fake storage adapter in one journey`.
* `src/modules/assets/application/uploads/complete-upload.use-case.spec.ts`: `never calls the object storage while completing an upload`.
* `test/e2e/statement-budget.e2e-spec.ts`: `completes an upload in 8 statements`.
  **Mutation targets.** Call `head` in the use case; allow completing an
  expired upload.
  **PR split.** One PR ≈ 300 lines.
  **Verification.** `npm run test:e2e -- assets-uploads statement-budget`.
  **Plan B delta.** None.

***

## 11. Stage A3a — The media-worker process

As built: UC "A3a as built" (no migration). Decided at build time (this section was silent): without the media-worker storage group the process stays up idle, logs once and starts neither the claim loop nor the expiry sweep. The modules and jobs below sit at `src/worker/media-worker.module.ts`, `src/modules/assets/assets-worker.module.ts` and `src/modules/assets/infrastructure/worker/`; the poison-row isolation of the A1c-2 requirements needs SQL and is not built.

**Goal.** A second worker process, isolated from the OTP worker (OD14,
UC §6.3), with its claim loop, the expiry sweep (AS9) and the nightly usage
reconciliation; no image handler yet.
**Preconditions.** A1c and A2c merged; A2a-1 (process role) merged; A0
created the `media-worker` Railway service.
**Deliverables.**

| File | Content |
|---|---|
| `src/media-worker.ts` | Like `src/worker.ts`, but validating the environment with the **`media-worker` process role** of A2a-1 (database user `ninaku_worker`, service name `ninaku-media-worker`, no identity keys) and bootstrapping `createMediaWorkerApplication()` |
| `src/foundation/config/media-worker-config.module.ts` | Like `worker-config.module.ts` (`:14`) with the `media-worker` process role |
| `src/bootstrap/create-media-worker-application.ts` | As `create-worker-application.ts`, with `MediaWorkerModule` |
| `src/media-worker/media-worker.module.ts` (as built: `src/worker/media-worker.module.ts`) | `MediaWorkerConfigModule`, `WorkerDatabaseModule`, `LoggingModule`, `ObservabilityModule`, `ScheduleModule.forRoot()`, `AssetsProcessingModule`; **not** `MessagingModule` or any Identity module (`src/worker/worker.module.ts:10-20` is the contrast) |
| `src/modules/assets/assets-processing.module.ts` (as built: `src/modules/assets/assets-worker.module.ts`) | Processing providers only (storage factory, processing repository, loops, sweeps) |
| `src/modules/assets/infrastructure/processing/processing-jobs.repository.ts` | Calls `claim_processing_job`, `record_processing_outcome`, `expire_uploads`, `reconcile_usage_batch` through `DatabasePoolService` (the Identity dispatch pattern, §1.2) |
| `src/modules/assets/application/processing/image-job-loop.ts` | Claims one job of the registered kinds, runs its handler outside any transaction, records the outcome; polls every `ASSETS_IDLE_POLL_MS` (default 2,000) when idle, immediately again after a job; lease `ASSETS_IMAGE_LEASE_SECONDS` (default 120); the handler registry is empty in A3a |
| `src/modules/assets/presentation/jobs/expire-uploads.job.ts` (as built: `infrastructure/worker/expire-uploads.job.ts`, logic in `application/processing/expire-abandoned-uploads.use-case.ts`) | Every 5 minutes: `pg_try_advisory_xact_lock(hashtextextended('assets.expire_uploads', 0))` inside a short transaction, `expire_uploads(500)`, then deletes each returned staging object (missing = done) |
| `src/modules/assets/presentation/jobs/reconcile-usage.job.ts` (as built: `infrastructure/worker/reconcile-usage.job.ts`) | Daily at 03:00 UTC: walks `reconcile_usage_batch` 500 organizations at a time |
| `knip.json`, `tooling/repository/check-main-imports.ts` | `src/media-worker.ts` as an entry point, with the same rules the worker entry has (`check-main-imports.ts:8-12`) |
| Environment schema | `ASSETS_IDLE_POLL_MS` (default 2000, 500–60000), `ASSETS_IMAGE_LEASE_SECONDS` (default 120, 30–900) |

**Tests.**

* `src/modules/assets/application/processing/image-job-loop.spec.ts`: `claims one job at a time, holds no transaction while its handler runs, and records the handler's outcome with the claimed lease`; `stops recording when the lease was lost and claims the next job`.
* `src/modules/assets/infrastructure/worker/expire-uploads.job.spec.ts` (planned under `presentation/jobs/`): `expires uploads under an advisory lock and deletes their staging objects, treating a missing object as done`.
* `src/modules/assets/infrastructure/processing/processing-jobs.repository.integration-spec.ts`: `claims, records and expires through the worker role only`.
* `src/bootstrap/create-media-worker-application.spec.ts`: `boots without the messaging and identity modules`; `boots with the media-worker process role, whose configuration exposes no SES, Wasender or identity signing value and reports the ninaku-media-worker service name`.
  **Mutation targets.** Open a transaction around the handler; claim
  without kinds; drop the advisory lock.
  **Security checks.** The process reads no SES, Wasender or identity
  signing variable: the `media-worker` role's schema does not declare them
  (A2a-1) and refuses another role's assets groups; the bootstrap spec asserts
  both.
  **Measurements.** None (A3c).
  **PR split.** One PR ≈ 380 lines.
  **Verification.** Unit and integration specs; `npm run lint:main-imports`,
  `npm run lint:dead-code`.
  **Plan B delta.** None.

***

### Requirements carried from A1c-2 (native review)

* **Sweep poison row.** `assets.expire_uploads` has no per-row failure isolation: a row that raises (a counter already drifted below zero, a guard trigger) aborts the whole batch and, because candidates are ordered by `expires_at, id`, the same row heads every later batch. A3a must run `assets.reconcile_usage_batch` before the expiry sweep when a batch fails, alert on a repeated sweep failure and, if the failure repeats, isolate the row (skip it by id for the next run) instead of retrying the same batch forever. Test: `isolates a failing upload so the next sweep expires the rest`.
* **Sweep lock order.** `expire_uploads` locks usage rows in upload `expires_at` order and `reconcile_usage_batch` in organization id order, so two sweeps running together can deadlock. A3a must guard both jobs with the same `pg_try_advisory_xact_lock` key so they never overlap. Test: `never runs the expiry sweep and the reconciliation at the same time`.
* **Staging cleanup durability.** The staging keys are returned once, in the call that settles the upload as `expired`. If the worker crashes or the object-store delete fails after commit, the object is orphaned. The bucket lifecycle rule for `in/` (one day, runbook section 4) bounds the orphan; A3a must delete with retries and must treat a missing object as success, and must not rely on the sweep to return the key again. Test: `deletes the staging object of an expired upload and ignores a missing one`.
* **No claim on the SQL under test inside the same review candidate** is a property of how the stage was split for the native review, not a defect.

## 12. Stage A3b-1 — Verification and the master

As built: UC "A3b-1 as built" (no migration).

**Goal.** The first half of AS7: read the staging object, verify it,
decode it once and produce the sanitized master (UC §6.3).
**Preconditions.** A3a merged.
**Deliverables.** `package.json` dependency `sharp` (worker only; prebuilt
binaries cover Linux x64 glibc ≥ 2.28 and musl, Node ≥ 20.9, S8);
`src/modules/assets/domain/media-signature.ts` (magic bytes: JPEG `FF D8
FF`; PNG `89 50 4E 47 0D 0A 1A 0A`; WebP `RIFF` + 4 bytes + `WEBP`; AVIF
`ftyp` box at offset 4 with major or compatible brand `avif` or `avis`);
`src/modules/assets/application/ports/image-codec.port.ts`;
`src/modules/assets/infrastructure/processing/sharp-image-codec.ts`
(`sharp.concurrency(1)`, `sharp.cache(false)`, input options
`limitInputPixels` of the detected format: `40_000_000` for JPEG, PNG and
WebP and `12_000_000` for AVIF, from `domain/image-limits.ts`,
`failOn: 'warning'`, `autoOrient: true`,
`animated: false`; master: `resize({ width: 4096, height: 4096, fit:
'inside', withoutEnlargement: true })`, `webp({ quality: 90 })`, alpha kept,
no `keepMetadata`/`withMetadata`);
`src/modules/assets/application/processing/verify-and-master.ts`.

**Failure matrix (A3b-1 part).**

| Condition | Class | `failure_code` |
|---|---|---|
| `HEAD` missing (`404`, or `403` if A0 recorded it) before the upload's expiry | retry storage | `assets.upload_missing` |
| `HEAD` missing after the upload's expiry | permanent | `assets.upload_missing` |
| Stored size ≠ declared size | permanent | `assets.size_mismatch` |
| Magic bytes not one of the four, or ≠ declared type, or ≠ sharp's detected format | permanent | `assets.content_mismatch` |
| Pixels > 40,000,000 (JPEG, PNG, WebP) or > 12,000,000 (AVIF), or a side > 12,000 | permanent | `assets.image_too_large` |
| A side < 64 | permanent | `assets.image_too_small` |
| sharp decode error (truncated, corrupt, warning) | permanent | `assets.image_corrupt` |
| Storage 5xx, timeout, DNS, `SlowDown` | retry storage | `assets.storage_unavailable` |
| Any other thrown error | retry processing | `assets.processing_error` |

**Tests.**

* `src/modules/assets/domain/media-signature.spec.ts`: `recognizes jpeg, png, webp and avif by their magic bytes and nothing else`.
* `src/modules/assets/infrastructure/processing/image-pipeline.integration-spec.ts`: `turns a rotated jpeg with gps exif into an upright srgb webp master with no metadata`; `refuses a png declared as jpeg, a polyglot, a truncated file and an image over 40 megapixels as permanent failures`; `decodes the input once`; `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`. Fixtures are generated in the test with sharp (no binary files committed beyond a 4 KB polyglot sample).
  **Mutation targets.** Skip the declared-type comparison; enable
  `keepMetadata`; raise the pixel cap.
  **Security checks.** The decoder never sees an object larger than the
  declared size + 1; no metadata survives (asserted by reading the output's
  EXIF and XMP as empty).
  **PR split.** One PR ≈ 300 lines.
  **Verification.** `npm run test:integration -- image-pipeline`.
  **Plan B delta.** None.

***

## 13. Stage A3b-2 — Renditions and the outcome

As built: `0096_assets_claim_original_variant.sql` (after Catalog's `0095`), baseline revision 106 (UC "A3b-2 as built"). The claim now also returns `original_variant_id` and `upload_expires_at`; the handler, the puts, the `ready` outcome and the staging delete are built, and the e2e is `test/integration/assets-processing.integration-spec.ts` against an S3-compatible server instead of the fake store.

**Goal.** The second half of AS7: renditions from the master, writes to S3
and R2, the `ready` outcome, staging cleanup; the image loop starts
claiming `process_upload`.
**Preconditions.** A3b-1 merged.
**Deliverables.** `src/modules/assets/application/processing/process-upload.handler.ts`
(registered for `process_upload`): master → `p1-thumbnail` (160×160
`cover`, `position: 'attention'`, WebP q80), `p1-tile` (320×320, same),
`p1-card` (fit inside 640, q80), `p1-detail` (fit inside 1280, q82), each
from the in-memory master; SHA-256 of every output; `put` master to
`s3-private` `m/<token>/original.webp`; `put` each public rendition to
`r2-public` `a/<token>/p1/<name>.webp` with `Content-Type: image/webp` and
`Cache-Control: public, max-age=31536000, immutable`; `record_processing_outcome('ready', …)`;
then `remove` the staging object. Permanent failures record
`failed_permanent` and remove the staging object; storage and processing
retries keep it.
**Tests.**

* `src/modules/assets/infrastructure/processing/image-pipeline.integration-spec.ts`: `derives four renditions of the profile from the master with the declared geometry`.
* `test/e2e/assets-processing.e2e-spec.ts` (as built: `test/integration/assets-processing.integration-spec.ts`, with the titles `ASSETS_CASE_MATRIX.md` AS7 quotes; the planned titles below were not kept verbatim): `processes a completed upload into a ready asset whose renditions, sizes, checksums and stored bytes are recorded in one transaction`; `deletes the staging object after a ready, a failed and an expired upload`; `fails the asset and releases its reservation on a permanent failure without retrying` (the e2e uses the fake object store and runs the handler in-process).
  **Mutation targets.** Write a rendition without `immutable`; record
  `ready` before the puts; skip the staging delete.
  **Security checks.** Public bucket receives only re-encoded renditions,
  never the master or the upload (asserted on the fake store's key list).
  **PR split.** One PR ≈ 250 lines.
  **Verification.** `npm run test:e2e -- assets-processing`.
  **Plan B delta.** Renditions go to `s3-public` `a/…` in the S3 bucket.

***

## 14. Stage A3c — Retries, dead letters, memory gate

As built: `0097_assets_requeue_dead_letters.sql` (after Catalog's `0095` and A3b-2's `0096`), baseline revision 107 (UC "A3c-1 as built" and "A3c-2 as built"). Decided at build time: the claim returns `processing_error_count` and a `claim_state` (`leased`, `dead_lettered` or `completed`), counts the reclaim of an expired lease as a processing error and dead-letters the job at the fifth, and completes a `process_upload` job whose asset or upload no longer waits (`assets.job_abandoned`); the requeue takes 1 to 500 ids, waits for the expiry sweep's advisory lock and locks the usage rows up front in organization order, accepts an upload created less than a day ago (not `expires_at`, the 600-second POST policy deadline that every processing dead letter has passed), re-checks the active-asset and storage limits and returns `requeued`, `skipped_over_limit` and `skipped_ineligible`, and skips archived assets; storage retries are capped at 14 min 30 s (a clock margin below the SQL limit), the transient storage list is wider than the stage table (SDK `$retryable`, `EPIPE`, `EHOSTUNREACH`, `ENETUNREACH`, `ECONNABORTED`, `ECONNREFUSED`, `socket hang up`, `slow down`) and any other storage failure is a processing error (`assets.storage_refused`) that counts towards the five. Paths moved to the branch's edit surface: the four contract titles run in `database/tests/contracts/assets-dead-letter-contracts.ts`, the handler title in `infrastructure/processing/process-upload.handler.spec.ts`, and `perf:assets` in `tooling/assets/`. The gate ran locally only (macOS arm64): **no-go at 1 GB** (peak 670 MB for the four images in one process, the 12 MP AVIF dominating; a 40 MP AVIF alone peaks at 1,173 MB); the lease condition passes. The owner applies the no-go order below.

**Goal.** UC §6.3 retry classes, AS8, dead-letter recovery (OD21) and the
memory measurement that fixes the pixel cap and the service limit.
**Preconditions.** A3b-2 merged; A0 `media-worker` service exists.
**Split (S2).** **A3c-1** the requeue migration with its canonical mirror
and contract (≈ 180 lines); **A3c-2** classifier, backoff, metrics and
`perf:assets` (≈ 350 lines).
**Migration (A3c-1).** `<next>_assets_requeue_dead_letters.sql` (next free number).
`assets.requeue_dead_letters(p_job_ids uuid[]) RETURNS integer`, SECURITY
DEFINER, **no `EXECUTE` grant to any application role** (run by the
operator as the migrator, runbook §11): for each `dead_lettered`
`process_upload` job created less than 1 day ago (the inbox lifecycle
deletes the staging object after 1 day, so older ones cannot be
reprocessed), job `pending` with **both** `attempt_count = 0` and
`processing_error_count = 0` (R1, `0093`: a requeue that kept the processing
count would dead-letter the job again at its first new error); asset `failed →
processing` (failure fields cleared); original variant `failed →
processing`; upload `rejected → received`; `reserved_bytes += declared`,
`active_asset_count += 1`; returns the number requeued.
**Code.** `src/modules/assets/application/processing/failure-classifier.ts`
(storage: SDK `$metadata.httpStatusCode >= 500`, `SlowDown`, timeouts,
`ENOTFOUND`/`EAI_AGAIN`/`ECONNRESET`; permanent: the codes of §12; else
processing); backoff: storage `min(5 s × 2^attempt, 15 min)` with ±20%
jitter, processing 5 s, 30 s, 2 min, 10 min after processing errors 1 to 4
(≈ 12.6 min in total; the fifth error dead-letters, so OB4's 30-minute step
never runs and SQL refuses any delay above 15 minutes); metric
`assets_processing_dead_letters` and `assets_storage_errors_total`;
`tooling/assets/run-assets-benchmark.ts` (planned under `tooling/performance/`) and `npm run perf:assets`.

**Gate (go/no-go).** `perf:assets` processes, one at a time in one
process, a 12 MP JPEG, a 40 MP JPEG, a 40 MP PNG and a 12 MP AVIF (the AVIF
cap, OD5), five times each, and reports peak RSS and p99 job time. **Go**
when peak RSS ≤ 60% of the `media-worker` memory limit (1.5 GB recommended
from the local measurement, so ≤ 900 MB; re-measured on Railway's Linux) and p99 job
time × 2 ≤ the lease (120 s). **No-go** fallback, in order: raise the
service memory (owner, runbook §8) and re-run; else lower the pixel cap to
the largest size that passes and record it in UC OD5. The numbers go to
`PERFORMANCE_BASELINE.md` and UC "A3c as built".
**Tests.**

* `src/modules/assets/infrastructure/processing/process-upload.handler.spec.ts` (planned under `application/processing/`; the handler itself is `application/processing/process-upload.handler.ts`): `retries a storage outage with capped backoff and no attempt cap, and dead-letters only the fifth processing error`.
* `src/modules/assets/application/processing/failure-classifier.spec.ts`: `classifies storage, permanent and processing failures`.
* `src/modules/assets/application/processing/failure-classifier.spec.ts`: `schedules every processing retry, for processing errors 1 to 4, at most 15 minutes ahead`.
* `database/tests/contracts/assets-processing-contracts.ts`: `requeues a dead-lettered processing job of the last day only through the operator function, restoring its reservation`.
  **Requirements carried from A1c-1 (known limits of `0088`).**

1. **Poison jobs.** `claim_processing_job` reclaims an expired lease without
   incrementing `attempt_count`, and the reclaimed job keeps its old
   `available_at`. A worker that dies before recording an outcome (for
   example sharp running out of memory on a huge image) therefore gets the
   same job back forever, first in line. A3c-1 must make the reclaim of an
   expired lease count as a processing attempt (increment `attempt_count`
   and `processing_error_count`, and dead-letter when
   `processing_error_count + 1 >= 5`, exactly as `retry_processing` does
   since R1) or add
   an equivalent poison-job guard, with a contract test:
   `counts a reclaimed expired lease as a processing attempt and dead-letters the job at the fifth`.
2. **Stranded jobs.** When the asset is no longer `processing` or the
   upload no longer `received`, `ready`, `failed_permanent` and the fifth
   `retry_processing` raise `assets_outcome_state`; the job stays `leased`
   and is reclaimed after every lease expiry without bound. A3c-1 must give
   such a job a terminal path (for example an `abandon` outcome that
   completes it without touching the asset, or skipping and completing it
   at claim time when its asset and upload are no longer waiting), with a
   contract test: `completes or dead-letters a job whose asset no longer waits for an outcome instead of reclaiming it forever`.

**Requirements carried from R1 (`0093`, two retry budgets).**

1. **Requeue resets both counters.** `requeue_dead_letters` sets
   `attempt_count = 0` and `processing_error_count = 0`, with a contract
   test: `resets both the attempt count and the processing error count of a requeued dead letter`.
2. **Processing backoff step.** `claim_processing_job` returns
   `attempt_count` (the total) only. The processing schedule (5 s, 30 s,
   2 min, 10 min: four retries, since the fifth processing error
   dead-letters) must be indexed by processing errors, not by the total, or
   storage retries would push a processing retry to a later step.
   A3c-2 either adds `processing_error_count` to the claim's result (a
   `RETURNS TABLE` change: drop and recreate, as A8a-2 does) or keeps the
   step from the total and records why.
3. **Every processing delay fits the SQL cap.** `record_processing_outcome`
   refuses an `available_at` more than 15 minutes ahead with `23514
   assets_retry_backoff`, for storage and processing retries alike. A3c-2
   indexes the processing schedule by `processing_error_count` and proves it
   with a unit test in `failure-classifier.spec.ts`:
   `schedules every processing retry, for processing errors 1 to 4, at most 15 minutes ahead`.

**Mutation targets.** Cap storage retries; classify `SlowDown` as
processing; grant `EXECUTE` on the requeue to the worker; leave a reclaimed
expired lease uncounted; leave a stranded job leased; keep the processing
error count on a requeue.
**PR split.** A3c-1 ≈ 180, A3c-2 ≈ 350 lines.
**Verification.** Unit specs, `npm run db:test`, `npm run perf:assets`
(transcript in the PR).
**Plan B delta.** None.

***

## 15. Stage A4a — Library

**Goal.** AS4, AS5, AS6 (UC §7.1).
**Preconditions.** A2b merged; the archive and restore functions of A1b-2
(§5.2 addendum below).

**Addendum to A1b-2 (moved there so A4a needs no migration).**
`assets.archive_asset(p_asset_id uuid) RETURNS varchar` and
`assets.restore_asset(p_asset_id uuid) RETURNS varchar`, SECURITY DEFINER,
`EXECUTE` to `ninaku_runtime`, tenant-filtered like `complete_upload`:
archive (`not_found`, `already_archived`, `purging` refused, else sets
`archived_at` and decrements `active_asset_count` when the status counts);
restore (`not_found`, `not_archived`, refuses `purging` and `failed`, refuses
after 30 days with `restoration_expired`, checks the asset limit like
`reserve_upload` and refuses with `assets_asset_limit`, else clears
`archived_at` and increments the count). The runtime therefore needs no
`UPDATE (archived_at)` grant: A1a-1 grants `UPDATE (display_name)` only.
Built by A1b-3 (`0094`, revision 104, §5.2 as-built note). Rename needs no
function: it changes no counter, so A4a writes it with the `UPDATE
(display_name)` grant of A1a-1 under the tenant RLS policy.

| Route | Authorization | Request | Response | Statuses | Statements | Audit |
|---|---|---|---|---|---|---|
| `GET /api/v1/assets?status=&archived=&after=&before=&limit=` | `assets.media.view` | `status` one of the five, `archived` boolean (default false), `limit` 1–100 (default 50), signed cursors (`CURSOR_SIGNING_KEY`) | `{ data: [AS3 shape with only renditions.thumbnail], meta: { pageInfo } }`, `private, no-cache`, no ETag | `200`, `400 · collection.cursor_*`, `403` | 7 | none |
| `PATCH /api/v1/assets/{assetId}` | `assets.media.manage` | `{ displayName }`; optional `If-Match` (AS3 ETag) | AS3 body and its ETag | `200`, `400`, `403`, `404`, `412 · foundation.precondition_failed` | 10 when it writes (session, membership, `BEGIN`, tenant context, permission, read and lock the asset for `If-Match`, update, audit, read the answer, `COMMIT`); 7 when unchanged (no update, audit or second read) | `assets.asset.renamed` (before/after) |
| `POST /api/v1/assets/{assetId}/archival` | `assets.media.manage` | none | AS3 body | `200`, `404`, `422 · assets.transition_invalid` | 9 (session, membership, `BEGIN`, tenant context, permission, the definer function, audit, read the answer, `COMMIT`); 8 for a repeat (no audit) | `assets.asset.archived` |
| `POST /api/v1/assets/{assetId}/restoration` | `assets.media.manage` | none | AS3 body | `200`, `404`, `422 · assets.transition_invalid`, `assets.restoration_expired`, `assets.asset_limit_reached` | 9 (session, membership, `BEGIN`, tenant context, permission, the definer function, audit, read the answer, `COMMIT`); 8 for a repeat (no audit) | `assets.asset.restored` |

Keyset order `(created_at DESC, id DESC)` on the A1a-1 indexes; never
`OFFSET` (`lint:no-offset-paging`). None of these routes is capability-gated.
**Tests.** `test/e2e/assets-library.e2e-spec.ts`:
`lists the library newest first with keyset cursors, archived assets only when asked, and only the caller organization's assets`;
`renames an asset with If-Match, records it once, and answers an identical rename without writing`;
`archives and restores an asset on a lapsed plan, keeps an archived attached image deliverable, and refuses restoring after 30 days`;
`refuses restoring an asset when the asset limit is reached`;
statement-budget rows for the four routes.
`database/tests/contracts/assets-lifecycle-contracts.ts`: `refuses archiving or restoring a purging asset and never lets its status leave purging`.
**PR split.** One PR ≈ 400 lines.
**Plan B delta.** None.
**As built.** No migration; the four routes, statement counts and audits as
the table says. The list cursor is bound to the organization, `status` and
`archived`; `If-Match` on the rename is checked under `FOR UPDATE OF` the
asset; the purging and failed refusals are proven over HTTP in
`test/e2e/assets-archival.e2e-spec.ts`. Details in UC "A4a as built".

***

## 16. Stage A4b — Purge

**Goal.** AS10 and UC §6.8: reference-safe erasure with automatic CDN purge.
**Preconditions.** A3c and A5b merged (the catalog index on `asset_id`
exists, SG11); A0 created the cache-purge token.
**Split.** **A4b-1** SQL (≈ 380 lines with the canonical mirror),
**A4b-2** worker (≈ 200 lines).
**Migration (A4b-1).** `<next>_assets_purge.sql` (next free number).

```sql
assets.enqueue_purge_candidates(p_batch integer) RETURNS integer
```

Inserts `purge_asset` jobs for up to `p_batch` assets that are archived for
more than 30 days or `failed` for more than 7 days, not `purging`, with no
open job of that kind and no `purge_asset` job completed in the last 30 days
(a referenced asset is retried monthly).

```sql
assets.purge_asset_begin(p_job_id uuid, p_lease_token uuid)
RETURNS TABLE (outcome varchar, object_keys varchar[], provider_codes varchar[],
               staging_provider_code varchar, staging_object_key varchar,
               public_rendition_names varchar[], delivery_token varchar, rendition_profile smallint)
```

Lease check; lock the asset `FOR UPDATE`; inside a nested block delete, in
this order, **every `processing_jobs` row of the asset (the running purge
job included)**, its storage objects, variants, upload and the asset row
(B2: without the jobs the FK `processing_jobs_asset_organization_fk` would
raise `23503` on every asset and the purge would never start). A `23503`
left after that can only come from a consumer's FK: answer `referenced`
(job `completed`, asset untouched). Success raises a private exception that
rolls the whole block back, then sets `status = 'purging'` and returns
`deletable` with: the storage objects' keys and provider codes (S3 `m/`,
`p/`; R2 `a/`), **the upload's staging key separately** (`in/<token>`, which
is an `assets.uploads` column, not a storage object, and is usually
already gone), and the public rendition names (UC §6.8 step 1). A8a
**replaces this function** (not only `_finish`) so the block also sets
`generation_requests.asset_id = NULL` for the asset before deleting it.

```sql
assets.purge_asset_finish(p_job_id uuid, p_lease_token uuid) RETURNS varchar
```

Lease check; deletes storage objects, variants, upload, every job of the
asset (this one included) and the asset in one transaction; nulls
`generation_requests.asset_id` once A8a exists (the function is replaced in
A8a); reconciles the organization's usage; a `23503` keeps the asset
`purging`, records `assets_asset_referenced_after_purge` on the job and
returns `referenced_after_purge`.

Grants: `EXECUTE` on the three to `ninaku_worker`.

**Worker (A4b-2).** `purge-asset.handler.ts`: `begin` → `remove` every
key (S3 `m/`, `p/`, `in/`; R2 `a/`) → Cloudflare purge by URL (`POST
https://api.cloudflare.com/client/v4/zones/{ASSETS_CDN_ZONE_ID}/purge_cache`
with `{ "files": [the public rendition URLs] }`, at most **four** URLs (profile 1 has four public renditions; the master is never public), one call;
limits 100 URLs per call and 800 URLs/s on the Free plan, S22) through
`OutboundHttpService` → `finish`. Purge errors are storage-class retries.
A daily sweep calls `enqueue_purge_candidates(500)`. New variables
`ASSETS_CDN_ZONE_ID`, `ASSETS_CDN_PURGE_TOKEN` (the CDN purge group of the `media-worker` role, A2a-1).
**Tests.**

* `database/tests/contracts/assets-purge-contracts.ts`: `answers referenced for an asset a catalog item still uses and changes nothing`; `marks an unreferenced archived asset purging without touching its storage rows, then deletes its storage objects, variants and row in one transaction`; `refuses attaching or restoring a purging asset`; `enqueues only archived assets older than 30 days and failed assets older than 7 days, once a month at most`.
* `src/modules/assets/application/processing/purge-asset.handler.spec.ts`: `purges every public rendition url from the cdn before finishing the purge`; `retries a cdn or storage failure without finishing`.
  **Requirement (from A6, the till ETag invariant).** A purge must never
  remove an asset that `catalog.item_assets` references; if a later change
  could ever let it, that change must bump the catalog version in the same
  transaction, because the C17 tag (`c-s-bu-d<fingerprint>`) does not read
  delivery state (UC "A6 as built", ETag invariant).
  **Mutation targets.** Set `purging` before the reference test; finish
  before the CDN purge; delete the asset row before its variants.
  **Security checks.** The purge token is never logged; only the four URLs
  of the asset are sent.
  **Data-subject erasure (OD23).** An erasure request under the personal-data
  policy maps to this path: archive the subject's assets, then this purge
  (S3, R2, CDN); the trigger path is defined when the policy exists; backups
  and OpenAI retention (ZDR) are covered by the policy text.
  **Plan B delta.** The CDN call becomes CloudFront `CreateInvalidation`
  with the four paths; the public keys are deleted from S3 `a/`.

As built: `0098_assets_purge.sql` (provisional number, after A3c's `0097`),
baseline revision 108 (UC "A4b-1 as built"); the worker side is UC "A4b-2
as built" (no migration).

***

## 17. Stage A5a — Published contracts

**Goal.** AS12 and AS13 (UC §6.6, §7.3): the only doors through which
another module reads or attaches an asset.
**Preconditions.** A3b-2 merged (ready assets exist).
**Deliverables.**

| File | Content |
|---|---|
| `src/modules/assets/contracts/asset-usage.contract.ts` | `AssetUsageContract.assertAttachable(ctx, { assetIds, requirement: { mediaKind: 'image', accessClass: 'public' } }) → AttachableAsset[]`; `AttachableAsset { assetId, imageRef }`; throws `AssetNotAttachableError { refusals: [{ assetId, reason }] }`, reasons `not_found`, `not_ready`, `archived`, `purging`, `wrong_media_kind`, `wrong_access_class` (and `not_approved` from A8d) |
| `src/modules/assets/contracts/asset-delivery.contract.ts` | `AssetDeliveryContract.describe(ctx, { assetIds, renditions }) → { assets: Map<assetId, { status, imageRef, aiGenerated, urls }> }`; `tileUrlTemplate()`; `fingerprint()` (first 8 hex of SHA-256 of `ASSETS_PUBLIC_BASE_URL` + `|` + the profile list) |
| `src/modules/assets/contracts/assets-contracts.module.ts` | Exports both, imported by consumers (the catalog contract module precedent, `src/modules/catalog/contracts/catalog.module.ts`) |
| `src/modules/assets/infrastructure/usage/asset-usage.repository.ts` | One statement: `SELECT … FROM assets.assets WHERE id = ANY($1) ORDER BY id FOR SHARE` under RLS (the caller's tenant transaction) |
| `src/modules/assets/infrastructure/delivery/asset-delivery.repository.ts` | One statement on `assets.assets` (PK lookups), ≤ 2,000 ids |
| `docs/contracts/MODULE_CONTRACTS.md` §15 | Two rows in Spanish: owner `assets`, files, consumers (`catalog` from A5b) |

**Tests.**

* `src/modules/assets/infrastructure/delivery/asset-delivery.repository.integration-spec.ts`: `describes up to 2000 assets in one statement, with urls only for ready public assets`.
* `src/modules/assets/infrastructure/usage/asset-usage.repository.integration-spec.ts`: `refuses an asset that is not ready, archived, purging, private or of another organization, each with its reason, and holds a share lock until commit`.
* `src/modules/assets/contracts/asset-delivery.contract.spec.ts`: `changes the fingerprint when the public base url changes`.
* `database/tests/contracts/assets-lifecycle-contracts.ts` (N5): `lets the runtime lock assets for share, which needs its column update grant on display_name` (PostgreSQL requires an `UPDATE` privilege on at least one column for `FOR SHARE`; revoking that grant would silently break attachment)
  **Mutation targets.** Drop `FOR SHARE`; return URLs for `processing`;
  ignore `archived_at`.
  **Security checks.** Contracts expose no key, bucket, provider or variant id
  (type-level review); `lint:module-boundaries` passes.
  **PR split.** One PR ≈ 350 lines.
  **Plan B delta.** None (the template's host changes with the variable).

***

## 18. Stage A5b — Catalog image link

**Goal.** AC1, AC2, AC3 (UC §7.5) and the catalog side of SG11.
**Preconditions.** A5a merged; OD16 accepted.
**Split.** **A5b-1** migration (≈ 220 lines with the canonical mirror), **A5b-2** routes (≈ 350 lines).
**Migration (A5b-1).** `<next>_catalog_item_images.sql` (next free number; as built `0095_catalog_item_images.sql`, baseline revision 105, UC "A5b as built").
`GRANT SELECT, INSERT, UPDATE, DELETE ON catalog.item_assets TO
ninaku_runtime`; statement-level triggers `item_assets_catalog_version_insert_trg`,
`…_update_trg`, `…_delete_trg` executing `catalog.bump_catalog_version()`
(the K1 pattern, `0075_catalog_foundations.sql:60-112`); `CREATE INDEX
item_assets_organization_asset_idx ON catalog.item_assets (organization_id,
asset_id)`; audit actions `catalog.item.images_set` and
`catalog.item.variant_images_set` (the values of `catalog.item.categories_set`,
`0380_audit.sql:3195`); canonical end state in `0080_catalog.sql`,
`0380_audit.sql`, `runtime_grants.sql`; the K1 contract's unreachable list
(`database/tests/contracts/catalog-foundations-contracts.ts:45-57`) loses
`item_assets`.

**Routes (A5b-2).**

| Route | Request | Response | Errors |
|---|---|---|---|
| `PUT /api/v1/catalog/items/{itemId}/images` (AC1) | `{ "images": [{ "assetId": uuid }] }`, 0–10, distinct; first = primary; optional `If-Match` (C3 ETag) | `200`, the C3 body with `images[]`, its new ETag | `400 · foundation.validation_failed` (11+ or duplicates), `403 · catalog.action_forbidden`, `403 · platform.capability_not_entitled` (unless removal only), `404 · catalog.not_found`, `412`, `422 · catalog.item_archived`, `422 · catalog.image_not_attachable` (`meta.errors[]: { assetId, reason }`) |
| `PUT /api/v1/catalog/items/{itemId}/variants/{variantId}/images` (AC2; merged in API v7 into `PUT /api/v1/catalog/items/{itemId}/images` with `{ images, variants? }`) | same | same | same, plus `404` for a variant of another item |

Authorization `catalog.items.manage` organization-wide with capability
`catalog.items`; a list that only removes images is ungated (C13 rule).
Lock order: `catalog_versions FOR UPDATE` → item `FOR UPDATE` →
`assertAttachable` for ids not already attached (`FOR SHARE`, id order)
→ `DELETE` rows not in the list → **demote**: `UPDATE catalog.item_assets SET
media_role = 'gallery' WHERE item_id = $item AND item_variant_id IS NULL AND
media_role = 'primary' AND asset_id <> $newPrimary` (B3:
`item_assets_one_item_primary_uq` and `item_assets_one_variant_primary_uq`
are not deferrable, `0080_catalog.sql:702-716`, so the old primary must stop
being primary before the new one is written; the catalog solved the same
problem for default variants with two statements, `CATALOG_USE_CASES.md:1395`)
→ **upsert** `INSERT … ON CONFLICT (item_id, asset_id) WHERE item_variant_id IS NULL
DO UPDATE SET media_role = EXCLUDED.media_role, sort_order = EXCLUDED.sort_order`
(the conflict target infers the partial unique index `item_assets_item_asset_uq`,
`0080_catalog.sql:688-693`; for a variant list the twin is `ON CONFLICT
(item_variant_id, asset_id) WHERE item_variant_id IS NOT NULL` and the demotion
filters `item_variant_id = $variant`), `sort_order` = position, `media_role` =
`primary` for the first → audit with before/after id lists.
Same list → `200`, nothing written. C3 (AC3) gains `images: [{ assetId,
isPrimary, sortOrder, status, thumbnailUrl, cardUrl, detailUrl }]` and
`variants[].images`, through one `describe` call; the item ETag (content
hash) follows.
Statements (AC1): session, membership, `BEGIN`, tenant context,
authorization, version lock, item lock, `assertAttachable`, delete, demote,
upsert, audit, item representation read (the C3 statements as built),
`COMMIT`; the e2e pins the number it measures, expected ≈ 14.
**Version bumps (S7).** The statement-level triggers bump the version once
per statement **whose transition table is not empty**
(`catalog.bump_catalog_version`, `0080_catalog.sql:4152-4162`), and an
`INSERT … ON CONFLICT DO UPDATE` fires both the insert and the update
statement triggers; so one image command moves the version by 1 to 4
(delete, demote, upsert-insert, upsert-update), never by more, and an
unchanged list moves it by 0.

**Tests.**

* `test/e2e/catalog-item-images.e2e-spec.ts`: `sets the images of an item with the first as primary, answers the item with its image urls and a new tag, and records the before and after lists`; `refuses a processing, archived, private or foreign asset, more than 10 images and duplicates, and removes images on a lapsed plan`; `serializes attaching an asset with archiving it, so an archived asset is never attached`; `sets the images of a variant apart from the item images, with one primary per variant`; `changes the item tag when its images change, so the old If-None-Match answers 200`.
* `database/tests/contracts/catalog-item-images-contracts.ts`: `bumps the catalog version once per image statement that changed rows, between one and four times per command, and lets the runtime replace an item's image set`;
  `moves the primary image to another asset of the same item without violating the one-primary index`.
* `test/e2e/statement-budget.e2e-spec.ts`: `sets an item's images in the measured statements`.
  **Mutation targets.** Skip `assertAttachable` for existing ids; lock the
  asset before the version row; drop the bump trigger on delete; remove the
  demotion statement.
  **Security checks.** A foreign asset id answers like an unknown one; the
  invariant of UC §2.4 (attach only through the contract) is the only insert
  path into `item_assets`.
  **Plan B delta.** None.

***

## 19. Stage A5c — Bulk image command

**Goal.** AC5 (UC §7.5, OD16).
**Preconditions.** A5b merged.
`PUT /api/v1/catalog/item-images` with `{ "items": [{ "itemId": uuid,
"images": [{ "assetId": uuid }] }] }`, 1–100 items, each list 0–10, an item
once; authorization as AC1; one transaction: version row → items `FOR
UPDATE` in id order → one `assertAttachable` for every new id (≤ 1,000) →
one set-based `DELETE`, one set-based demotion and one set-based upsert
(the AC1 statements over all items) → one audit per changed item →
`200 { "data": { "items": [{ "itemId", "etag" }] } }` (as built in UC "A5c as built"). Any refusal refuses
all with `meta.errors[]` naming item and asset. The version moves by 1 to 4
whatever the number of items (one bump per non-empty statement, S7).
**Tests.** `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`; statement-budget row `sets images for 100 items with a statement count independent of the number of items except the audits`.
**Mutation targets.** Per-item statements; partial commit on refusal.
**PR split.** One PR ≈ 300 lines.
**Plan B delta.** None.

***

## 20. Stage A6 — Till read with images

**Goal.** AC4 (UC §7.5): `imageRef` per item and variant and one
`meta.media.tileUrlTemplate` in C17 and C19.
**Preconditions.** A5b merged; OD13 confirmed on 2026-10-06 (the template's host is `media.ninaku.io` / `media-staging.ninaku.io`).
**Deliverables.** The C17 read statement left-joins `item_assets` through
`item_assets_one_item_primary_uq` (`0080_catalog.sql:702-708`) and the
variant twin; one `describe` call; the ETag becomes
`"c<catalogVersion>-s<structureVersion>-<businessUnit|none>-d<fingerprint>"`;
`aiGenerated` beside `imageRef` (OD19). C19 the same per page.
**Statement budgets.** C17 `200` 8 → 9, C17 `304` stays 7, C19 8 → 9
(`test/e2e/statement-budget.e2e-spec.ts` updated).
**Gate.** `npm run perf:http` before and after on the catalog scenarios;
`catalog-till-sellables` and `catalog-till-sellables-restaurant`
(`tooling/performance/measurement/known-breaches.ts:25-34`) are
re-baselined with the measured delta and its cause, because `perf:http`
fails on a regression above 25% (`AGENTS.md` §5.5). **Go:** p90/p95/p99 of
the restaurant till still meet the targets and the `304` stays near 2 ms;
**no-go:** drop `aiGenerated` from the till body (served by C3 instead) and
re-measure.
**Tests.** `test/e2e/catalog-till-images.e2e-spec.ts`: `answers each sellable item with its image reference and one tile url template, and changes the till tag when an image is set, never when another organization's image changes`; `src/modules/catalog/application/till/sellable-entity-tag.spec.ts`: `includes the delivery fingerprint so a new public base url gives another tag`; statement-budget: `reads what an outlet sells with images in 9 statements and answers 304 in 7`.
**PR split.** One PR ≈ 250 lines plus the baseline file.
**Plan B delta.** None (template host from the variable).
**As built.** UC "A6 as built" (`ASSETS_USE_CASES.md`): go, re-baselined.

***

## 21. Stage A7 — Private and sensitive delivery

**Goal.** AS14 (UC §7.4).
**Preconditions.** **Blocked until the personal-data policy is published**
(OD15: it must name S3 `us-east-1` storage) **and the owner confirms which
private files will be stored**; A3c merged.
**Migration.** Next free number: `CREATE OR REPLACE` of `reserve_upload`
(same signature) accepting `private` and `sensitive`; audit action
`assets.asset.sensitive_accessed` (risk `high`) in canonical `0380_audit.sql`.
**Code changes to earlier stages (N6).** A2b's pre-SQL refusal
(`accessClass` not `public` → `422 · assets.access_class_unavailable`)
is narrowed to nothing: every access class is accepted; A3b-2's handler
writes renditions of non-public assets to S3 `p/<token>/p1/<name>.webp`
instead of R2; `describe` keeps returning URLs only for `public`.
**Infrastructure.** The API IAM policy gains `s3:GetObject` on
`ninaku-<env>-assets/p/*` (runbook §4.1, owner).
**Route.** `GET /api/v1/assets/{assetId}/renditions/{rendition}/access`
(`assets.media.view`): `200 { "data": { "url", "expiresAt" } }` with
`Cache-Control: no-store`; presigned `GET` 300 s (`private`) or 60 s
(`sensitive`, audited); `404` for public assets (they have URLs), unknown
renditions and foreign assets. Statements 7 (session, membership, `BEGIN`,
tenant context, permission, read, `COMMIT`); 8 for sensitive (audit).
**Tests.** `test/e2e/assets-private-access.e2e-spec.ts`: `issues a five-minute signed url for a private rendition and a sixty-second audited one for a sensitive rendition, never cached`; `refuses signed access to a public asset and to another organization's asset`; `test/e2e/assets-uploads.e2e-spec.ts`: `accepts a private upload once private delivery exists`.
**PR split.** Two PRs ≈ 250 each (SQL with its canonical mirror; route and
worker branch).
**Plan B delta.** None.

***

## 22. Stage A8a — AI schema and functions

**Goal.** UC §6.7.1 SG19–SG21, with every earlier function that a
generated asset touches adjusted.
**Preconditions.** **Blocked until the personal-data policy is published**
(OD15: it must name OpenAI as a processor and S3 `us-east-1`); A8 does not
reach production before that and before the owner approves the AI-label
wording (OD19); OD17 takes its recommended value unless the owner objects;
OD18 and OD20 accepted (recommended values); A1c and A4b merged (A8a
replaces functions of both).
**Split.** **A8a-1** tables, job changes and seeds (≈ 400 lines with the
canonical mirror), **A8a-2** new functions (≈ 400), **A8a-3** replaced
functions (≈ 400); migrations `<next>_assets_generation_tables.sql`,
`<next>_assets_generation_functions.sql`,
`<next>_assets_generation_replacements.sql` (next free numbers; A7 comes
first in the default order).

### 22.1 A8a-1 tables, job changes and seeds

* `assets.generation_requests` exactly as UC SG19 (columns, checks,
  `UNIQUE (organization_id, creation_idempotency_key)`, **`UNIQUE (id,
  organization_id)`** for tenant-safe references, `asset_id` and
  `source_asset_id` tenant-safe FKs to `assets.assets`), RLS select policy,
  `SELECT` to `ninaku_runtime`.
* `assets.ai_generation_usage` (SG20), RLS select, `SELECT` to `ninaku_runtime`.
* **`assets.processing_jobs` (B4):** add `generation_request_id uuid` with
  the tenant-safe FK `(generation_request_id, organization_id) →
  assets.generation_requests (id, organization_id)`; **make `asset_id`
  nullable** (the generated asset exists only after the provider answered)
  with `CHECK (job_kind = 'generate_image' OR asset_id IS NOT NULL)` and
  `CHECK (job_kind <> 'generate_image' OR generation_request_id IS NOT
  NULL)`; replace `processing_jobs_kind_chk` to add `generate_image`. Both
  are the explicit expand-only exceptions of §1.1 (objects of this same
  release, no reader but the media worker). `processing_jobs_one_open_uq
  (asset_id, job_kind)` keeps working (NULLs never conflict); add
  `processing_jobs_one_open_generation_uq (generation_request_id) WHERE
  state IN ('pending','leased')`.
* Seeds: capability `assets.ai_generation` and limit `assets.ai_generations`
  (`integer`, unit `generations`, `reset_period 'month'`) in canonical
  `0060_platform.sql`; **the Pionero "current maximum + 1" `DO` block with
  `assets.ai_generation` and `assets.ai_generations = 20` (OD18) both in the
  migration and in canonical `0060_platform.sql`** (B1, §1.1); resource kind
  `assets.generation` and actions `assets.generation.requested`,
  `.approved`, `.rejected` in canonical `0380_audit.sql`.

### 22.2 A8a-2 new functions

Usage arithmetic shared by all of them (OD20): a request reserves **one
asset slot** (`active_asset_count + 1`), **3 MiB** (`reserved_bytes +
3145728`) and **one generation unit** (`ai_generation_usage.reserved + 1`
for the current UTC month).

| Function (SECURITY DEFINER) | Role | Behaviour |
|---|---|---|
| `assets.request_generation(p_request_id uuid, p_style_preset varchar, p_instruction varchar, p_source_asset_id uuid, p_prompt_text varchar, p_prompt_sha256 text, p_model_code varchar, p_quality varchar, p_size varchar, p_idempotency_key uuid, p_request_hash text) RETURNS TABLE (outcome varchar, request_id uuid, state varchar, request_hash text)` | runtime | Tenant assertion; usage row lock; replay by key → `existing`; monthly limit (`assets_ai_generation_limit`); asset limits with the slot and 3 MiB (`assets_asset_limit`, `assets_storage_limit`); inserts the request (`queued`) and the `generate_image` job (`asset_id NULL`, `generation_request_id`); reserves as above. The prompt is built by the application (A8b) from the item; the function stores it |
| `assets.create_generated_asset(p_job_id uuid, p_lease_token uuid, p_asset_id uuid, p_original_variant_id uuid, p_provider_original_variant_id uuid, p_storage_object_id uuid, p_delivery_token varchar, p_provider_original_key varchar, p_provider_original_bytes bigint, p_provider_original_sha256 bytea, p_width integer, p_height integer, p_provider_request_id varchar) RETURNS varchar` | worker | Lease check; inserts the asset **`processing`** (`source_kind 'ai_generated'`, `access_class 'public'`, `created_by_membership_id` = the requester), its `original` (`processing`) and a `ready` `provider-original` variant with its `available` storage object on `s3-private`; sets `processing_jobs.asset_id` and `generation_requests.asset_id`; request `generating`; `stored_bytes += p_provider_original_bytes`; never refuses on a limit reached meanwhile (OD20) |
| `assets.record_generation_failure(p_job_id uuid, p_lease_token uuid, p_state varchar, p_failure_code varchar, p_moderation_outcome varchar) RETURNS varchar` | worker | Before any asset exists: request `failed` or `refused`; releases the unit (`reserved - 1`), the slot and the 3 MiB; job `completed` |
| `assets.decide_generation(p_request_id uuid, p_decision varchar) RETURNS TABLE (outcome varchar, asset_id uuid)` (**B8**) | runtime | Tenant assertion and the explicit `organization_id = identity.current_organization_id()` filter; locks the request; only `review` moves: `approve` → `approved` (the unit stays consumed); `reject` → `rejected` and the asset archived (`archived_at`, `active_asset_count - 1`; the unit stays consumed because the provider was paid, OD20); any other state → `invalid_state`; unknown or foreign → `not_found` |

### 22.3 A8a-3 replaced functions (B2, B5)

| Function | Change | How |
|---|---|---|
| `assets.claim_processing_job` | Returns `generation_request_id` too (upload columns `NULL` for `generate_image`) | **`DROP FUNCTION` + `CREATE` + `GRANT EXECUTE … TO ninaku_worker` again**: a `RETURNS TABLE` change cannot be `CREATE OR REPLACE`d (explicit exception, §1.1); the media worker is redeployed with the migration |
| `assets.record_processing_outcome` | **AI branch**, chosen when the job's asset has `source_kind = 'ai_generated'` and no upload row: `ready` skips the upload (`verified`, `reserved_bytes -= declared`) and instead does `reserved_bytes -= 3145728`, `stored_bytes += sum(p_byte_sizes)`, request `review`, unit consumed (`reserved - 1`, `consumed + 1`); `failed_permanent` and an exhausted `retry_processing` set the request `failed`, consume the unit (the provider was paid), `active_asset_count - 1`, `reserved_bytes -= 3145728` | `CREATE OR REPLACE` (same signature) |
| `assets.reconcile_usage_batch` | `active_asset_count` adds the requests in `queued` or `generating` without an asset (their slot); `reserved_bytes` adds 3 MiB for each request whose asset is not `ready` yet; recomputes `ai_generation_usage` for the current month (`reserved` = requests `queued`/`generating`, `consumed` = requests that produced an image) | `CREATE OR REPLACE` |
| `assets.purge_asset_begin` | The rolled-back block also sets `generation_requests.asset_id = NULL` for the asset before deleting it | `CREATE OR REPLACE` (same signature) |
| `assets.purge_asset_finish` | Sets `generation_requests.asset_id = NULL` before deleting the asset | `CREATE OR REPLACE` |

**Tests.** `database/tests/contracts/assets-generation-contracts.ts`:
`reserves one generation unit, one asset slot and three megabytes per request and replays the same key`;
`refuses a request over the monthly limit`;
`queues a generation job without an asset and refuses any other job kind without one`;
`creates a generated asset with its original and provider original, counts it toward the asset and storage limits, and keeps it when the limit was reached after the request`;
`releases the unit and the reservation on a failure or a refusal before any asset exists`;
`moves a ready generated asset's request to review, consuming its unit and its three-megabyte reservation`;
`approves or rejects only a request in review, archiving the asset on rejection, and only in the caller's organization`;
`keeps the slot and the reservation of queued generations when usage is reconciled`;
`claims a generation job with its request id through the recreated claim function`;
`purges a generated asset after nulling its request's asset`.
**Mutation targets.** `CREATE OR REPLACE` the claim function (fails to
apply); forget the AI branch of `ready`; reconcile without queued
generations; let `decide_generation` move a `generating` request.
**Security checks.** Prompt text readable only by the organization (RLS);
`decide_generation` filters by the context organization; worker functions
never read the tenant context.
**Plan B delta.** None.

***

## 23. Stage A8b — AI request from the catalog

**Goal.** AC6 and the AS15 read (UC §6.7, §7.5).
**Preconditions.** A8a merged; A5b merged.
**Deliverables.** `src/modules/assets/contracts/asset-generation.contract.ts`
(`request(ctx, input)` calling `request_generation`);
`src/modules/assets/domain/generation-prompt.ts` (template per preset, UC
§6.7 "Prompt"; instruction ≤ 200, NUL and control characters rejected,
URLs removed; description cut at 300; SHA-256 of the final prompt);
`src/modules/catalog/presentation/controllers/item-image-generations.controller.ts`.

| Route | Specification |
|---|---|
| `POST /api/v1/catalog/items/{itemId}/image-generations` | `Idempotency-Key` required; body `{ "stylePreset": "studio_white" | "rustic_table" | "bright_menu" | "transparent_cutout", "instruction"?: string ≤ 200, "sourceAssetId"?: uuid }`; authorization `catalog.items.manage` **and** `assets.media.manage` organization-wide, capability `assets.ai_generation`; `202 { "data": { "generationId", "state": "queued" } }`; errors `400`, `403`, `404 · catalog.not_found`, `409 · idempotency.*`, `422 · catalog.item_archived`, `assets.ai_generation_limit_reached`, `assets.asset_limit_reached`, `assets.storage_limit_reached`, `catalog.image_not_attachable` (bad source); statements 9 (session, membership, `BEGIN`, tenant context, authorization, item read, `request_generation`, audit, `COMMIT`); audit `assets.generation.requested` |
| `GET /api/v1/assets/generations/{generationId}` | `assets.media.view`; `200 { "data": { "id", "state", "intent", "stylePreset", "assetId", "preview": { "thumbnailUrl", "cardUrl" } | null, "failureCode", "createdAt" } }`; strong ETag, `private, no-cache`; never the prompt text; statements 7 |

**Tests.** `test/e2e/catalog-item-image-generation.e2e-spec.ts`: `queues a generation for an item with a prompt built from its name, description and category and a bounded instruction, reserving one unit of the monthly quota`; `refuses a generation once the monthly limit is reached and on a plan without the capability, writing nothing`; `src/modules/assets/domain/generation-prompt.spec.ts`: `drops control characters, urls and text over 200 characters from the instruction and never includes another organization's data`.
**Security checks.** The prompt is built only from the caller's item
(RLS); the response never echoes the prompt; prompt text not logged.
**PR split.** One PR ≈ 400 lines.
**Plan B delta.** None.

***

## 24. Stage A8c-1 — OpenAI client

**Goal.** A port and a dedicated adapter that call the Images API safely.
**Preconditions.** A8a merged; A0 OpenAI steps done (runbook §9).
**Decision (B7).** `OutboundHttpService` is deliberately small: it caps
responses at 1 MiB (`OUTBOUND_HTTP_MAX_RESPONSE_BYTES`,
`src/foundation/outbound-http/outbound-http.service.ts:25`, applied at
`:97-99`) and sends only string bodies
(`src/foundation/outbound-http/values/outbound-http-request.ts:7`). A
base64 image from `/images/generations` exceeds 1 MiB (a 1024×1024 PNG is
typically 1–3 MB before base64, *assumption*), so both calls would fail.
**The OpenAI adapter uses Node's global `fetch`** (with `FormData` and
`Blob` for `/images/edits`), lives in the Assets infrastructure of the media
worker only, and enforces its own limits: request timeout 180 s
(`AbortSignal.timeout`), response body read as a stream and aborted beyond
**16 MiB**, JSON parsed only after the cap, never logged. The shared service
stays unchanged.
**Deliverables.** `src/modules/assets/application/ports/image-model.port.ts`
(`generate({ prompt, size, quality, format })`, `edit({ image, prompt,
size, quality, format })` → `{ bytes, providerRequestId, moderation }`
or a classified failure: `rate_limited` with `retryAfter`, `unavailable`,
`refused`, `invalid`, `too_large`);
`src/modules/assets/infrastructure/generation/openai-image-model.ts`
calling `POST https://api.openai.com/v1/images/generations` and
`…/images/edits` (S30) with `Authorization: Bearer <OPENAI_API_KEY>`,
`OpenAI-Project: <OPENAI_PROJECT_ID>`, model and quality from
configuration, `output_format: png`, `moderation: auto`, and the
response's `x-request-id`; the OpenAI group of the `media-worker` role
(A2a-1): `OPENAI_API_KEY`, `OPENAI_PROJECT_ID`, `OPENAI_IMAGE_MODEL`
(default `gpt-image-1.5`), `OPENAI_IMAGE_QUALITY` (default `medium`).
**Check (replaces the spike).** The integration spec runs the adapter
against a local HTTP stub: a 6 MiB base64 body is accepted, a 17 MiB body is
refused as `too_large` without buffering it whole, and the edit request
arrives as `multipart/form-data` with the image part intact.
**Tests.** `src/modules/assets/infrastructure/generation/openai-image-model.integration-spec.ts`:
`sends the configured model, quality, size and format with the project header and returns the image bytes with the provider request id`;
`accepts a response of six megabytes and refuses one over sixteen without reading it whole`;
`sends an edit as multipart form data with the image part intact`;
`classifies 429 with Retry-After, 5xx, a moderation refusal and a timeout`;
`never logs the prompt, the api key or the response body`.
**PR split.** One PR ≈ 300 lines.
**Plan B delta.** None.

***

## 25. Stage A8c-2 — Generation job

**Goal.** AS15w (UC §6.7): the generation loop.
**Preconditions.** A8c-1 and A3c merged.
**Deliverables.** `src/modules/assets/application/generation/generation-job-loop.ts`
(claims kinds `{generate_image}`, up to 2 concurrent jobs, lease 300 s for
one attempt); `src/modules/assets/application/generation/generate-image.handler.ts`:
build the call from the request, call the model (no transaction open),
`put` the provider original to S3 `m/<token>/provider-original.png`,
`create_generated_asset`, hand the bytes to the A3b pipeline (serialized
decoder) and record `ready` through `record_processing_outcome`.
**Retry rules (S10).** Storage errors while writing to S3 or R2 use
`retry_storage`, uncapped, like every job (§14). **Provider errors** (429,
5xx, timeout) are capped by the handler, not by SQL: it reads the claimed
`attempt_count`; when `attempt_count + 1 < 3` it re-queues through
`record_processing_outcome('retry_storage', …)` with `available_at` from
`Retry-After` (lease released, never held); at the third provider failure it
calls `record_generation_failure('failed', 'assets.ai_unavailable')`. A
moderation refusal calls `record_generation_failure('refused', …)`.
**Tests.** `src/modules/assets/application/generation/generate-image.handler.spec.ts`:
`stores a generated image through the upload pipeline as ai_generated, keeps the provider original privately and consumes the reserved unit`;
`marks a request refused by moderation, releases its unit and creates no asset`;
`re-queues a rate-limited call after its Retry-After instead of holding its lease, and fails the request at the third provider failure`;
`retries a storage failure while saving the provider original without counting it against the provider cap`;
`src/modules/assets/application/generation/generation-job-loop.spec.ts`:
`runs at most two generations at once while image work stays serialized and holds no transaction during the provider call`.
**Measurements.** Provider latency p50/p99 and cost per image over 20
staging generations, recorded in UC "A8c as built".
**PR split.** One PR ≈ 350 lines.
**Plan B delta.** None.

***

## 26. Stage A8d — Review, approval and disclosure

**Goal.** AS16, the `not_approved` rule and the disclosure flag (UC §6.7,
OD19).
**Preconditions.** A8c-2 merged; OD19 answered (recommended value
accepted by default).

| Route | Specification |
|---|---|
| `POST /api/v1/assets/generations/{generationId}/approval` | `assets.media.manage`; `decide_generation(id, 'approve')` (A8a-2); `200` with the AS15 read body; `422 · assets.transition_invalid` for `invalid_state`; `404 · assets.not_found`; audit `assets.generation.approved`; statements **9** (session, membership, `BEGIN`, tenant context, permission, `decide_generation`, audit, read the answer, `COMMIT`) |
| `POST /api/v1/assets/generations/{generationId}/rejection` | Same with `'reject'`: the request becomes `rejected` and its asset archived inside the function; audit `assets.generation.rejected`; statements 9 |

`assertAttachable` gains `not_approved` for an `ai_generated` asset whose
request is not `approved` (A5a repository statement joins
`generation_requests`, still one statement); `describe` returns
`aiGenerated`; the till carries it (A6). No migration: the runtime writes
only through `decide_generation`.
**Tests.** `test/e2e/assets-generation-review.e2e-spec.ts`: `refuses attaching a generated image until it is approved, attaches it after approval and archives it on rejection`; `refuses deciding a request that is not in review or belongs to another organization`; `src/modules/assets/infrastructure/delivery/asset-delivery.repository.integration-spec.ts`: `marks generated images as ai generated in the delivery description`; statement-budget rows `approves a generated image in 9 statements` and `rejects a generated image in 9 statements`.
**PR split.** One PR ≈ 350 lines.
**Plan B delta.** None.

***

## 27. Readiness table

This is the **single** readiness table of the Assets plan;
`ASSETS_USE_CASES.md` §14.2 points here. "READY" means the specification is
final: a writer can start as soon as the listed earlier stages have merged.
"Blocked" names exactly what is missing besides earlier stages.

| Stage | PRs | Status | Blocked by |
|---|---|---|---|
| A0 | 1 (IaC) + owner runbook | READY | — (OD1 and OD13 confirmed 2026-10-06; OpenAI steps §9 wait for the published data policy) |
| A1a-1 | 1 | READY | — |
| A1a-2 | 1 | READY | — |
| A1b-1, A1b-2, A1b-3 | 3 | READY | — |
| A1c-1, A1c-2 | 2 | READY | — |
| A2a-1a, A2a-1b | 2 | READY | — |
| A2a-2 | 1 | READY | A0 done in staging (the storage contract runs against it) |
| A2b | 1 | READY | — |
| A2c | 1 | READY | — |
| A3a | 1 | READY | A0 (`media-worker` service) |
| A3b-1 | 1 | READY | — |
| A3b-2 | 1 | READY | — |
| A3c-1, A3c-2 | 2 | READY | — (its memory gate is measured inside the stage) |
| A4a | 1 | READY | — |
| A4b-1, A4b-2 | 2 | READY | A0 cache-purge token |
| A5a | 1 | READY | — |
| A5b-1, A5b-2 | 2 | READY | — |
| A5c | 1 | READY | — |
| A6 | 1 | READY | — (OD13 confirmed); its `perf:http` gate is inside the stage |
| A7 | 2 | Blocked | Until the personal-data policy is published (OD15) and the owner confirms which private files will be stored |
| A8a-1, A8a-2, A8a-3 | 3 | Blocked | Until the personal-data policy is published (OD15); OD17, OD18, OD20 take their recommended values |
| A8b | 1 | Blocked | Until the data policy is published (through A8a) |
| A8c-1 | 1 | Blocked | Until the data policy is published; A0 OpenAI steps (runbook §9) |
| A8c-2 | 1 | Blocked | Until the data policy is published (through A8c-1) |
| A8d | 1 | Blocked | Until the data policy is published; production also needs the owner-approved AI-label wording (OD19) |
