Skip to content

Assets Use Cases ​

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

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

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


Contents ​

  1. Purpose and scope
  2. Ownership and boundaries
  3. Infrastructure that exists today
  4. Table traceability and schema gaps
  5. Domain model and state machines
  6. Design decisions
  7. Use cases
  8. Derived API
  9. Vertical variations
  10. Authorization and plan
  11. Security
  12. Performance and data design
  13. Infrastructure plan (AWS, Cloudflare, OpenAI, Railway)
  14. Delivery slices
  15. Owner decisions
  16. Risks and what the existing SQL gets wrong or leaves open
  17. Planned case matrix
  18. Sources
  19. Glossary

1. Purpose and scope ​

1.1 What Assets is for ​

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

BusinessWhat it needs on day oneWhat it does NOT need
Small restaurantA photo per dish and drink, shown on the till tile and on a digital menu later; uploaded from a phoneGalleries of many photos, documents, video
Retail shopA photo per product, sometimes per variant (the red shirt vs the blue shirt); bulk upload laterVideo, 3D, PDFs of spec sheets (later)
HotelPhotos of sellable extras (minibar, spa) as catalog items; room photos belong to Hospitality laterRoom-type galleries now

1.2 In scope for the first delivery ​

Decided:

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

1.3 Explicitly out of scope, with the reason ​

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

2. Ownership and boundaries ​

2.1 Facts Assets owns ​

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

2.2 Neighbours ​

NeighbourDirectionWhat crossesMechanism
IdentityAssets consumesPermission decisionPermissionCheckContract / OperationAuthorizationContract
PlatformAssets consumesCapability assets.media, limitsOperationAuthorizationContract; platform.effective_limit read in SQL inside the usage lock (as identity.assert_membership_seat_available does, 0040_identity.sql:2103-2129)
AuditAssets consumesTenant audit recordsTenantAuditContract
CatalogConsumes Assets"Are these asset ids mine, ready, public?" and "give me delivery for these ids"AssetUsageContract, AssetDeliveryContract (§6.6) inside the caller's tenant transaction (D18)
Media worker process (media-worker, OD14)Executes Assets jobs and sweepsClaim one job, record outcome, expire uploads, purgeSECURITY DEFINER functions granted to ninaku_worker only (D26, OB6)
Object storage (AWS S3 for uploads and masters, Cloudflare R2 for public renditions, OD1)Assets infrastructureSign POST policies (API, local computation), read/write objects (media worker)ObjectStoragePort in assets/application/ports, adapter in assets/infrastructure
Every other module with an FK to assets.assets (§2.4)Consume laterSame two contractsNo new mechanism

2.3 Module contracts (R1 to R9) ​

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

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

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

2.4 Schema boundary and existing cross-schema SQL ​

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

3. Infrastructure that exists today ​

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

4. Table traceability and schema gaps ​

4.1 Existing tables ​

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

4.2 Schema gaps (exact proposed changes) ​

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

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

4.3 Contradictions between the existing schema and this design ​

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

5. Domain model and state machines ​

5.1 Aggregates ​

AggregateRootInsideConsistency boundary
AssetAsset (assets.assets)Rendition (variants), StoredCopy (storage objects), Upload (1:1 while it lives)One asset changes in one transaction; the worker changes it only through its SECURITY DEFINER functions
Organization usageOrganizationUsage (assets.organization_usage)countersLocked FOR UPDATE first by every command that reserves or releases bytes (lock order §7)
Processing jobProcessingJoblease, attemptsClaimed with SKIP LOCKED; never locked by the API

5.2 Identifiers, codes and keys ​

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

5.3 Asset state machine (SG1) ​

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

 ready or failed ── purge begins, nothing references it (AS10) ──▶ purging ──▶ row deleted
 archived_at is orthogonal: archival (AS6) sets it on any status but purging;
 restoration clears it, never on a purging asset. purging is terminal (SG16).
Asset statusOriginal variantRendition variantsUpload stateJob
pendingpendingnone yetissuednone
processingpending → processingprocessing (inserted by the worker)receivedprocess_upload pending/leased
readyready (master, S3 m/)all of profile 1 readyverifiedcompleted
failedfailed (failure_code)failed or absentrejected or expiredcompleted or dead_lettered
purgingunchanged (ready or failed)unchangedunchangedpurge_asset pending/leased

5.4 Variant and storage object machines ​

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

5.5 Rendition profile 1 ​

Recommended (OD3):

CodeGeometryFormatPurposeApprox. size (assumption)
originalfit inside 4,096 × 4,096, never enlargedWebP q90 (alpha kept)Master for regeneration; S3 m/; never served publicly300 KB–1.5 MB
p1-thumbnail160 × 160 cover, attention cropWebP q80Back-office lists5–10 KB
p1-tile320 × 320 cover, attention cropWebP q80Till tile, offline cache15–30 KB
p1-cardfit inside 640 × 640WebP q80Item detail panel, storefront card40–80 KB
p1-detailfit inside 1,280 × 1,280WebP q82Zoom, storefront detail120–250 KB

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


