Skip to content

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 (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.

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
  2. Stage A0 — Infrastructure (owner)
  3. Stage A1a-1 — Asset columns, triggers, first grants, D31
  4. Stage A1a-2 — Upload, job and usage tables
  5. Stage A1b — Admission functions and seeds
  6. Stage A1c — Processing functions
  7. Stage A2a-1 — Module skeleton and configuration
  8. Stage A2a-2 — Storage adapters and the storage contract
  9. Stage A2b — Request an upload and read an asset
  10. Stage A2c — Complete an upload
  11. Stage A3a — The media-worker process
  12. Stage A3b-1 — Verification and the master
  13. Stage A3b-2 — Renditions and the outcome
  14. Stage A3c — Retries, dead letters, memory gate
  15. Stage A4a — Library
  16. Stage A4b — Purge
  17. Stage A5a — Published contracts
  18. Stage A5b — Catalog image link
  19. Stage A5c — Bulk image command
  20. Stage A6 — Till read with images
  21. Stage A7 — Private and sensitive delivery
  22. Stage A8a — AI schema and functions
  23. Stage A8b — AI request from the catalog
  24. Stage A8c-1 — OpenAI client
  25. Stage A8c-2 — Generation job
  26. Stage A8d — Review, approval and disclosure
  27. Readiness table

1. Conventions shared by every stage ​

1.1 Migrations (every SQL stage) ​

RuleSource
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 REPLACEd); 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 migrationCATALOG_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.sqlAGENTS.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 blockdatabase/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_available0040_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 writtenUC §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 ​

KindLocation and runner
Unitsrc/**/<name>.spec.ts, npm test
Integration (PostgreSQL 18, serial)src/**/<name>.integration-spec.ts, npm run test:integration
End to endtest/e2e/<name>.e2e-spec.ts, npm run test:e2e
Database contractdatabase/tests/contracts/<name>.ts, npm run db:test
Database concurrencydatabase/tests/concurrency/<name>.ts, npm run db:test
Statement budgettest/e2e/statement-budget.e2e-spec.ts (one row per route)
Mutation targetsHand-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.

AreaDeliverable
RunbookExecuted in order: 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
EvidenceThe smoke-test transcript of runbook §10 pasted into the PR that closes A0, with every secret value removed

Spikes decided here (runbook §10).

ExperimentPassFail → fallback
R2 enabled on the Cloudflare account (OD22)The R2 bucket can be createdOwner 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 key404403: 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 bound400 (EntityTooLarge)Any 2xx: stop; the design's size bound is broken; escalate before A2b
SES after the key swapTest OTP and invitation deliveredRevert 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.

SeedRowsCanonical 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 NOTHING0070_assets.sql
identity.permissionsassets.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_permissionsorganization_owner and manager (system roles, organization_id IS NULL) get both; no other role (UC §10.2)0070_assets.sql
platform.capabilitiesassets.media ("Images and files")0060_platform.sql
platform.limit_definitionsassets.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 versionThe 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 returnsthe 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_kindsassets.asset (owner module assets, sensitivity internal, "A file of the tenant Organization: an image and its renditions")0380_audit.sql
audit.action_typesassets.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 23514assets_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_outcomeEffectReturns
readyArrays 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 completedcompleted
retry_storageattempt_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 cappending
retry_processingAs 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 -= 1pending or dead_lettered
failed_permanentAsset 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 completedcompleted

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 areaContent
src/modules/assets/assets.module.tsNest module registered in AppModule; imports DatabaseModule, the identity, platform, audit contract modules only (R1)
src/modules/assets/domain/delivery-token.tsgenerateDeliveryToken(): 16 bytes from node:crypto randomBytes, lowercase RFC 4648 base32 without padding, 26 characters; isDeliveryToken(value)
src/modules/assets/domain/media-type.tsACCEPTED_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.tsProfile 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.tsstagingKey(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.tsStatus 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.tsassetsStorageSettings() returning undefined when the group is absent (as emailChannelSettings)
src/foundation/logging/redaction/sensitive-field-names.tssignature 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 registryThe 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 refusalMutation 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.

FileContent
package.jsonDependencies @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.tssignUploadPolicy({ key, mediaType, maxBytes, expiresInSeconds }) → { url, fields, expiresAt }
src/modules/assets/application/ports/object-store.port.ts`head(provider, key) →
src/modules/assets/infrastructure/storage/s3-upload-policy-signer.tscreatePresignedPost 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.tsHeadObject, 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.tsBuilds 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 requiredMutation 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) ​

ItemSpecification
HeadersSession, X-Organization-Id, Idempotency-Key required (UUID v4/v7, IdempotencyKeyHeadersSchema)
AuthorizationOperationAuthorizationContract 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"
Domain refusals before SQLmedia type not accepted → 422 · assets.media_type_unsupported; byteSize > 10485760 → 422 · assets.file_too_large; accessClass not public → 422 · assets.access_class_unavailable
Main pathConfiguration 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)
ReplayOutcome 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 201Cache-Control: no-store; body below
Other statuses400 · 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
Statements8 (session, membership, BEGIN, tenant context, authorization, reserve_upload, audit, COMMIT); replay 8 (the upload read replaces the audit)
Auditassets.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) ​

ItemSpecification
AuthorizationPermissionCheckContract 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":
ETagStrong SHA-256 of the representation (content-entity-tag.ts); Cache-Control: private, no-cache; If-None-Match match → 304 empty
Statuses200, 304, 400, 401, 403 · assets.action_forbidden, 404 · assets.not_found
Statements7 (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.

ItemSpecification
RoutePOST /api/v1/assets/uploads/{uploadId}/completion, no body
AuthorizationPermissionCheckContract with assets.media.manage, organization-wide; not capability-gated
Main pathcomplete_upload(uploadId); outcome completed or already_received → read the asset → 202 with the AS3 body; no storage call
Statuses202; 404 · assets.not_found (not_found); 422 · assets.upload_expired; 422 · assets.upload_rejected
Statements8 (session, membership, BEGIN, tenant context, permission, complete_upload, asset read, COMMIT)
AuditNone (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.

FileContent
src/media-worker.tsLike 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.tsLike worker-config.module.ts (:14) with the media-worker process role
src/bootstrap/create-media-worker-application.tsAs 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.tsCalls 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.tsClaims 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.tssrc/media-worker.ts as an entry point, with the same rules the worker entry has (check-main-imports.ts:8-12)
Environment schemaASSETS_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).

ConditionClassfailure_code
HEAD missing (404, or 403 if A0 recorded it) before the upload's expiryretry storageassets.upload_missing
HEAD missing after the upload's expirypermanentassets.upload_missing
Stored size ≠ declared sizepermanentassets.size_mismatch
Magic bytes not one of the four, or ≠ declared type, or ≠ sharp's detected formatpermanentassets.content_mismatch
Pixels > 40,000,000 (JPEG, PNG, WebP) or > 12,000,000 (AVIF), or a side > 12,000permanentassets.image_too_large
A side < 64permanentassets.image_too_small
sharp decode error (truncated, corrupt, warning)permanentassets.image_corrupt
Storage 5xx, timeout, DNS, SlowDownretry storageassets.storage_unavailable
Any other thrown errorretry processingassets.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_letteredprocess_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.

RouteAuthorizationRequestResponseStatusesStatementsAudit
GET /api/v1/assets?status=&archived=&after=&before=&limit=assets.media.viewstatus 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 ETag200, 400 · collection.cursor_*, 4037none
PATCH /api/v1/assets/{assetId}assets.media.manage{ displayName }; optional If-Match (AS3 ETag)AS3 body and its ETag200, 400, 403, 404, 412 · foundation.precondition_failed10 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}/archivalassets.media.managenoneAS3 body200, 404, 422 · assets.transition_invalid9 (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}/restorationassets.media.managenoneAS3 body200, 404, 422 · assets.transition_invalid, assets.restoration_expired, assets.asset_limit_reached9 (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.

FileContent
src/modules/assets/contracts/asset-usage.contract.tsAssetUsageContract.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.tsAssetDeliveryContract.describe(ctx, { assetIds, renditions }) → { assets: Map<assetId, { status, imageRef, aiGenerated, urls }> }; tileUrlTemplate(); fingerprint() (first 8 hex of SHA-256 of ASSETS_PUBLIC_BASE_URL + `
src/modules/assets/contracts/assets-contracts.module.tsExports both, imported by consumers (the catalog contract module precedent, src/modules/catalog/contracts/catalog.module.ts)
src/modules/assets/infrastructure/usage/asset-usage.repository.tsOne 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.tsOne statement on assets.assets (PK lookups), ≤ 2,000 ids
docs/contracts/MODULE_CONTRACTS.md §15Two 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).

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).