6. Design decisions ​

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

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

OptionStorageDelivery pathOrigin authenticationExtra componentConsoles to run
AS3Media domain proxied by Cloudflare, origin S3Cloudflare's Cloud Connector routes to S3 but "only supports public buckets" (S23); a private bucket needs a Cloudflare Worker that signs SigV4 requests (aws4fetch, S42). An IP allow-list of Cloudflare ranges or an aws:Referer secret in the bucket policy are brittle or weakA Worker on every media request (Workers run before the cache and every request is billed, S24)AWS, Cloudflare
BS3AWS CloudFront with Origin Access Control; the media hostname's DNS record in Cloudflare is DNS-only (not proxied)OAC, nativeCloudFront distributionAWS, Cloudflare (DNS only)
CR2 only (previous version)R2 custom domain on CloudflareNativeNoneCloudflare, plus AWS for SES
DS3 for the system of record (upload inbox, masters, private renditions) and R2 only for public renditionsR2 custom domain on Cloudflare (S3 is never on the delivery path)S3 never public; R2 public bucket served nativelyNone (both are S3 APIs: one adapter, two configurations, 0070:302-308 already models several providers)AWS, Cloudflare

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

ProfileStoredServed/monthA: S3 + Cloudflare WorkerB: S3 + CloudFrontC: R2 onlyD: S3 + R2 public
Small restaurant0.3 GB5 GB, 0.2 M requests≈ $0 (Workers Free 100k/day, S24)≈ $0 (free tier)≈ $0≈ $0
Retail, 50,000 images50 GB100 GB, 4 M requests≈ $6 ($1.15 S3 + $5 Workers)≈ $1.15≈ $0.60≈ $0.90
Platform, 1,000 organizations2 TB5 TB, 200 M requests≈ $129 ($46 S3 + $13.5 egress on misses + $4 GETs + $62 Workers requests + ≈ $3 Workers CPU)≈ $916 pay-as-you-go ($46 storage + 4,096 GB × $0.110 = $451 data + $418 requests) or the $1,000 Premium flat plan (S25)≈ $30≈ $46 ($32 S3 + $9 R2 + ≈ $4.5 S3 egress for the worker reading uploads, assuming ≈ 150 GB/month of uploads after the 100 GB monthly free allowance)

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

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

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

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

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

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

Prefix (bucket ninaku-<env>-assets)HoldsWritersReadersLifecycle
in/Raw uploadsBrowser POST signed by the API principalmedia-workerExpire after 1 day; abort incomplete multipart after 1 day (S29)
m/Masters (m/<token>/original.webp) and AI provider originalsmedia-workermedia-workerNone
p/Private renditions (A7)media-workerAPI presign (A7)None

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

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

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

6.2 Upload flow (Decided except limits, OD5) ​

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Contracts (D18 modes):

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

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

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

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

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

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

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

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

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

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

Provider facts (fetched 2026-10-06).

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

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

Two intents.

IntentEndpointInput
Generate a photo for a product that has noneimages/generationsA prompt built by the server
Enhance a photo the user uploaded (clean background, better light)images/editsThe asset's master (read from S3 m/ by the worker) plus the server-built prompt; no mask in v1

Flow.

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

6.7.1 Schema gaps for AI (A8a) ​

#Change
SG19Function assets.create_generated_asset(job, lease_token, …) (SECURITY DEFINER, ninaku_worker only): creates the ai_generated asset, its original and provider-original variants and storage objects, links the request, and re-checks the asset quotas (OD20). Table assets.generation_requests: id, organization_id, asset_id (nullable until produced, FK tenant-safe), source_asset_id (nullable, enhance), requested_by_membership_id (FK (id, organization_id)), intent (generate, enhance), style_preset varchar(40), instruction varchar(200), prompt_text varchar(2000) (nullable after retention), prompt_sha256 text CHECK (~ '^[0-9a-f]{64}$'), model_code varchar(80), quality varchar(20), size varchar(20), provider_request_id varchar(120), state (queued, generating, review, approved, rejected, failed, refused), failure_code varchar(80), moderation_outcome varchar(40), cost_units integer, creation_idempotency_key uuid, creation_request_hash text (^[0-9a-f]{64}$), timestamps; unique (organization_id, creation_idempotency_key); RLS; state-shape check; runtime reads only, writes through definer functions
SG20assets.ai_generation_usage (organization_id, period_start date, reserved integer, consumed integer), primary key (organization_id, period_start), non-negative checks; reserve/consume/release in definer functions under the row lock
SG21SG5's source_kind values become ('upload','ai_generated'); job kind generate_image added to SG7
  • Archive is soft (archived_at); restore within 30 days.
  • Purge (AS10, slice A4b) of an asset archived more than 30 days, or failed more than 7 days, when nothing references it, in three steps:
    1. purge_asset_begin locks the asset FOR UPDATE and tests the reference with the DELETE itself: inside a PL/pgSQL block it deletes the asset's storage objects, variants, upload and asset row; a 23503 (some consumer's FK still points at it) is caught and answers referenced; success raises a private exception that rolls the block back and answers deletable. On deletable it sets status = 'purging' (SG16) in the same transaction. Storage rows are untouched. Because assertAttachable takes FOR SHARE on the same row and refuses purging, no reference can appear between the test and the status change, nor afterwards (the invariant of §2.4).
    2. The media worker deletes the objects from S3 and R2 (missing counts as done) and purges every public rendition URL from Cloudflare's cache (S22).
    3. purge_asset_finish deletes storage objects, variants, upload, jobs and the asset in one transaction, which satisfies the deferred triggers (0070:625-637, :937-949), then calls assets.reconcile_usage. A 23503 here can only come from a consumer that bypassed the contract: the function catches it, keeps the asset purging, records assets_asset_referenced_after_purge on the job and raises an alert metric; the objects are already gone, which is why the invariant is tested in every consumer slice.
  • Each referencing table that holds data needs an index leading with asset_id so the DELETE's FK check is an index probe (SG11 for item_assets; each future consumer adds its own).
  • Data-subject erasure (OD23). An erasure request under the personal-data policy maps to this existing path: archive the subject's assets, then the purge (AS10) deletes them from S3 and R2 and purges the CDN; the trigger path is defined when the policy exists. Backups and OpenAI's retention (abuse-monitoring logs, Zero Data Retention when granted, S35) are covered by the policy text, not by this purge.
  • Erasure on request (GDPR-style): the consumer detaches first, then the owner archives, then a staff-triggered purge skips the 30-day wait. The CDN copies are purged automatically in step 2; copies already in browsers or devices cannot be recalled.

7. Use cases ​

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

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

Shared rows (not repeated per case):

Shared pathAnswer
No session401 · identity.session_required
Organization not selectable403 · tenant.context_rejected
Permission missing403 · assets.action_forbidden (new, audited as denied, like catalog.action_forbidden)
Capability not in the plan (AS1, AS15 only; AS15 checks assets.ai_generation)403 · platform.capability_not_entitled
Malformed id or body400 · foundation.validation_failed
Unknown or foreign-tenant asset or upload404 · assets.not_found (new; RLS makes both identical)
If-Match stale412 · foundation.precondition_failed
Unmapped constraint422 · assets.asset_refused (new, generic backstop)

7.1 Uploads ​

AS1 — Request an upload ​

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

AS2 — Complete an upload ​

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

AS3 — Read one asset ​

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

AS4 — List the media library ​

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

AS5 — Rename an asset ​

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

AS6 — Archive and restore an asset ​

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

7.2 Worker cases ​

AS7 — Process an upload ​

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

AS8 — Retry, fail and dead-letter processing ​

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

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

AS9 — Expire abandoned uploads ​

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

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

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

AS11 — Regenerate renditions for a new profile ​

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

7.3 Contracts ​

AS12 — Describe delivery (AssetDeliveryContract.describe) ​

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

AS13 — Check attachability (AssetUsageContract.assertAttachable) ​

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

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

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

7.5 The Catalog side ​

AC1 — Set an item's images ​

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

AC2 — Set a variant's images ​

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

AC3 — Item read with images (C3 extension) ​

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

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

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

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

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

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

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

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


8. Derived API ​

GroupRoutesCases
UploadsPOST /assets/uploads, POST /assets/uploads/{uploadId}/completionAS1, AS2
LibraryGET /assets, GET /assets/{assetId}, PATCH /assets/{assetId}, POST /assets/{assetId}/{archival,restoration}AS3–AS6
LaterGET /assets/{assetId}/renditions/{rendition}/access, GET /assets/generations/{id}, POST /assets/generations/{id}/{approval,rejection}, POST /catalog/items/{itemId}/image-generationsAS14–AS16, AC6
CatalogPUT /catalog/items/{itemId}/images ({ images, variants? }: the item list and the lists of its variants), PUT /catalog/item-imagesAC1, AC2, AC5
InternalAssetUsageContract, AssetDeliveryContractAS12, AS13
Media workerjobs process_upload, purge_asset, render_profile; sweep expire_uploads (not a job)AS7–AS11

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


9. Vertical variations ​

ConceptRestaurantRetailHospitalityWhere
Item photo on the till tileYes (visual menu)Optional (search-and-scan tills often hide images)ExtrasAssets + Catalog, common
Variant photosRareCore (colour)—AC2, common
GalleryRareStorefront laterRoom galleries (Hospitality)Catalog gallery role, common
Brand logoReceipts, menuReceipts, labelsFoliosOrganization/commerce later, same contracts
Bulk upload—Later (import)—Its own design

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


10. Authorization and plan ​

10.1 Permission codes ​

CodeGrantsAnchorCapability gate
assets.media.manageAS1, AS2, AS5, AS6 (later AS16 and, with catalog.items.manage, AC6)Organization-wideassets.media for AS1; assets.ai_generation for AC6
assets.media.viewAS3, AS4Organization-widenone