RouteRequestResponseErrors
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 ETag400 · 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? })samesamesame, 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)RoleBehaviour
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)runtimeTenant 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 varcharworkerLease 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 varcharworkerBefore 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)runtimeTenant 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) ​

FunctionChangeHow
assets.claim_processing_jobReturns 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 REPLACEd (explicit exception, §1.1); the media worker is redeployed with the migration
assets.record_processing_outcomeAI 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 -= 3145728CREATE OR REPLACE (same signature)
assets.reconcile_usage_batchactive_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_beginThe rolled-back block also sets generation_requests.asset_id = NULL for the asset before deleting itCREATE OR REPLACE (same signature)
assets.purge_asset_finishSets generation_requests.asset_id = NULL before deleting the assetCREATE 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.

RouteSpecification
POST /api/v1/catalog/items/{itemId}/image-generationsIdempotency-Key required; body `{ "stylePreset": "studio_white"
GET /api/v1/assets/generations/{generationId}assets.media.view; `200 { "data": { "id", "state", "intent", "stylePreset", "assetId", "preview":

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).

RouteSpecification
POST /api/v1/assets/generations/{generationId}/approvalassets.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}/rejectionSame 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.

StagePRsStatusBlocked by
A01 (IaC) + owner runbookREADY— (OD1 and OD13 confirmed 2026-10-06; OpenAI steps §9 wait for the published data policy)
A1a-11READY—
A1a-21READY—
A1b-1, A1b-2, A1b-33READY—
A1c-1, A1c-22READY—
A2a-1a, A2a-1b2READY—
A2a-21READYA0 done in staging (the storage contract runs against it)
A2b1READY—
A2c1READY—
A3a1READYA0 (media-worker service)
A3b-11READY—
A3b-21READY—
A3c-1, A3c-22READY— (its memory gate is measured inside the stage)
A4a1READY—
A4b-1, A4b-22READYA0 cache-purge token
A5a1READY—
A5b-1, A5b-22READY—
A5c1READY—
A61READY— (OD13 confirmed); its perf:http gate is inside the stage
A72BlockedUntil the personal-data policy is published (OD15) and the owner confirms which private files will be stored
A8a-1, A8a-2, A8a-33BlockedUntil the personal-data policy is published (OD15); OD17, OD18, OD20 take their recommended values
A8b1BlockedUntil the data policy is published (through A8a)
A8c-11BlockedUntil the data policy is published; A0 OpenAI steps (runbook §9)
A8c-21BlockedUntil the data policy is published (through A8c-1)
A8d1BlockedUntil the data policy is published; production also needs the owner-approved AI-label wording (OD19)

Application Foundation in progress. Tracked in issue #13.