10.2 Seeded role mapping (proposal, D30) ​

Roleassets.media.manageassets.media.view
organization_owner✓✓
manager✓ (effective only organization-wide)✓
cashier, waiter, kitchen, bar, sales_associate, stock_keeper

10.3 Capability and limits ​

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


11. Security ​

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

12. Performance and data design ​

12.1 Expected cardinalities (assumption) ​

ProfileItemsImagesPhotos per itemUploads per day (steady)
Small restaurant50–30050–300~1< 10
Restaurant group300–1,500300–2,0001–2< 30
Small retail500–5,000500–8,0001–2 (variant colours)10–100
Larger retail5,000–50,0005,000–75,0001–3100–1,000, bursts on import

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

12.2 Hot reads ​

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

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

ProfileImagesStoredStorage cost/monthOne-time Class A (≈7 writes per asset)
Small restaurant3000.3 GBinside free tier2,100 ops, free tier
Small retail8,0008 GBinside free tier per account, else $0.1256,000 ops ≈ $0.25
Larger retail75,00075 GB$1.13525,000 ops ≈ $2.36
1,000 tenants mixed (assumption: 2,000 images each)2 M2 TB≈ $30≈ $63 one-time

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

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

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

12.5 Statement budget ​

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

RouteStatements (target)
AS1 (first call)session (1) + membership (1) + BEGIN (1) + tenant context (1) + authorization (1) + reserve_upload (1: usage lock, limits, idempotency claim, inserts, reservation) + audit (1) + COMMIT (1) = 8
AS1 (replay)same, with the existing upload read (1) instead of the audit = 8
AS2session + membership + BEGIN + tenant context + permission + complete_upload (1) + asset read for the answer (1) + COMMIT = 8
AS3, AS4session + membership + BEGIN + tenant context + permission + read (1) + COMMIT = 7
AS510 when it writes (session, membership, BEGIN, tenant context, permission, read and lock for If-Match, update, audit, read the answer, COMMIT); 7 when unchanged
AS69 (session, membership, BEGIN, tenant context, permission, the definer function, audit, read the answer, COMMIT)
AC1session + membership + BEGIN + tenant context + authorization + version lock + item lock + assertAttachable + delete + upsert + audit + item representation read (C3's statements) + COMMIT ≈ 13, pinned by A5b
AC5as AC1 with set-based statements for all items, plus one audit statement per changed item (≤ 100): constant in N except the audit; A5c measures it
C17 2008 as built + describe = 9
C17 3047, unchanged (answers before any read)
C198 as built + describe = 9
AC6session + membership + BEGIN + tenant context + authorization + item read + request_generation + audit + COMMIT = 9
AS16session + membership + BEGIN + tenant context + permission + decide_generation (1) + audit + read the answer + COMMIT = 9

12.6 perf:http ​

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


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

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

13.1 Existing AWS usage and what we reuse ​

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

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

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

13.2 Accounts and ownership ​

AccountHoldsOwner and billingMFAAlerts
AWS (existing)SES (today), S3 buckets ninaku-staging-assets, ninaku-production-assets, IAM principalsThe company account owner (OD12)Root and every human userAWS Budgets 50/80/100%; CloudTrail management events
Cloudflare (existing)DNS zone(s), media custom domains, R2 public buckets ninaku-staging-media-public, ninaku-production-media-public, cache/transform/WAF rules, API tokensThe company account owner (OD12)Every memberBilling notification; R2 storage usage notification
OpenAI (new, A8)Organization (verified, S30), one project per environment, API keys, spend limitsThe company account owner (OD17)Every memberProject hard monthly limit and 50% alert (S37)
Railway (existing)api, worker, database-migrator, postgres, new media-workerExistingExistingExisting

13.3 Environment matrix ​

EnvironmentObject storageCDNOpenAIHow tests reach it
Unit and integration tests, CIIn-memory fake ObjectStoragePort; a fake image-model port—FakeEvery slice; CI needs no cloud credential
Local developmentA local S3-compatible server in a container for the manual browser flow, POST policies included (Decided: MinIO first, LocalStack as fallback; assumption: current image availability and POST-policy support are checked in A2a); public renditions written to a second local bucket and served by itNoneFake by default; a developer key only on requestnpm run scripts in A2a
Stagingninaku-staging-assets (S3) + ninaku-staging-media-public (R2)media-staging.<domain> (OD13)Staging project with a low hard limitContract test of the POST policy (A2a) and a manual end-to-end upload
Productionninaku-production-assets + ninaku-production-media-publicmedia.<domain>Production project, ZDR if eligible—

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

13.4 Per-service credentials and least-privilege policies ​

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

API principal policy (design text):

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

Media worker principal policy:

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

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

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

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

13.5 DNS and Cloudflare configuration ​

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

13.6 Railway variables (names only) ​

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

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

13.7 A0 owner checklist (in order) ​

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

13.8 Monthly cost estimate per profile ​

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

ProfileImages storedStorage + requests + transfer (option D)CDNAI generationsTotal
Small restaurant300 (0.3 GB)≈ $0.01$0 (Cloudflare, free egress from R2)20/month ≈ $0.68≈ $0.70
Restaurant chain (20 outlets, one catalog)2,000 (2 GB)≈ $0.05$050/month ≈ $1.70≈ $1.75
Retail, 50,000 images50 GB≈ $0.90 (+ ≈ $4.50 once for the worker reading 50,000 uploads)$0200/month ≈ $6.80≈ $7.70
Platform, 1,000 organizations2 TB≈ $46$020,000/month ≈ $680≈ $726

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

13.9 Third-party outage register ​

OutageEffectBehavior (Decided)
S3 unavailableNew uploads fail at the POST; processing jobs cannot read or writeThe client shows "try again"; an unused intent expires and its quota is released (AS9); jobs re-queue with capped backoff and no attempt cap, so assets wait in processing instead of failing; an alert fires after 1 hour; delivery is unaffected (public renditions are on R2)
R2 unavailablePublic images not served on cache misses; renditions cannot be writtenCached images keep serving; the till's offline cache keeps showing tiles; jobs re-queue without an attempt cap
Cloudflare unavailableMedia domain down (and any other proxied hostname)Tills show cached tiles; no fallback domain in v1 (accepted: a second CDN doubles configuration for a rare event)
OpenAI unavailable or rate-limitedGeneration requests waitJobs retry with Retry-After, then fail and release quota; the UI says "try later"; nothing else depends on OpenAI
SES unavailableUnchanged by this designUnchanged (security outbox, D26)
Railway media-worker downUploads stay processing; generations stay queuedNothing is lost (jobs in PostgreSQL); the api and the OTP worker are unaffected (OD14)

14. Delivery slices ​

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

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

#SliceCasesDepends onSizeEst. linesRiskSecurity-criticalMigrationMeasured
A0Infrastructure (owner-run, §13.7): AWS (budgets, root MFA, S3 bucket per environment with prefixes, lifecycle, CORS, TLS-only policy, IAM users per service), Cloudflare (R2 public bucket per environment, media custom domain, r2.dev off in production, cache and transform rules, R2 and cache-purge tokens, alerts), OpenAI later (A8); .railway/railway.ts declares the media-worker service and the §13.6 variables with preserve()—OD1, OD12–OD14S≈ 80 (railway.ts)MediumYes (credentials)Norailway config plan shows only the new service and variables
A1aStructure: SG1–SG5, SG9, SG12, SG16–SG18 on existing tables; SG6–SG8 tables, RLS, usage backfill; grants: runtime USAGE, SELECT on assets, asset_variants, uploads, organization_usage, storage_providers, column UPDATE (display_name) on assets.assets only (archive and restore go through definer functions that keep the usage counters), no INSERT; worker nothing yet. Also, in the same PR (OD7): a new decision D31 in DECISIONS.md (Spanish: "Las tareas pesadas de un módulo usan una cola propia del módulo con organization_id, con la forma de events.outbox_records; events.* queda para eventos de integración") and the amendment of AGENTS.md §5.4 naming the module-owned queue——M≈ 650 → split A1a-1 (columns, triggers, grants, D31, AGENTS; ≈ 350) and A1a-2 (three tables, RLS, backfill; ≈ 330)HighYesYes (0083, then the next free number)db:test contracts
A1bAdmission: reserve_upload and complete_upload (SECURITY DEFINER, the runtime's only write path for status and creation), seeds (two providers, s3-private and r2-public, permissions §10.1 and roles §10.2, capability assets.media on Pionero current maximum + 1, limit definitions per OD6, audit kind and actions), quota and idempotency contracts—A1aM≈ 500 → split seeds (≈ 200) from functions (≈ 300)HighYesYes (next free number)db:test
A1cProcessing contract: claim_processing_job (one job), record_processing_outcome, expire_uploads, reconcile_usage; worker grants (USAGE, EXECUTE on these only); lease contracts—A1aM≈ 400HighYesYes (next free number)db:test
A2aModule skeleton, ObjectStoragePort, S3 POST-policy signer and the S3-API object adapter for S3 and R2 (explicit credentials only), 11 storage variables in the environment schema, SEC-1 log redaction (signature, policy, x-amz-signature). Contract check (no longer a gate): against the local S3-compatible server and the staging bucket, a POST one byte over the declared size, with another content type or another key is refused by S3, and a HEAD on a missing key answers 404—A0 (staging), A1bL≈ 650 → split A2a-1 (skeleton, port, environment schema, redaction; ≈ 300) and A2a-2 (POST signer, S3/R2 adapter, credential-isolation spec, the local-server contract test; ≈ 350)HighYesNo—
A2bAS1 and AS3AS1, AS3A2aM≈ 400HighYesNo—
A2cAS2 and the upload e2e journey with a fake storage adapterAS2A2bS≈ 300MediumYesNo—
A3amedia-worker entrypoint and module, job loop (claim one, record), staging cleanup, AS9 sweepAS9 (sweep)A1c, A2cM≈ 380MediumYesNo—
A3bImage pipeline: sniff, decode once into the master, renditions from the master, sharp dependencyAS7A3aL≈ 550 → split A3b-1 (magic-byte sniffing, dimension and pixel checks, master decode; ≈ 300) and A3b-2 (renditions, outputs to S3 and R2, outcome recording; ≈ 250)HighYes (decoder)No—
A3cRetry classes (storage errors without attempt cap, processing errors 5 attempts), dead letter, assets.requeue_dead_letters runbook function (OD21), perf:assets, memory limit from measured peak RSS (gate for OD5)AS8A3bM≈ 400HighYesYes (next free number, the requeue function)throughput, peak RSS
A4aLibrary list, rename, archive, restoreAS4–AS6A2bM≈ 400MediumNoNo—
A4bPurge: purge_asset_begin/finish, deletes from S3 (m/, p/) and R2 (public renditions), automatic CDN purge by URL, offboarding enumeration designedAS10A3c, A5bM≈ 450 → split SQL (≈ 250) from worker (≈ 200)HighYes (erasure)Yes (next free number)—
A5aAssetUsageContract, AssetDeliveryContract, MODULE_CONTRACTS.md §15 rowsAS12, AS13A3bM≈ 350HighYesNo—
A5bCatalog: item_assets grants, bump triggers, index (SG11), audit actions; AC1–AC3AC1–AC3A5aL≈ 450 → split migration (≈ 150) from routes (≈ 300)High (cross-module locks)YesYes (next free number)—
A5cBulk image commandAC5A5bM≈ 300MediumNoNostatement count
A6Till read with imageRef and template, ETag fingerprint, statement-budget update (9/7/9), KNOWN_BREACHES re-baselineAC4A5bM≈ 250High (hot path)NoNoC17 p50–p99, 1 and 16 clients
A7Private and sensitive delivery (blocked by OD15)AS14A3c, OD15M≈ 400MediumYesMaybe—
A8aAI SQL: SG19–SG21 (with create_generated_asset), request_generation, consume/release functions, capability assets.ai_generation, limit assets.ai_generations (integer, monthly), audit actions—A1c, OD9, OD15, OD17, OD18, OD20M≈ 500 → split tables (≈ 250) from functions and seeds (≈ 250)HighYesYes (next free number)db:test
A8bAssetGenerationContract, prompt builder and presets, AC6 route, AS15 readAS15, AC6A8a, A5bM≈ 400HighYes (prompt input)No—
A8cMedia worker: generation claim loop (concurrency 2), generate_image job, OpenAI client (fixed endpoint, key from variable, timeout 180 s), re-queue on 429/5xx, moderation and refusal handling, quota consume/release, create_generated_asset and pipeline hand-offAS15wA8b, A3cL≈ 550 → split A8c-1 (OpenAI client port and adapter, error classification, redaction of prompts and bodies; ≈ 250) and A8c-2 (claim loop, job, asset creation, quota; ≈ 300)HighYes (secret, cost)Noprovider latency, cost per image
A8dReview: AS16 approval/rejection, not_approved in assertAttachable, aiGenerated in delivery and the till, disclosure label contract (OD19)AS16A8cM≈ 350MediumYesNo—

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

14.1 Stage specifications ​

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A5c as built (no migration) ​

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

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

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

Job deadline hardening as built (no migration) ​

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

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

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

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

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

14.2 Plan completeness checklist ​

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

15. Owner decisions ​

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

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

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

#FindingEvidenceRecommendation
R1Runtime and worker cannot reach assetsruntime_grants.sql:36, :177-181A1a–A1c grants, with negative contract tests
R2No asset-level status, so readiness needs a three-table join0070:84-133SG1–SG3
R3Header still names BullMQ0070:25X1: D26 wins; D31 records the module-owned queue
R4catalog.item_assets writes would not invalidate the till 3040075_catalog_foundations.sql:60-112SG11
R5item_assets FK checks tenant only, not readiness or access class0080:660-668assertAttachable under FOR SHARE (AS13) and the invariant of §2.4
R6Default access_class = 'private' vs public product images0070:91Explicit accessClass in AS1, public only until A7, immutable (SG17)
R7Upload size must be bounded at the storage layerR2 has no POST policy (S2); S3 has content-length-range (S11)Uploads go to S3 with a POST policy (§6.2); verified again in AS7; reserve quota; staging cleanup on every outcome; in/ lifecycle
R8Log redaction misses X-Amz-Signatureredaction.ts:27-33, sensitive-field-names.ts:1-14SEC-1 in A2a
R9Decoder vulnerabilities in libvips§11Keep sharp updated; isolated media-worker with least privilege
R10Media worker single replica; bulk imports take hours§12.4Scale replicas; SKIP LOCKED is safe
R11Purging storage objects of a ready variant breaks the deferred triggers0070:657-672, :883-949SG16: purging status, storage rows deleted with variants and asset in one transaction (§6.8)
R12A consumer that attaches without assertAttachable defeats erasure§2.4 invariantTest per consumer slice; purge_asset_finish detects it, keeps purging and alerts
R13audit.export_artifacts accepts only sensitive assets0380_audit.sql:2148-2152Consistent with A7; nothing to change
R14ASSETS_PUBLIC_BASE_URL change silently alters URLs§6.4Delivery fingerprint in ETags
R15Public objects are cached with immutable for a year§6.4Automatic CDN purge in AS10; device caches are an accepted residual
R16Every image write forces a full till re-read§6.6AC5 bulk command (OD16)
R17Keys carry no organization prefix§5.2Database-driven offboarding purge
R18An S3 client built without explicit credentials would use the SES keyAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY are the SES variables (environment-variables.schema.ts:213-216) and the SDK's default chain reads themExplicit credentials in every Assets client, asserted by a unit test (A2a); the SES principal has no S3 permission (A0)
R19AI generation cost grows with use§13.8Per-plan monthly limit (OD18), OpenAI project hard limit (S37), quota released on failures
R20AI food images may mislead customers§6.7Mandatory human approval and the disclosure label (OD19)
R21Prompt text is tenant data sent to a third party§6.7OD15 sign-off; ZDR if eligible (S35); prompt never logged; prompt text nulled after 90 days
R22Third-party outages§13.9Degradation per provider as registered in §13.9

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


17. Planned case matrix ​

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

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


18. Sources ​

Verified on 2026-10-06 unless marked.

#SourceUsed for
S1Cloudflare R2 pricing — https://developers.cloudflare.com/r2/pricing/Storage $0.015/GB-month, Class A $4.50/M, Class B $0.36/M, free egress, free tier
S2Cloudflare R2 presigned URLs — https://developers.cloudflare.com/r2/api/s3/presigned-urls/GET/HEAD/PUT/DELETE only, no POST; 1 s–7 days; Content-Type in the signature; not on custom domains
R2-PBCloudflare R2 public buckets — https://developers.cloudflare.com/r2/buckets/public-buckets/Custom domain with Cloudflare cache and WAF; r2.dev rate-limited, non-production
S4Cloudflare R2 object lifecycles — https://developers.cloudflare.com/r2/buckets/object-lifecycles/Delete by age/prefix, abort multipart, applied within ~24 h
S5Cloudflare R2 data location — https://developers.cloudflare.com/r2/reference/data-location/Location hints (enam, …), jurisdictions eu, fedramp, us; immutable after creation
S6sharp output options — https://sharp.pixelplumbing.com/api-outputMetadata removed by default, sRGB conversion, WebP q80 / AVIF q50 defaults
S7sharp constructor — https://sharp.pixelplumbing.com/api-constructorlimitInputPixels default 268,402,689, failOn default warning, autoOrient default false, animated, input formats
S8sharp installation — https://sharp.pixelplumbing.com/installPrebuilt platforms, Node ≥ 20.9, formats of prebuilt binaries (no HEIC)
S9OWASP File Upload Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.htmlAllow-lists, untrusted Content-Type, signatures, generated names, size limits, separate storage, scanning/CDR, SVG/XML, CSRF
S10Amazon S3 pricing — https://aws.amazon.com/s3/pricing/$0.023/GB-month, PUT $0.005/1k, GET $0.0004/1k, egress $0.09/GB after 100 GB
S11Amazon S3 POST policy — https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.htmlPOST policy expiration and conditions; content-length-range ("The minimum and maximum allowable size for the uploaded content"); exact and starts-with matching for key and Content-Type (fetched as https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-HTTPPOSTConstructPolicy.md)
S12Railway Storage Buckets — https://docs.railway.com/storage-buckets and https://docs.railway.com/storage-buckets/billingPrivate only, presigned URLs, no lifecycle/versioning/SSE, $0.015/GB-month, free operations and egress
S13Railway public networking limits — https://docs.railway.com/networking/public-networking/specs-and-limits15 min with data flowing, 5 min idle, uploads within 5 minutes, 32 KB headers
S14Cloudflare R2 S3 API compatibility — https://developers.cloudflare.com/r2/api/s3/api/PutObject supports Content-MD5 and conditional headers; x-amz-checksum-* not implemented; PostObject absent; lifecycle and CORS implemented
S15sharp utilities — https://sharp.pixelplumbing.com/api-utilityconcurrency default (1 on glibc without jemalloc), cache defaults 50 MB/20 files/100 items
S16Express body-parser — https://expressjs.com/en/resources/middleware/body-parser.htmlJSON/raw limit default 100kb
S17Cloudflare WAF token authentication — https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/HMAC tokens need Pro, Business or Enterprise
S18Cloudflare R2 limits — https://developers.cloudflare.com/r2/platform/limits/5 GiB single PUT, 1,024-byte keys, 1 write/s per key
S19RFC 8246 (HTTP Immutable Responses) — https://www.rfc-editor.org/rfc/rfc8246Cache-Control: immutable (not fetched in this task; standard reference)
S20RFC 9110 (HTTP Semantics, conditional requests §13) — https://www.rfc-editor.org/rfc/rfc9110Strong/weak ETag comparison already applied by conditional-request.ts (not fetched in this task; standard reference)
S21Cloudflare R2 API tokens — https://developers.cloudflare.com/r2/api/tokens/Object Read & Write and Object Read tokens can be scoped to a set of buckets, not to prefixes
S22Cloudflare purge cache — https://developers.cloudflare.com/cache/how-to/purge-cache/ (and the single-file page https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-single-file/)Purge by URL: Free plan 800 URLs/s and 100 URLs per call (Pro and Business 1,500/s); purge by tag, prefix and hostname exist on every plan (Free: 5 requests per minute)
S23Cloudflare Cloud Connector — https://developers.cloudflare.com/rules/cloud-connector/Routes traffic to AWS and others on all plans; "Your object storage bucket must be publicly accessible"; does not configure caching
S24Cloudflare Workers pricing — https://developers.cloudflare.com/workers/platform/pricing/Free 100,000 requests/day; Paid $5/month minimum, 10 M requests, $0.30/M after; Workers run before the cache and cached requests are billed
S25Amazon CloudFront pricing — https://aws.amazon.com/cloudfront/pricing/ and https://aws.amazon.com/cloudfront/pricing/pay-as-you-go/Free tier 1 TB and 10 M requests/month; US $0.085/GB, South America $0.110/GB; HTTPS $0.0100 (US) and $0.0220 (South America) per 10k; origin fetches from AWS origins free; flat plans Free $0, Pro $15, Business $200, Premium $1,000
S26Amazon S3, uploading objects with presigned URLs — https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.htmlA presigned upload is limited by the signer's permissions; same key replaces the object; SDK presigned URLs up to 7 days
S27Amazon S3 default encryption — https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-bucket-encryption.htmlSSE-S3 on every new object since 2023-01-05 at no cost; SSE-KMS subject to KMS charges and quotas; Bucket Keys
S28Amazon S3 Object Ownership — https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html"Bucket owner enforced" with ACLs disabled is the default for new buckets
S29Amazon S3 lifecycle configuration elements — https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.htmlFilters by key prefix, tags and size; Expiration; AbortIncompleteMultipartUpload; up to 1,000 rules per bucket
S30OpenAI image generation guide — https://developers.openai.com/api/docs/guides/image-generationModels, endpoints, sizes, quality, output formats, transparency, moderation auto/low, organization verification, "up to 2 minutes"
S31OpenAI pricing — https://developers.openai.com/api/docs/pricingGPT Image model list; token prices (gpt-image-2 image output $30/1M, gpt-image-1 $40/1M); calculator for per-image cost
S32OpenAI gpt-image-1-mini — https://developers.openai.com/api/docs/models/gpt-image-1-miniPer-image prices by quality and size; 20/150/250 images per minute by tier
S33OpenAI gpt-image-1.5 — https://developers.openai.com/api/docs/models/gpt-image-1.5Per-image prices ($0.034 medium 1024×1024); snapshot gpt-image-1.5-2025-12-16
S34OpenAI gpt-image-2 — https://developers.openai.com/api/docs/models/gpt-image-2Endpoints (generations, edits, batch), inpainting, snapshot gpt-image-2-2026-04-21, 20/150/250 IPM
S35OpenAI data controls — https://developers.openai.com/api/docs/guides/your-dataNo training by default; 30-day abuse-monitoring logs; ZDR eligibility of image endpoints for some models; CSAM exception
S36OpenAI C2PA and SynthID — https://help.openai.com/en/articles/8912793 (search snippet only), with secondary coverage (TNW; https://openai.com/index/advancing-content-provenance)API images carry C2PA metadata and a SynthID watermark: secondary sources, primary page not fetched
S37OpenAI spend limits — https://developers.openai.com/api/docs/guides/spend-limitsOrganization and project monthly limits, alerts or enforced hard limits returning 429
S38Expo ImagePicker — https://docs.expo.dev/versions/latest/sdk/imagepicker/preferredAssetRepresentationMode (automatic, compatible, current), quality, mimeType, fileSize, exif
S39Expo ImageManipulator — https://docs.expo.dev/versions/latest/sdk/imagemanipulator/Resize and save as JPEG, PNG or WebP with compression
S40sharp and HEIF — https://github.com/lovell/sharp/issues/3346 and https://github.com/strukturag/libheif/blob/master/README.mdPrebuilt binaries exclude patent-encumbered HEVC; HEIC needs a custom libvips with libheif/libde265 (search results of these pages; not fetched in full)
S41OpenAI usage policies — https://openai.com/policies/usage-policies/Not fetched (403); not quoted
S42Cloudflare Workers VPC, private S3 bucket example — https://developers.cloudflare.com/workers-vpc/examples/private-s3-bucket/The documented way for a Worker to reach a private S3-compatible origin (search result; not fetched in full)

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


19. Glossary ​

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

Application Foundation in progress. Tracked in issue #13.