Skip to content
GenerateSpecs

Outlands SkinForge

15,862 lines · 159,130 words

CC BY 4.0 Open online
consumer-appPublic

Outlands SkinForge

A web tool to preview and share UO Outlands human skin, slot, and hue combinations before buying in-game.

15,862 lines · 159,130 words · 29 sections · Sep 3, 2026

Outlands SkinForge — Product Specification #

A web tool for designing, previewing and sharing UO Outlands character skins.

Outlands SkinForge lets a UO Outlands player pick a body, a skin hue, and a cosmetic item and hue for every paperdoll slot, see the result two ways — a live-composited paperdoll and per-slot static previews — and share the exact combination through a permanent link that carries its own social preview image. Behind the public site sits a staff-only pipeline that discovers and extracts art and hue data from the operator's own copy of the game client, an admin console for reviewing and publishing those assets, and server-side hosting of every rendered image. There are no user accounts, no adverts, and no reason for a player to point a game directory at anything.

This document is written to be executed cold by an AI coding agent or a development team. Every decision it needs has been made. Each concern has exactly one owning section; every other section refers to that section by number rather than restating it.

Table of Contents #

    1. Before You Start — Customization Decisions
    1. Project Overview & Vision
    1. Domain Primer — UO Outlands Paperdolls, Layers, Hues & Skins
    1. Technology Stack & Architecture
    1. Repository Layout, Conventions & Coding Standards
    1. Data Model & Database Schema
    1. Asset Discovery & Extraction Pipeline
    1. Rendering Engine — Hue Math, Compositing & Encoding
    1. Asset Ingestion, Versioning & Patch Re-Sync
    1. Public Web Application — Information Architecture & Routing
    1. Designer UI — Components, State & Interaction
    1. Preview Surfaces — Live Paperdoll & Per-Slot Static Previews
    1. Permalinks & Design Encoding
    1. Open Graph Image Generation
    1. Catalog Browse, Search & Filtering
    1. Public HTTP API
    1. Admin Console
    1. Design System, Visual Language & Accessibility
    1. Performance, Caching & CDN Strategy
    1. Security, Privacy & Legal Posture
    1. Observability — Logging, Metrics & Analytics
    1. Testing Strategy & Quality Gates
    1. Deployment, Infrastructure & Operations
    1. Configuration & Environment Variables
    1. Error Catalog & Edge Case Register
    1. Milestones & Execution Plan
    1. Executor Instructions
    1. Appendices

1. Before You Start — Customization Decisions #

1.1 Purpose #

This section lists every point in this specification where the executing agent may substitute its own value for a working default without breaking the rest of the document. Every other section is written as if the defaults below are already chosen. Nothing downstream is blocked on a decision: if a row is left untouched, the build proceeds exactly as specified elsewhere. If a row is changed, the "Blast radius" column names every section that must be re-read and, where applicable, every test suite in Section 22 that must be re-run before the build is considered done.

No decision in this document is deferred to "later," left as an open question, or handed back to a human for clarification. Every choice below already has a working default. Sections 2 through 28 are written against these defaults and never repeat the phrase "the customer should decide" — that phrasing does not appear anywhere in this specification outside this sentence.

1.2 Decision table #

ID Question Working default Used in Blast radius of changing it
D-01 Deployment target Single Linux VPS, Docker Compose (app + Postgres + optional MinIO + Caddy) 4.8, 23.1–23.4 Low if staying container-based; high if moving to a managed platform — re-derive 23.2 provisioning steps and 23.6 backup jobs; re-check 19.3 CDN origin config.
D-02 Object storage driver fs (local disk under the app container's volume) 4.7, 6, 23.3, 24 Switching to s3 requires setting the five SKINFORGE_S3_* variables in Section 24 and re-running the storage integration tests named in Section 22.4; no schema change, because asset_images.storage_key is driver-agnostic in Section 6.
D-03 Public domain name skinforge.example (placeholder, executor supplies real value) 10.1, 13, 14, 20, 24 Cosmetic only. Sets SKINFORGE_PUBLIC_BASE_URL (Section 24); affects OG absolute URLs (Section 14) and sitemap host (Section 10.9). No code change.
D-04 Donation link shown Enabled, pointing to SKINFORGE_DONATION_URL 2.6, 10.1, 24 If disabled (empty env var), the /support route (Section 10) renders without the donation button; no other page changes.
D-05 Hue-group curation policy for the skin group Heuristic keyword/range tagging at import (Section 7.7), with staff able to add or remove members afterward in the admin console (Section 17.8) 3.6, 6, 17.8 Switching to a fully staff-curated allowlist (no automatic tagging at all) removes the automatic pre-population in Section 7.7 and requires staff to build the group from empty on every new build; the hue_group_members.method column (Section 6) records which path — manual or heuristic — assigned each membership, so this is a config-level toggle, not a schema change.
D-06 Render scales offered to the public 1, 2, 3 (integer nearest-neighbour upscale) 6, 7.8, 8, 12, 16, 19 Removing scale 3 reduces render cache volume (Section 19.4), shrinks the @:scale enum validated in Section 8.8, and requires a new migration relaxing CHECK (scale IN (1,2,3)) on asset_images and design_renders (Sections 6.12, 6.19) plus updates to D6's scale loop (Section 7.8 step 3).
D-07 Default image format for previews webp, with png always available via the .png render URL suffix 8.7, 12, 14, 19 Changing the default to png increases bytes served (Section 19.1 budgets assume WebP); update the <source type="image/webp"> ordering in Section 19.6's <picture> markup. No server-side negotiation exists to change (Section 8.8).
D-08 Number of admin users at launch 2 (one primary, one backup), both with mandatory TOTP 17.2, 24 Adding more admins is a CLI operation (Section 17.2) with no spec impact; removing TOTP is not offered as an option — Section 20 treats it as a fixed control.
D-09 TOTP enforcement Mandatory for every admin account, no exceptions 17.2, 20.3 Not adjustable. Listed here only to document that it was considered and rejected as optional, per the security posture in Section 20.
D-10 CDN in front of the origin Cloudflare (or any CDN that honors Cache-Control and offers a purge API) in front of the reverse proxy, optional but recommended 19.3, 23.4, 24 Running without a CDN is fully supported: the origin's own Cache-Control headers (Section 8, 19) still cache correctly at the browser and at any transparent proxy; SKINFORGE_CDN_PURGE_URL stays empty and purge calls in Section 9.5 become no-ops.
D-11 Backup retention 14 daily + 8 weekly + 6 monthly Postgres backups, object storage mirrored to a second location on the same rotation (Section 23.6 owns the exact schedule) 23.6 Shortening retention lowers storage cost and recovery window; update the retention numbers in 23.6's backup table only.
D-12 Application log retention 30 days for structured request logs at info level; audit_log rows are retained indefinitely and never pruned (Section 6.33) 21.1, 20.7 Lowering request-log retention reduces disk usage; audit_log retention is not adjustable — Section 20.8's legal posture requires a permanent record.
D-13 Public API rate limits 120 requests/minute per IP for /api/v1/*, 60 renders/minute per IP for /render/*, 30 design creations/hour per IP (defaults; owned by Section 24.2) 16.7, 19, 24 Raising limits increases render CPU exposure (Section 19.5); lowering them risks false-positive throttling for shared-NAT users (e.g. mobile carriers, campus networks) — Section 25's SF-3002 errors already document the resulting user-facing message.
D-14 Brand name "Outlands SkinForge" throughout Renaming is a find-and-replace across Sections 2, 10, 14, 18; the SKINFORGE_ env var prefix (Section 24) is a code-level constant and does not need to match the display brand name.
D-15 Brand primary colour #3F5FD8 (indigo), on a dark slate background #14161C 18.2 Purely visual; changing it updates only the design tokens in Section 18.2 and any pre-rendered OG background art regenerated per Section 14.5.
D-16 Catalog indexability by search engines Indexable: /catalog/*, /hues/*, /about, /faq, /legal, /support, /changelog allowed in robots.txt; /d/* permalinks are indexable too (they are the shareable content); /admin/*, /api/*, and /design disallowed 10.6 Blocking /d/* from indexing (e.g. to avoid thin-content concerns) is a one-line robots.txt change in Section 10.6; no functional impact elsewhere.
D-17 Locale English (en) only 5.8, 10.2 Adding a locale means adding a core/i18n/<locale>.ts file (Section 5.8) and extending the language negotiation in the root middleware (Section 10.2); no schema change is required because all user-facing strings already route through core/i18n/en.ts.
D-18 Image download permission Public downloads allowed for the composite preview and OG image at all offered scales, unauthenticated, no watermark 12.7, 8.8 Restricting downloads (e.g. requiring a Referer check) is a single guard in the render route handler (Section 8.10) with no schema or data-model change.
D-19 Third-party analytics Disabled entirely; only first-party server-side counters (Section 21) 2.6, 21, 24 Turning on a third-party analytics script would contradict the no-cookies-for-visitors posture in Section 20.7's privacy posture; not offered as a toggle. Listed to record that it was considered and rejected.
D-20 Maintenance-mode behaviour When SKINFORGE_MAINTENANCE_MODE=true, all public routes return a static 503 page with Retry-After: 300; /admin/* stays reachable for staff 23.9, 24, 25 Changing the retry hint only affects the header value; changing which routes stay reachable during maintenance changes the middleware allowlist described in 23.9.
D-21 Seed data size for local development 2 bodies × 6 sample assets per slot × 12 sample hues, enough to render a full composite without running the real extraction pipeline 6.31, 22.10 Increasing seed volume only changes the row counts inserted by deno task db:fixtures; it never changes the seed script's shape.
D-22 Unofficial fan-project disclaimer placement Persistent footer strip on every public page plus a dedicated /legal page 2.8, 10.4, 18.11, 20.8 Moving it to a dismissible banner is not permitted — Section 10.4 requires it to be persistent and non-dismissible; this row exists only to record that constraint's rationale.
D-23 Session cookie name and TTL for admin Cookie sf_admin, 30-minute idle timeout plus a 12-hour absolute timeout from created_at (SKINFORGE_ADMIN_SESSION_TTL_HOURS=12, Section 17.1 owns the exact mechanism) 17.1, 24 Shortening the TTL only changes the env var default; the cookie name is referenced by exact string in Section 20.4's CSRF notes and should stay in sync if renamed.
D-24 Minimum supported viewport 360px width (small mobile) up to unbounded desktop, single responsive layout, no separate mobile app 2.5, 18 Not adjustable without contradicting the "responsive website only" boundary from Section 2.5; listed for completeness.
D-25 Job runner concurrency In-process job runner claims up to 4 concurrent jobs (SKINFORGE_RENDER_MAX_CONCURRENCY default 4) 4.7, 19.8, 24 Raising concurrency trades CPU contention on the single VPS (Section 4.8) for faster import/render throughput; lowering it protects public-request latency during a large import run.

1.3 Statement of completeness #

Every default in Section 1.2 is already applied throughout Sections 2–28. The executing agent may build the entire product by implementing this document literally, changing nothing. No decision in this table blocks any other section: every working default is self-sufficient, every env var referenced has a default value in Section 24, and every schema referenced in the "Used in" column already supports both the default and the commonly anticipated alternative (for example, D-02's storage driver and D-05's curation method).

1.4 How to change a default safely #

Follow this procedure for any row in Section 1.2 the executor wants to change:

  1. Locate the owning sections. Read every section number listed in that row's "Used in" column, in order, in full.
  2. Change the value at its single source of truth. Most defaults resolve to exactly one of: an environment variable default in Section 24, a constant in core/config.ts (Section 5.1), or a row in a lookup table seeded by a migration (Section 6). Change it in that one place. Do not duplicate the value elsewhere — every other section that mentions the concept references the owning section instead of restating the value, so there is nothing else to edit.
  3. Re-run the affected automated checks. Cross-reference the section numbers touched against Section 22's test map:
    • Changing D-01, D-02, D-10, or D-11 (infrastructure/storage) → re-run the storage and deployment integration suite (Section 22.4) and the backup/restore drill (Section 23.6).
    • Changing D-06 or D-07 (render scales/formats) → re-run the golden-image tests (Section 22.3) at every scale/format combination now offered.
    • Changing D-13 (rate limits) → re-run the rate-limit contract tests (Section 22.4) and confirm the SF-3002 error responses in Section 25 still match the new thresholds in any updated fixtures.
    • Changing D-05 or D-21 (curation/seed data) → re-run deno task db:fixtures and the catalog integration tests (Section 22.4).
    • Changing D-14, D-15, D-16, D-17 (brand/visual/locale) → re-run the accessibility checks (Section 22.6) and confirm core/i18n/en.ts still resolves every key used by the changed components.
  4. Update the changelog. Record the change in the developer's own CHANGELOG.md per the Conventional Commits convention in Section 5.3; this document itself is not versioned per-decision.
  5. Do not introduce a second default. If a decision needs a per-environment override (for example, a different rate limit in staging versus production), express it as a different value for the same environment variable in each environment's .env file (Section 24), never as new code branching on environment name inside core/.

1.5 Decisions explicitly not offered #

The following are constraints from the hard scope boundaries and are not customization points. They are listed here once so the executor does not mistake their absence from Section 1.2 for an oversight: elf or gargoyle bodies, animated or multi-directional previews, user accounts or login, a full equipment/armor catalog, in-game account integration, native mobile apps, advertising, and paid tiers. Each is covered as an explicit non-goal in Section 2.5 and, where relevant, as a boundary check in Section 25's edge-case register.

1.6 How decisions interact #

Most rows in Section 1.2 are independent: changing one has no effect on any other row's default. A small number interact, and the executor should read the related row before changing either one:

  • D-01 (deployment target) and D-02 (storage driver). The fs storage driver assumes the application container and its storage volume are co-located, which is only guaranteed under the single-VPS default in D-01. Moving to a multi-host deployment without also switching D-02 to s3 will leave later web process replicas unable to see renders written by earlier ones; Section 4.8 states this dependency as part of the horizontal-scaling path.
  • D-06 (render scales) and D-13 (rate limits). Offering more render scales increases the CPU cost of a cache-miss burst (Section 19.5); if D-06 is widened beyond 1, 2, 3, re-check the render rate limit in D-13 against the new worst-case cost per unique design rather than assuming the existing number still protects the single-VPS CPU budget in Section 4.10.
  • D-07 (default format) and D-19 (analytics). These are independent in mechanism but both feed the same performance metric in Section 2.6 (time to first preview); changing D-07 to png as the default changes the baseline that metric is measured against, so the target number in Section 2.6 should be re-validated, not just the code path.
  • D-10 (CDN) and D-11 (backup retention). A CDN in front of the origin reduces load on the object storage backing D-02, which in turn changes how urgently a storage restore (Section 23.6) needs to complete during an incident, because cached copies at the CDN edge continue serving reads while the origin recovers. This does not change the backup retention numbers themselves, only the operational urgency of a restore, which is documented in Section 23.6's runbook rather than in this table.
  • D-08 (admin user count) and D-09 (TOTP enforcement). These are listed as separate rows because one is adjustable and one is not, but they are provisioned together: every admin user created via the CLI (Section 17.2), regardless of how many there are per D-08, is created with mandatory TOTP per D-09 — there is no code path that creates an admin account without it.
  • D-21 (seed data size) and D-05 (curation policy). Local development seed data always uses the manual curation method for its seeded skin hue group members (Section 6), regardless of which method D-05 designates for production, so that deno task db:seed never depends on the heuristic classifier being present or tuned.

No other rows in Section 1.2 have a dependency relationship; every remaining row may be changed in isolation following the procedure in Section 1.4.

1.7 Quick reference by concern #

For an executor who wants to find every customization point touching one area of the system without re-reading the full table in Section 1.2, the rows group as follows:

Concern Rows
Infrastructure and deployment D-01, D-02, D-10, D-11, D-25
Domain and branding D-03, D-14, D-15, D-22
Catalog and content policy D-05, D-16, D-21
Rendering and media D-06, D-07, D-18
Security and access D-08, D-09, D-23
Operations D-12, D-20
Product policy D-04, D-13, D-17, D-19, D-24

This grouping is a navigation aid only; the authoritative default, usage, and blast-radius for every row remains the table in Section 1.2.

1.8 What "no decision blocks the build" means in practice #

An executor reading this specification top to bottom, applying every default in Section 1.2 without changing a single value, produces a complete, deployable, correctly functioning product. This is verified structurally: every default resolves to a concrete value already written into its owning section (an env var default in Section 24, a schema default in Section 6, a constant in Section 5.1), never to a placeholder, a range without a chosen point, or a cross-reference back to this section asking the executor to pick. Section 1 is read exactly once, at the start of the build, specifically so the executor can decide up front whether any default needs to change before writing the first line of code — not because any later section is incomplete without that decision.

2. Project Overview & Vision #

2.1 The problem, in the players' words #

UO Outlands sells cosmetic customizations — skin hues, hair and beard styles, tattoos, backpack dyes, and clothing dyes — through in-game currency (doubloons) and cosmetic vendors (the Wig Stand and related NPCs). A player deciding whether to spend that currency faces three concrete, recurring frustrations:

  • Buying blind. The in-game purchase flow shows a swatch or a name, not the player's own character wearing the result. A player cannot see "my human female in hue 1402 skin with hair style X in hue 1102" before spending currency that, once spent, is not refunded.
  • No way to compare combinations. A player who owns three hair styles and is considering a fourth cannot see all four side by side on their own body shape without changing their live character repeatedly and taking screenshots by hand, then flipping between image files.
  • No way to share a look. When a player finds a combination they like, or wants a second opinion from a guildmate before spending doubloons, the only sharing mechanism is a screenshot posted to a chat channel or forum — lossy, hard to reproduce exactly, and impossible for the recipient to open and remix into their own variant.

Quantified in gameplay terms: a full cosmetic wardrobe covers 18 choosable slots (Section 3.3) each with potentially dozens of asset choices and thousands of possible hues (Section 3.6). The combinatorial space a player might want to preview before buying is in the hundreds of thousands of distinct looks. No in-game UI attempts to browse that space; the game's own interface is built for selecting one item at a time from a vendor list, not for comparison shopping.

2.2 What SkinForge is #

Outlands SkinForge is a free, public, no-login web tool that lets a UO Outlands player assemble a character's cosmetic appearance — body, skin hue, and up to 18 choosable hueable slots — see the result as both a live composited paperdoll and individual per-slot preview images, and generate a permanent, shareable link that reproduces that exact combination for anyone who opens it, complete with a social preview image for chat and forum links.

Capabilities:

  • A designer interface for choosing a body (male/female), a skin hue, and an asset plus hue for each of the 18 choosable cosmetic slots (Section 11).
  • A live client-side composited paperdoll preview that updates instantly as choices change (Section 12).
  • Static per-slot preview images showing each chosen asset in isolation (Section 12).
  • A permanent, immutable link per exact combination, with no account required to create or open one (Section 13).
  • An automatically generated Open Graph preview image for every permalink, so links posted in Discord or forums show the character, not a blank card (Section 14).
  • A browsable catalog of every available asset and hue, searchable and filterable, for players who want to explore before designing (Section 15).
  • A staff-only pipeline that discovers, extracts, and classifies art and hue data from the operator's own local copy of the game client files (Section 7), and an admin console to review, approve, and publish that data, including re-import after game patches (Sections 9, 17).
  • A single optional donation link; no ads, no paid tiers, no purchases of any kind inside the tool itself (Section 2.6, Section 1 D-04).

2.3 Personas #

Persona Goals Context Device Job they hire the tool for
The shopper Decide whether a specific cosmetic is worth the doubloons before buying it in-game Mid-session or between sessions, often reacting to a vendor listing they just saw Mostly desktop while the game client is open in another window; sometimes phone while away from keyboard "Show me this hair style on my exact body and skin hue so I know if I'll like it before I spend currency."
The theorycrafter Explore the full combinatorial space of looks, compare many options side by side, plan a full outfit Dedicated browsing session, not concurrent with playing Desktop, multiple browser tabs "Let me try dozens of combinations quickly and keep the ones I like."
The community sharer Show off a look, get opinions, start a trend Posting to Discord, guild forums, or social media Phone or desktop, whichever they were already using "Give me one link that shows exactly what I built, with a nice preview image, that anyone can open."
The staff curator (admin) Keep the catalog accurate and complete after every game content patch, with minimal manual effort and no risk of publishing broken art Working directly against their own local Outlands client install, on a schedule tied to patch releases Desktop, admin console plus command-line tooling "Point the tool at my updated client files, review what changed, and publish it without breaking any existing shared link."

2.4 Primary user journeys #

Journey A — Design a look from scratch.

  1. Visitor opens / (Section 10). No login prompt, no interstitial.
  2. Visitor picks body (male/female) and a skin hue from a swatch picker (Section 11.6).
  3. Visitor opens a slot (e.g. Hair), browses available assets for that slot and gender, picks one, then picks a hue for it (Section 11.6). The live paperdoll preview updates immediately (Section 12.2), composited entirely in the browser.
  4. Visitor repeats step 3 for any of the other 17 choosable slots, in any order, any number of times.
  5. Visitor clicks Share. The current combination is canonicalized, hashed, and persisted server-side (Section 13); the browser navigates to the resulting permalink URL, /d/:code.
  6. Visitor copies the URL or uses a native share sheet; the page itself already carries Open Graph tags pointing at the generated preview image (Section 14).

Journey B — Open a shared permalink and remix it.

  1. Recipient clicks a /d/:code link shared in chat. The link unfurls with a preview image and title before it is even opened, because the OG image and tags are pre-generated (Section 14).
  2. The page server-renders the full paperdoll composite immediately, so the look is visible even before any client-side JavaScript loads (Section 8.10, Section 10.3).
  3. Recipient clicks "Open in Designer." The permalink's design JSON (Section 13) hydrates the designer island's state; the recipient is now editing a copy, never the original — the original permalink is immutable (Section 13.7).
  4. Recipient changes one or more slots and shares a new permalink of their own.

Journey C — Browse the catalog, then jump into the designer.

  1. Visitor opens /catalog (Section 15) and filters by slot, then by gender, then by hue group.
  2. Visitor finds an asset they like, opens its detail view showing it on both bodies across a spread of hues.
  3. Visitor clicks "Try this in the designer," which opens the designer with that slot pre-filled and every other slot at its default empty state.
  4. Visitor continues as in Journey A from step 3.

Journey D — Staff import after a game patch.

  1. Outlands ships a client patch. Staff curator runs the discovery stage of the CLI (Section 7, Stage D1) against their local, updated client directory.
  2. The CLI reports newly discovered or changed source files and stages an import run (Section 9.1).
  3. Staff curator reviews extraction candidates in the admin console (Section 17.6): new assets, changed hues, anything flagged needs_operator_input (Section 7's degrade-gracefully rule).
  4. Staff curator approves the candidates that are correct, corrects classification for anything ambiguous, and publishes the new build (Section 9.5).
  5. Every existing permalink continues to render exactly as before, because designs pin a build_id (Section 13.7) and retired assets stay renderable (Section 6's soft-delete rule); only new designs or edits can pick up the newly published assets.

2.5 Goals and explicit non-goals #

Goals:

  • Let any UO Outlands player preview any combination of the two bodies, one skin hue, and the 18 choosable cosmetic slots, each with its own hue, before spending in-game currency.
  • Produce a permanent, exact, remixable record of any combination, shareable as a single URL.
  • Make that URL look good when unfurled in chat apps and forums, with zero manual effort from the player.
  • Give staff a repeatable, low-effort process for keeping the catalog in sync with the live game client after patches, without ever breaking an already-shared link.
  • Stay free, ad-free, and account-free indefinitely, funded only by an optional donation link.

Non-goals (explicit, matching Section 1.5's decisions-not-offered list exactly):

  • Elf or gargoyle bodies. UO Outlands has no elf or gargoyle player bodies; this is not a limitation of SkinForge, it is a fact about the game.
  • Animated previews or multiple facing directions. The paperdoll is a single static pose; SkinForge ships exactly that one view, at multiple render scales (Section 8.6), never an animation.
  • User accounts, login, or saved galleries. Every visitor is anonymous; the only persistence mechanism is the permalink itself.
  • A full clothing, armor, or equipment catalog. SkinForge models exactly the layers needed to render the visible cosmetic look (Section 3.3, Section 3.5); it is not an equipment or stat planner.
  • In-game integration, purchasing, or applying a skin to a real account. SkinForge never connects to the game client, the game server, or any Outlands account system.
  • Native mobile apps. The product is a single responsive website (Section 1 D-24).
  • Advertising or paid subscription tiers. The only monetization surface is the optional donation link (Section 2.6 constraint, Section 1 D-04).

2.6 Success metrics #

All metrics are computed from first-party server-side data only — no third-party analytics, per the no-cookies-for-visitors, no-PII architecture decision (Section 1 D-19) and the observability model in Section 21.

Metric Target How measured
Designs created per week 500+ within three months of launch Count of INSERT rows into designs per ISO week, from stat_counters (Section 6, Section 21.2).
Permalink open rate At least 2 opens per design created, averaged over a rolling 30 days page_view_daily rows for /d/:code routes divided by designs created in the same window (Section 21.6).
Share-image fetch rate At least 1 OG image fetch per 3 designs created Count of og_images cache-hit + cache-miss requests logged by the render access log (Section 14.5, Section 21.2).
Catalog coverage 95%+ of in-game cosmetic-eligible assets represented within 14 days of a patch that adds them assets.status = 'published' count divided by extraction_candidates count for the relevant game_build_id (Section 9.2).
Time to first preview Under 1.5 seconds from first paint to a visible composited paperdoll, at the 75th percentile, on a mid-tier mobile device Synthetic measurement in CI against the Section 19.1 reference device profile, recorded per release; no client-side beacon is shipped, consistent with D-19's no-third-party-script posture.
Staff time per patch re-import Under 30 minutes of staff review time for a typical minor patch (under 50 new assets) Self-reported duration field staff enters when closing an import run in the admin console (Section 17.5), stored on import_runs.

2.7 Constraints and assumptions #

  • The operator has, or will obtain, a local copy of the UO Outlands client files. SkinForge never distributes, downloads, or bundles any Outlands game asset itself; it only reads from a local directory the operator supplies to the CLI, and only server-side, only for staff.
  • The exact on-disk layout, file formats, and compression schemes used by that client install are unknown at specification time. This document does not assume a specific MUL/UOP/other layout. Section 7 defines a staged discovery capability — inventory, identify, probe, extract, classify, normalize — specifically because the layout must be discovered against the real files, not assumed from a stale reference.
  • UO Outlands may change its client format, add new hue ranges, or restructure asset packaging in a future patch. The pipeline's staged design (Section 7) and the immutable-build model (Section 9, Section 13.7) are built so a format change degrades to a review queue, never a crash and never a silent data loss.
  • The tool has no relationship with, and is not endorsed or sponsored by, UO Outlands, Broadsword Online Games, or Electronic Arts. It exists as a fan-made companion tool. See Section 2.8.
  • Traffic is expected to be modest and bursty (spikes around patch announcements and popular shared links), well within the single-VPS scaling posture described in Section 4.8.

2.8 Unofficial fan-project posture #

Outlands SkinForge is an unofficial, fan-made tool built by and for the UO Outlands player community. Section 10.4 owns the exact disclaimer text rendered on every public page: "Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts." It reads only from art and data files the operator already possesses through their own legitimate client installation, hosts only derived preview imagery necessary to serve its stated purpose, and makes no claim of ownership over any underlying game asset. This posture is stated persistently on every public page (Section 1 D-22) and detailed in full, including takedown procedure, in Section 20.8.

2.9 Glossary #

Every UO-specific and SkinForge-specific term used in this document — gump, layer, tiledata, MUL, IDX, UOP, hue, partial hue, paperdoll, doll art, ClassicUO, doubloon, wig stand, and others — is defined once, precisely, in Section 3.9, with a consolidated glossary appendix in Section 28.

2.10 Risks and mitigations #

Risk Impact if unmitigated Mitigation Owning section
The operator's client files use an on-disk layout not anticipated by this specification's assumptions Import pipeline fails to extract any usable art, blocking catalog population Discovery is staged and format-agnostic: unknown containers are fingerprinted and queued for staff review rather than assumed away, and the pipeline never crashes on an unrecognized format Section 7
A game patch changes hue ranges or asset ids between imports Newly published designs render incorrectly, or existing permalinks appear to change Designs pin an immutable build_id; retired assets stay renderable forever; new builds are staff-reviewed before publish Section 9, Section 13.7
A shared permalink stops rendering because an asset was removed from the catalog Broken links reduce the share-image fetch metric (Section 2.6) and damage trust in the permalink model Soft-delete only: catalog rows never hard-delete, they gain a retired_at timestamp and remain renderable indefinitely Section 6, Section 9.7
A single-VPS deployment experiences a traffic spike around a popular shared link or a patch announcement Degraded latency or downtime during the moment the product is most visible Immutable, aggressively cached renders mean steady-state traffic after first view is served without compositing work; rate limits bound abusive traffic Section 8.9, Section 19, Section 1 D-13
A rights holder requests removal of specific derived imagery Legal exposure if no process exists to respond A documented takedown path that can retire specific assets or designs without taking the whole product offline Section 20.8
Staff curator time per patch grows unbounded as the catalog grows The success metric in Section 2.6 (staff time per re-import) is missed, and the catalog falls behind the live game Diffing between builds surfaces only what changed, not the full catalog, for every review pass Section 9.2

The product's overall risk posture favors mechanisms that degrade gracefully — a review queue instead of a crash, a retired asset instead of a broken link, a cached render instead of a compute spike — over mechanisms that require perfect upfront knowledge of the game client's internals, because that knowledge is explicitly unavailable at specification time (Section 2.7).

2.11 Why a dedicated tool, not an existing alternative #

Players currently approximate SkinForge's job with tools and habits that each fall short in a way that motivates building a dedicated product rather than recommending a workaround:

Existing approach Why it falls short
Changing the look on a live character in-game, then screenshotting it Costs real doubloons before the player can confirm they like the result; only shows one combination at a time; produces a lossy screenshot, not a reproducible, remixable record.
Asking in a Discord or forum channel and hoping someone has the item to compare Depends on another player happening to own the exact item and being willing to log in and screenshot it; no systematic coverage of the catalog.
Community-maintained spreadsheets or image galleries of individual cosmetics Shows each item in isolation, never composited with a player's own body, skin hue, and other worn items; quickly goes stale as new cosmetics are added and is not interactive.
General-purpose UO art viewers built for other UO shards or the base game Not aware of UO Outlands' own custom art and hue additions (Section 3.7); require the visitor to already have and configure a local client install, which the constraints in Section 2.7 explicitly rule out for SkinForge's own end users.

SkinForge's contribution is specifically the combination of: zero setup for the visitor, full compositing across every hueable slot at once, and a permanent shareable record — none of the existing approaches provide all three together.

2.12 North star statement #

If SkinForge succeeds, a UO Outlands player never spends doubloons on a cosmetic without first seeing, on their own body shape, exactly what it will look like combined with everything else they already wear — and every combination worth remembering has a link that still works, unchanged, years later.

3. Domain Primer — UO Outlands Paperdolls, Layers, Hues & Skins #

This section teaches an executor with no prior Ultima Online knowledge everything needed to build SkinForge correctly. Read it before Sections 6 through 14; those sections assume this vocabulary and these rules without re-explaining them.

3.1 What a paperdoll is #

In Ultima Online (and every client built on its art conventions, including the ClassicUO-based Outlands client), a character's on-screen inventory and appearance summary is called the paperdoll. It is a flat, front-facing, static 2D illustration of the character standing in a single neutral pose, built by stacking pre-drawn sprite images — one per equipped or worn item — on top of a base body sprite, in a fixed drawing order. There is no 3D model, no skeleton, no animation frames involved in a paperdoll: it is layered 2D art, the same technique used by paper doll toys, hence the name.

The paperdoll art area is 260 pixels wide by 330 pixels tall (Section 8.1 fixes this as the composite canvas). Every sprite involved in a paperdoll — the body, and every worn item — is pre-drawn at a size and offset that positions it correctly inside that fixed canvas. SkinForge does not scale, rotate, or reposition sprites relative to each other; it reproduces exactly what the in-game paperdoll shows, at integer pixel scales (Section 8.6).

Because the entire visual is 2D sprite compositing, SkinForge's rendering problem reduces to: pick the right sprite for each occupied layer, recolor it if a hue applies, and stack the results in the correct order. There is no lighting, no perspective, and no 3D geometry anywhere in the render path (Section 8 owns the full algorithm).

3.2 Bodies #

UO Outlands has exactly two player character bodies available for cosmetic design purposes:

Body Body code Base gump Notes
Human male m 0x000C (12)
Human female f 0x000D (13)

These are the only two bodies SkinForge supports. UO as a franchise has other playable races in some rule sets (elf, gargoyle) but UO Outlands' player character bodies for the purposes this tool covers are human only, male and female — this is a fact about the game, not a limitation SkinForge imposes (Section 2.5 states this as a non-goal boundary, not an omission).

The base gump number (0x000C / 0x000D) is the identifier the original client art format uses for the nude/base body sprite that every other layer draws on top of. SkinForge's own code never needs to special-case these numbers outside the asset extraction pipeline (Section 7), where they are the seed values used to locate body art during classification (Stage D5).

Gender-specific art variants. Some cosmetic assets have separate artwork for male and female bodies (because the sprite must fit differently over each body shape); others share one sprite drawn to fit both. The asset_variants table (Section 6) records, per asset, which bodies it has art for. Rule when an asset has no variant for the visitor's chosen gender: that asset is not offered in the picker for that slot when the visitor's body is set to the gender lacking a variant (Section 11.6 filters the asset list by body before rendering it). If a permalink was created for one body and the design is later inspected in the designer with the other body selected, a slot whose chosen asset has no variant for the new body is retained but flagged incompatible: it contributes no layer to the composite while incompatible, and switching back to the original body restores it automatically, because nothing was cleared. This is the rule for gender/body mismatches; Section 11.13 owns the exact recovery behavior and warning copy, and Section 13 owns dropping a still-incompatible entry from the canonical document at Share time.

3.3 The canonical slot registry #

SkinForge composites exactly 19 layers per character: one required body layer and 18 choosable cosmetic slots the visitor can leave empty or fill, backpack included. backpack is always visible in the rendered output, but it is a choosable slot exactly like any other — the visitor may pick its asset and hue — it simply falls back to a default asset when the design carries no entry for it, rather than disappearing the way any other empty slot does. The table below is the canonical registry — every other section that needs slot order, display names, or gender rules references this table by section number rather than repeating it.

z slot_key Display name uo_layer Hueable Gender notes
10 body Body yes (skin hue) Required, always present. The base nude sprite for the chosen body code, recolored with the skin hue.
20 tattoo_body Body Tattoo — (Outlands cosmetic) yes Both genders. An Outlands-specific cosmetic layer drawn directly on the body, beneath clothing.
30 footwear Footwear 3 yes Both. Boots, shoes, sandals.
40 legs_inner Pants 4 yes Both. Base legwear, drawn under any skirt/kilt layer.
50 torso_inner Shirt 5 yes Both. Base torso covering, drawn under any chest piece.
60 torso_middle Chest Piece 17 yes Both. Tunics, vests, breastplates when worn as a cosmetic layer rather than a stat item.
70 arms Arms 19 yes Both. Sleeves and arm coverings, drawn over the shirt.
80 gloves Gloves 7 yes Both.
90 waist Belt / Sash 12 yes Both.
100 legs_outer Skirt / Kilt 23 yes Both. Drawn over pants.
110 torso_outer Robe / Outer Torso 22 yes Both. The outermost torso garment; drawn over the chest piece and arms.
120 neck Neck 10 yes Both. Collars, scarves, gorgets worn as cosmetics.
130 hair Hair 11 yes Both, but the two genders draw from different style sets, because hairstyle art is drawn to fit each body's head shape and silhouette.
140 facial_hair Beard 16 yes Male only. Shown disabled with an explanatory tooltip when body is f (Section 11.5), never removed from the rail.
150 face Face Art 15 yes Both. Face paint, warpaint, and similar tight-fit facial cosmetics distinct from a full body tattoo.
160 earrings Earrings 18 yes Both.
170 head Hat / Helm 6 yes Both. Cosmetic headwear; drawn last among the "worn item" layers so it sits over hair.
180 cloak Cloak 20 yes Both.
190 backpack Backpack 21 yes Both. Always visible on every rendered character, because the in-game paperdoll always shows a backpack — but it is a choosable slot like any other: the visitor may pick its asset and hue. When the design carries no entry for it, it renders with the default asset backpack.default at hue 0 (unhued).

Layers 24 and 13 are the classic client's alternate ids for the same visual slot as legs_inner and torso_inner respectively; SkinForge records only the primary id shown above, which is the one classification (Section 7.6) matches on.

That is 19 rows total: one required body slot (row z=10) and 18 choosable cosmetic slots — the 17 rows a visitor may leave empty, plus the always-visible backpack slot (row z=190), whose asset and hue are visitor-changeable exactly like any other choosable slot. A choosable slot left empty contributes nothing to the composite — it is simply skipped during compositing (Section 8.4 owns the composite order), not rendered as a transparent placeholder. backpack is the one choosable slot that never actually renders empty, because it falls back to the default asset backpack.default at hue 0 when the design carries no entry for it.

3.4 Composite order and why z-order matters #

The z column in Section 3.3 is the literal paint order: the renderer draws slot body first, then tattoo_body, and so on up through backpack last, alpha-compositing each non-empty layer's sprite on top of everything drawn so far (Section 8.4 owns the exact pixel algorithm). Getting this order wrong produces a character that looks structurally wrong even though every individual sprite is correct — for example, if torso_outer (a robe) were drawn before torso_middle (a chest piece), the chest piece would incorrectly appear to float on top of the robe instead of being covered by it.

Worked example — five slots stacking, human female, reading bottom to top:

  1. body (z=10): the base female nude sprite, recolored to skin hue 1002.
  2. legs_inner (z=40): plain trousers, hue 0 (as-drawn).
  3. torso_inner (z=50): a linen shirt, hue 1302.
  4. torso_outer (z=110): a hooded robe, hue 1802, drawn over the shirt — the shirt's sleeves remain visible below the robe's shorter sleeve art because the sprites are drawn to the correct relative proportions by the original artists; SkinForge does not need any collision logic, only correct order.
  5. hair (z=130): a long braid style, hue 1102, drawn last in this example — visible because no head slot is occupied to cover it.

If a head slot were added at z=170, it would draw after hair and would visually cover the top of the braid where a hat naturally would, without SkinForge writing any per-slot occlusion rule; the z-order table alone produces correct results because the original sprites were drawn with this convention in mind.

3.5 Excluded layers #

SkinForge intentionally does not model the following classic UO layers: one-handed weapon (layer 1), two-handed weapon (layer 2), ring (layer 8), bracelet (layer 14), talisman (layer 9), and it does not model any stat-bearing armor or weapon catalog beyond the cosmetic slots in Section 3.3.

Rationale. SkinForge is a skin designer, not an equipment or combat-loadout planner. Weapons and jewelry in UO Outlands are primarily functional items chosen for combat statistics, not cosmetic identity, and including them would expand scope into balance-sensitive game data (damage, resists, enchantments) that is out of date the moment it is captured and is irrelevant to the "what does my character look like" question SkinForge answers. Excluding them keeps the asset pipeline (Section 7) focused on purely cosmetic art and keeps the catalog (Section 15) free of stat comparisons that would require constant balance-patch maintenance.

3.6 Hues #

A hue in UO is a recoloring instruction applied to a grayscale-shaded sprite at render time, rather than a separate full-color image per color variant. This is why one piece of art (say, "long braid hair") can be offered in hundreds of colors without hundreds of separate sprite files: the artist draws the sprite once, using shades of gray to represent light and shadow, and the hue system remaps those gray shades to a chosen color ramp at draw time.

The hues.mul structure. The classic hue data file is organized as 375 blocks of 8 hues each (3,000 hues total, indices 1 through 3000; index 0 is reserved, see below). Each of the 8 hues within a block contains:

  • A 32-entry color table: 32 packed 16-bit colors forming a ramp from dark to light, used to remap a sprite's gray shades to this hue's color ramp.
  • A start/end byte pair: metadata the original client used for certain rendering effects (partial application boundaries); SkinForge's importer records these values for provenance but the render algorithm in Section 8.3 uses only the 32-entry table.
  • A 20-byte name field: a fixed-width ASCII name for the hue, used for display in the catalog (Section 15) and admin console (Section 17).

Do not confuse a hue block — the 8-hue grouping in the file layout described above, a fact about how hues.mul is laid out on disk — with a hue group, the curated, staff-maintained grouping of hues used for browsing and filtering (hue_groups, Section 6.14). The two are unrelated concepts that happen to share a word.

ARGB1555 packing. Each of the 32 colors in a hue's table is a 16-bit value in ARGB1555 format: 1 bit alpha, 5 bits red, 5 bits green, 5 bits blue. The exact bit layout, most significant bit first, is:

bit:   15  14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
field:  A   R  R  R  R  R  G  G  G  G  G  B  B  B  B  B

Unpacking a 16-bit ARGB1555 value into 8-bit RGB channels, and the transparency convention that applies when a packed value is exactly 0x0000, are specified once, with a single reference implementation, in Section 8.3.1 — this section introduces the bit layout conceptually but does not restate the unpacking function.

Hue index 0 means "unhued." A hue value of 0 is not a lookup into the table at all; it is the sentinel meaning "draw the sprite's own original colors, unmodified." Every choosable slot in Section 3.3 accepts hue 0 as a valid choice with this meaning, and the design JSON (Section 13.1) encodes it the same way. The renderer's exact handling of hue 0 is owned by Section 8.3.3.

Full hue vs. partial hue. UO's hue system has two application modes:

  • Full hue remaps every non-transparent pixel of the sprite: each pixel is looked up in the 32-entry table by table index, and that entry's color replaces the pixel entirely, regardless of the pixel's original color.
  • Partial hue applies the same table-lookup process but only to pixels that are already gray/neutral in the source art (used for effects like "just recolor the metal parts of an item, leave the leather parts alone" in the original game). SkinForge's asset importer records, per sprite, whether a partial-hue flag was present in the source data (Section 7's provenance record); this specification does not require SkinForge to expose "partial vs full" as a visitor-facing choice — it is a property of the asset, decided by the original artist, not by the visitor.

How a pixel's table index is derived — the exact bit arithmetic, the grey test that gates partial hue, and the reference implementation — is owned entirely by Section 8.3; this section supplies only the conceptual model above, so a reader who has not yet reached Section 8 understands what a hue is before Section 8 defines exactly how one is applied to a pixel.

3.7 UO Outlands specifics #

UO Outlands runs a client based on ClassicUO, an open-source, modernized UO client, but ships its own art assets and its own custom hue additions layered on top of (or alongside) the base UO hue space described in Section 3.6. SkinForge must treat everything Outlands-specific as data the pipeline discovers, never as a hard-coded assumption, because the exact custom hue index ranges, the exact set of Outlands-added cosmetic slots such as tattoo_body, and the exact asset ids are not known at specification time (Section 2.7 states this constraint explicitly; Section 7 is the staged discovery capability that resolves it against the real files at import time).

What is known and modeled as fact, because it comes from the game's documented cosmetic economy rather than from undiscovered binary layouts:

  • Outlands sells skin hues as a cosmetic customization (through the Wig Stand and related cosmetic vendors, paid for with doubloons, the game's cosmetic currency). SkinForge models this as the skin hue group (Section 3.6, Section 6), a curated subset of the hue space tagged during import per the curation policy in Section 1 D-05.
  • Outlands sells hair and beard styles as cosmetics through the same vendors; the game does not publish a fixed enumerated list of style ids anywhere outside the client art itself. SkinForge stores every hair or beard style as a plain catalog asset (Section 6) with a slot_key of hair or facial_hair respectively, discovered and classified by the pipeline (Section 7) exactly like any other cosmetic asset — no different in structure or handling from a footwear or cloak asset.
  • Tattoos and backpack dyes are sold the same way, cosmetically, mapping to the tattoo_body and backpack slots respectively (Section 3.3).
  • The wardrobe and in-game paperdoll preview UI that inspired this tool already show gender-restricted items — for example, beard styles are only ever shown for male characters. This is the precedent SkinForge follows exactly for the facial_hair slot's male-only rule (Section 3.3, Section 11.5).

SkinForge models these facts about the cosmetic economy in its taxonomy and admin workflows; it does not model or replicate Outlands' purchasing flow, doubloon balances, or vendor inventory — that would be in-game integration, an explicit non-goal (Section 2.5).

3.8 What "a skin" means in SkinForge terms #

Within this specification, "a skin" (the noun, matching the product name) means exactly this tuple: one body code (m or f), one skin hue applied to the body slot, and a set of zero-or-more entries for the 18 choosable cosmetic slots (Section 3.3, backpack included), each entry naming one asset and one hue. This tuple, canonicalized and hashed, is the entire content of a permalink. The exact JSON shape, canonicalization rule, and hashing algorithm that turn this concept into a storable, shareable, immutable record are owned by Section 13; this section defines only the concept, Section 13 defines its encoding.

3.9 Terminology table #

Term Definition
Gump In classic UO art terminology, a 2D image resource identified by a numeric id, used for paperdoll art, UI art, and item icons. SkinForge uses "gump id" only during asset extraction (Section 7) to identify source sprites; it is never exposed in public-facing URLs or JSON.
Layer A numbered equipment/body slot in the classic UO client's paperdoll compositing system (e.g. layer 3 = footwear). SkinForge's uo_layer column (Section 3.3) records this for provenance and classification; SkinForge's own slot_key is the identifier used everywhere else in this specification.
Tiledata A UO client data file describing properties of every static and mobile art tile, including flags SkinForge's classifier uses to help determine what a piece of art represents (Section 7, Stage D5).
MUL A classic UO file format: a flat binary blob of concatenated records, almost always paired with an IDX file that supplies offsets and lengths into the MUL.
IDX The index file paired with a MUL file: a sequence of fixed 12-byte entries (lookup offset, length, extra data) that locate each record inside the corresponding MUL file.
UOP MythicPackage, a container format used by newer UO-family clients in place of loose MUL/IDX pairs, identified by the magic bytes MYP\0, containing versioned blocks and a chain of entries addressed by a 64-bit hash of their internal path rather than a simple index.
Hue A recoloring instruction — a table of 32 colors plus metadata — applied to a grayscale-shaded sprite at render time. Full definition in Section 3.6.
Partial hue A hue application mode that recolors only the neutral/gray pixels of a sprite, leaving already-colored pixels untouched. Full definition in Section 3.6.
Paperdoll The flat, static, front-facing 2D compositing of a character's body and worn items into one illustration. Full definition in Section 3.1.
Doll art Colloquial term for the specific sprite variant of an item drawn to be shown on the paperdoll, as distinct from the item's in-world or inventory-icon art. SkinForge's asset pipeline extracts doll art specifically (Section 7, Stage D4).
ClassicUO An open-source, modernized Ultima Online client that UO Outlands' own client is built on. SkinForge never runs or embeds ClassicUO; it only reads static art/data files that happen to be organized the way a ClassicUO-family client expects.
Razor A popular third-party UO client assistant/automation tool. Mentioned only for completeness; SkinForge has no interaction with Razor.
Doubloon UO Outlands' cosmetic currency, used to purchase skin hues, hair/beard styles, tattoos, and similar cosmetics from in-game vendors. SkinForge never handles doubloons or any currency; it only models the resulting cosmetic catalog.
Wig Stand An in-game NPC/fixture in UO Outlands where players spend doubloons to change hair, beard, and skin-hue cosmetics. Mentioned as the real-world source of the skin and hair hue/asset catalog SkinForge models.

3.10 Worked example: unpacking and applying a hue end to end #

This subsection intentionally carries no worked example of its own. The hue algorithm — table-index derivation, unpacking, and pixel application — is specified exactly once, in Section 8.3, and its one worked example lives in Section 8.3.6, with additional worked examples in Appendix B (Section 28.2); publishing a second, independently-computed set of numbers here would risk the two drifting apart, so this section does not duplicate them. Read Section 8.3 for the full algorithm and its worked arithmetic; this section's role is limited to the conceptual introduction to hues in Section 3.6.

3.11 Gender and slot interaction edge cases #

Beyond the base rule in Section 3.2 (an asset with no variant for the visitor's chosen body is not offered, and is retained-but-flagged if the body changes underneath an existing choice), three further cases are decided here so the executor never has to invent behavior at build time:

  • Switching body with facial_hair occupied. Because facial_hair (Section 3.3) is male-only, switching the body from m to f leaves the slot's stored selection untouched but incompatible — it contributes no layer to the composite while body is f, and Section 11.13 shows the same warning-badge treatment as any other gender-incompatible slot, even though the underlying reason (a slot restriction, not a missing art variant) is different from the general case in Section 3.2. Switching back to m restores it automatically, because nothing was cleared.
  • An asset published for only one gender is later given an art variant for the other gender in a subsequent build. The existing asset_variants row set (Section 6) simply gains a new row for the newly available body; no retroactive change is made to any design that was created before the new variant existed, because a design's rendering is pinned to its build_id (Section 13.7) and its original choice (or lack of one, if the slot was previously incompatible) is preserved exactly as made.
  • A slot with a gender-neutral display name but gendered art (e.g. hair). The slot itself is offered identically for both genders (Section 3.3 marks hair as "both, different style sets"); the asset picker (Section 11.6) filters the list of selectable assets to only those with a variant for the current body, so a visitor never sees a hairstyle they cannot actually select — this differs from facial_hair, where the entire slot control is shown disabled with an explanatory tooltip (Section 11.5) rather than filtering a list, because facial_hair is not offered for female bodies at all, not merely absent for the visitor's current asset choice.

3.12 Why hue math and slot rules are decided here, not left to the pipeline #

An executor might expect the discovery pipeline (Section 7) to determine rules like "beard is male only" or "hue table has 32 entries" from the source data at import time, since the pipeline is explicitly designed to discover unknown facts (Section 2.7). These particular facts are different in kind from what the pipeline discovers: they are stable properties of the UO art format and of UO Outlands' own game design that do not vary between client builds or patches, unlike asset ids, hue index ranges, or on-disk file layout, which do vary and are exactly what Section 7's staged discovery resolves. Hard-coding the stable facts in this section, while leaving the genuinely variable facts to discovery, keeps the pipeline focused on what actually changes patch to patch.

3.13 Domain facts quick-reference table #

The following table consolidates the fixed numeric facts introduced throughout this section, for an executor who needs to check a constant while implementing Sections 6 through 14 without re-reading the full prose.

Fact Value Defined in
Composite canvas size 260 × 330 px 3.1
Number of player bodies modeled 2 (human_male, human_female) 3.2
Body identifiers m, f (display names "Male", "Female") 3.2
Base gump ids 0x000C (male), 0x000D (female) 3.2
Total composited layers 19 (1 required body + 18 choosable, backpack included) 3.3
Excluded classic UO layers 1, 2, 8, 9, 14 3.5
Hue blocks in hues.mul 375 3.6
Hues per block 8 3.6
Total addressable base hue indices 3,000 (1–3000) 3.6
Hue color table entries per hue 32 3.6
Hue name field width 20 bytes, ASCII 3.6
Color packing format ARGB1555 (1/5/5/5 bits) 3.6
Unhued sentinel value Hue index 0 3.6
Hue application modes Full, partial 3.6
IDX entry size 12 bytes (lookup, length, extra) 3.9
UOP magic bytes MYP\0 3.9

3.14 Summary of what this section hands to later sections #

Section 3 is a reference, not an implementation. The tables and rules above are consumed directly by: the slot registry and z-order by Section 6 (schema) and Section 8 (render algorithm); the hue structure and bit-packing math by Section 8.3; the body/gender rules by Section 11.6 (asset picker filtering) and Section 13 (design encoding); the excluded-layer rationale by Section 15 (catalog scope) and Section 28 (appendix reference tables); and the terminology table by every other section that uses a UO-specific term, none of which re-define it.

4. Technology Stack & Architecture #

4.1 Stack table #

Section 4 is the ONLY place this specification states dependency versions. Every other section refers to "the version in Section 4" rather than repeating a number.

Dependency Version line Role Why chosen
Deno 2.x Runtime for both the web process and the staff CLI Native TypeScript execution with no separate build/transpile step for server code, a built-in permissions model well suited to a tool that reads arbitrary operator-supplied files during import, a single toolchain (deno fmt, deno lint, deno test, deno task) replacing a typical Node project's five separate tools.
Fresh 2.x (jsr:@fresh/core) Web framework: routing, server rendering, islands Islands architecture ships zero JavaScript for pages that need none (catalog browsing, static permalink viewing) and only the interactive designer component's JS to the browser; server-side rendering by default keeps permalinks crawlable and fast without a client-side router.
Preact 10.x UI component runtime, used by Fresh Small runtime footprint keeps the designer island's JS bundle light, directly compatible with Fresh's islands model.
Preact Signals @preact/signals 2.x Client and server state primitives inside islands Fine-grained reactivity without a virtual-DOM diff on every keystroke; well suited to a designer UI with many independent per-slot state pieces (Section 11.2).
Tailwind CSS 4.x, via Fresh's official Tailwind plugin Styling Utility-first styling keeps the design tokens (Section 18.2) centralized in one config and avoids hand-maintaining a separate CSS file per component.
Deno standard library jsr:@std/* 1.x HTTP helpers, assertions, path utilities, etc. Audited, dependency-free, versioned in lockstep with Deno itself.
PostgreSQL 18 System of record for all metadata Mature transactional guarantees, native FOR UPDATE SKIP LOCKED for the job queue (Section 4.7), strong JSON column support for the design/canonical-JSON fields (Section 6, Section 13), no separate database technology needed for search (Section 15 uses Postgres full-text search).
npm:postgres 3.x (postgres.js) Postgres driver Fast, promise-native, prepared-statement support, works cleanly under Deno's npm compatibility layer without an ORM's code-generation step.
Zod 4.x Runtime schema validation One schema module per domain object (Section 5.4), shared verbatim between server route handlers and client-side islands, single source of truth for validation rules referenced throughout Sections 11, 13, 16.
@jsquash/png 3.x PNG encode/decode (WASM) Pure WASM codec with no native binary dependency, works identically in the Deno server process and (via the same package) in the browser for client-side compositing parity (Section 8.12, Section 22.3).
@jsquash/webp 1.x WebP encode/decode (WASM) Same rationale as the PNG codec; WebP is the default served format (Section 1 D-07) for its smaller byte size at equivalent visual quality on pixel art with transparency.
satori 0.33.x SVG layout engine for Open Graph images Lets Section 14's OG image layout be expressed as JSX/Preact-like markup with CSS-like styling rather than manual canvas drawing calls, while staying pure-JS/WASM.
@resvg/resvg-wasm 2.x SVG-to-PNG rasterization (WASM) Pairs with satori's SVG output to produce the final OG PNG, again with no native binary dependency.
Playwright 1.62.x End-to-end browser testing Cross-browser automation for the designer flow and no-JS progressive-enhancement checks (Section 22.5).
jsr:@std/ulid 1.x ULID generation Sortable, URL-safe, collision-resistant surrogate ids for every table's primary key (Section 6.2).
lucide-preact 0.x Icon set The single icon library used by every icon-only control in the design system (Section 18.6), packaged as Preact components so tree-shaking removes unused icons.
@fresh/plugin-tailwind 0.x (jsr) Tailwind 4 build integration The official Fresh 2 plugin that wires Tailwind's build step into fresh.config.ts (Section 5.1); this is the "Fresh's official Tailwind plugin" named in the Tailwind row above.

Put this paragraph, verbatim, wherever this specification is read as the authoritative statement on dependency freshness:

These version lines are a known-good floor, not a lockfile. At build time, install the current stable release of each dependency (deno add jsr:@fresh/core, npm install <pkg>@latest, or your ecosystem's equivalent), confirm the major line still matches, and let the lockfile record the exact resolved versions.

4.2 Why Deno + Fresh here #

Four properties of this specific product favor Deno + Fresh over alternative stacks:

  1. One language, one runtime, two processes. The public web app and the staff CLI (Section 4.3) share the entire core/ domain library (hue math, compositing, format parsers, schema). A single TypeScript codebase running on a single runtime means the compositing algorithm used for live client-side preview, server-side permalink rendering, and staff-side extraction validation is provably the same code, not three reimplementations that can drift.
  2. Islands avoid a heavyweight client bundle for pages that do not need one. Most of SkinForge's traffic is people opening a permalink (Journey B, Section 2.4) or browsing the catalog (Journey C). Those pages are almost entirely static content once rendered; Fresh ships them with no JavaScript beyond what a specific interactive widget (e.g. a hue swatch hover) needs, as an isolated island. Only /, which hosts the full designer, loads the larger designer island bundle; /design is not a separate page — it permanently redirects to / (Section 10.5).
  3. Server-side rendering by default keeps permalinks crawlable and fast. Chat-app and forum link unfurlers (Journey B, step 1) and search engine crawlers never execute JavaScript reliably; Fresh renders the full page, including the composited paperdoll's <img> tag pointing at a server-rendered PNG/WebP (Section 8.10), on the server for every request, so the content is present in the initial HTML response.
  4. A single language for CLI and web removes a translation layer. The staff import pipeline (Section 7) is not a separate service in a separate language calling into the web app's API; it is a Deno CLI in the same repository, importing the same core/ modules the web app imports, so a change to the hue math (Section 8.3) or the compositing algorithm (Section 8.4) cannot silently go out of sync between "how the CLI validates an extracted asset" and "how the web app renders it."

4.3 Process topology #

Three processes, one database, one storage target, in front of a reverse proxy and (optionally) a CDN:

                              ┌─────────────────────┐
                    (optional)│         CDN          │  (Section 1 D-10, Section 19.3)
                              └──────────┬───────────┘
                                         │
                              ┌──────────▼───────────┐
                              │   Caddy (reverse      │  TLS termination, HTTP/2,
                              │   proxy, TLS)         │  routes to the web process
                              └──────────┬───────────┘
                                         │
                    ┌────────────────────▼────────────────────┐
                    │         Deno process: web app             │
                    │  Fresh routes (public + admin) + JSON API  │
                    │  + render endpoints (Section 8, 16)         │
                    │  + in-process job runner (Section 4.7)      │
                    └───────┬───────────────────────┬───────────┘
                            │                         │
                 ┌──────────▼─────────┐   ┌──────────▼──────────┐
                 │   PostgreSQL 18     │   │   Object storage      │
                 │   (metadata,        │   │   (fs or S3-compat,   │
                 │   jobs, sessions)   │   │   Section 4.7)        │
                 └──────────▲──────────┘   └──────────▲───────────┘
                            │                          │
                    ┌───────┴──────────────────────────┴────────┐
                    │   Deno process: skinforge-cli (staff-only)  │
                    │   discovery, extraction, import, publish    │
                    │   (Section 7, Section 9), same core/ library │
                    └──────────────────────────────────────────────┘

The web process and the CLI process never run in the same OS process, but they import the exact same core/ package from the same repository checkout, so there is exactly one implementation of every domain algorithm (Section 4.5).

4.4 Request flow walkthroughs #

Cold load of /d/:code (a shared permalink, no cache warm anywhere):

Browser              Caddy/CDN         Fresh web process        Postgres      Object storage
  │  GET /d/abc123def4    │                    │                    │               │
  │───────────────────────▶ (cache miss)       │                    │               │
  │                        │───────────────────▶ route handler      │               │
  │                        │                    │  look up design ──▶ SELECT design │
  │                        │                    │◀───────────────────  row + build   │
  │                        │                    │  check render cache ──────────────▶ HEAD renders/../hash.webp
  │                        │                    │◀────────────────────────────────── 404 (cache miss)
  │                        │                    │  composite server-side (Section 8) │
  │                        │                    │  write render ─────────────────────▶ PUT renders/../hash.webp
  │                        │                    │  render full HTML page             │
  │                        │◀───────────────────  200 HTML (img src = render URL)     │
  │◀───────────────────────  200 HTML             │                    │               │
  │  GET render URL from <img src>                │                    │               │
  │───────────────────────▶ (cache miss) ────────▶ render route ──────────────────────▶ GET renders/../hash.webp
  │                        │                    │◀──────────────────────────────────── 200 image bytes
  │◀───────────────────────  200 image, immutable cache headers (Section 8.9)          │

Designer slot change with client-side compositing (no network round trip for the preview itself):

Visitor action        Designer island (browser)                  In-memory state
  │ picks hair asset+hue │                                            │
  │──────────────────────▶ update signal for slot `hair`  ────────────▶ slots.hair = {asset, hue}
  │                       │ recompute composite on <canvas>:           │
  │                       │   for each occupied slot in z-order:       │
  │                       │     fetch cached sprite bytes (already      │
  │                       │     preloaded per Section 12.2)             │
  │                       │     apply hue table lookup (Section 8.3)   │
  │                       │     alpha-composite onto canvas             │
  │                       │ canvas repaints, visible within one frame  │

Cache-miss server render (triggered by any first request for a given design/scale/format combination, whether from a browser, a crawler, or the OG image generator):

Requester             Fresh render route            core/hue+composite (Section 8)      Object storage
  │ GET /render/d/:code@2.webp │                          │                        │
  │──────────────────────────────▶ compute cache key         │                        │
  │                              │  (Section 8.9) ───────────▶ check existence         │
  │                              │                            │───────────────────────▶ HEAD
  │                              │                            │◀─────────────────────── 404
  │                              │  load design + asset refs  │                        │
  │                              │  from Postgres              │                        │
  │                              │  run composite pipeline ───▶ decode sprites, hue,   │
  │                              │                              alpha-composite, encode │
  │                              │◀──────────────────────────  bytes                    │
  │                              │  write to object storage ─────────────────────────────▶ PUT
  │◀──────────────────────────────  200 image, immutable cache headers                    │

OG image fetch by a crawler (e.g. Discord's link unfurler):

Crawler                Fresh /og route              core/codec/og.ts (Section 14)         Object storage
  │ GET /og/d/:code.png  │                               │                          │
  │────────────────────────▶ check og_images cache row      │                          │
  │                        │───────────────────────────────▶ SELECT og_images         │
  │                        │◀─────────────────────────────── row absent (first fetch) │
  │                        │  render composite PNG at OG scale (calls Section 8)       │
  │                        │  build satori SVG layout, rasterize with resvg-wasm       │
  │                        │  store PNG ───────────────────────────────────────────────▶ PUT
  │                        │  insert og_images row                                     │
  │◀────────────────────────  200 PNG, immutable cache headers                          │

Staff import run (Journey D, Section 2.4):

Staff (CLI)            cli/main.ts                 core/import (Section 7, 9)   Postgres / storage
  │ skinforge-cli import discover --client-dir <path> │  │                          │
  │─────────────────────────────────────────▶ Stage D1–D3 (walk, identify, probe)     │
  │                                          │  insert source_files, import_runs rows ▶ INSERT
  │ skinforge-cli import extract --run <id> │              │                          │
  │─────────────────────────────────────────▶ Stage D4–D6 (extract, classify,          │
  │                                          │  normalize) ───────────────────────────▶ INSERT candidates,
  │                                          │                                          write normalized images
  │  (staff reviews candidates in admin console — separate flow, Section 17.6)          │
  │ skinforge-cli builds publish --build <id> │            │                          │
  │─────────────────────────────────────────▶ promote approved candidates to assets ──▶ INSERT/UPDATE
  │                                          │  new game_build row, invalidate CDN     │
  │                                          │  (Section 9.5) if configured             │

4.5 Module boundaries #

core/       Pure domain logic. No HTTP, no filesystem access except through injected
            interfaces, no framework imports. Sub-modules: formats/ (MUL/IDX/UOP readers),
            hue/ (Section 8.3 math), composite/ (Section 8.4), codec/ (PNG/WebP encode via
            jsquash), schema/ (Zod modules, Section 5.4), design/ (canonical JSON + hashing,
            Section 13), db/ (hand-written SQL repository modules).
web/        Fresh application. routes/ (pages + API + render + admin, following Fresh's file
            routing convention), islands/ (interactive components: designer, hue picker,
            catalog filters), components/ (server-rendered, non-interactive UI pieces).
cli/        skinforge-cli commands: import discover/identify/probe/extract/classify/normalize/
            run/verify, builds submit-review/publish/rollback/diff, db migrate/seed/fixtures,
            admin create-user/reset-credentials.
db/         migrations/ (Section 6.1 numbered SQL files), seed data (Section 1 D-21).

Dependency direction rule, enforced by lint (Section 5.4) and CI (Section 22.11): core/ imports nothing from web/ or cli/. web/ and cli/ may both import from core/. web/ never imports from cli/ and cli/ never imports from web/ — the only shared code path between them is core/. This guarantees the compositing and hue-math code paths used by the live browser preview, the server-side permalink render, and the staff-side extraction validation step are the literal same functions, not parallel reimplementations.

4.6 Rendering architecture decision record #

Decision: all pixel-level work — sprite decoding, hue table application, alpha compositing, and final image encoding — is pure TypeScript plus WASM codecs (@jsquash/png, @jsquash/webp, @resvg/resvg-wasm). No ImageMagick, no sharp, no other native image-processing binary, and no headless browser anywhere in the render path.

Why:

  • No native binary dependency. sharp and ImageMagick require native shared libraries that must match the host OS/CPU architecture and be kept patched independently of the application's own dependency updates; a pure TS/WASM path runs identically wherever Deno runs, including inside the minimal Docker image described in Section 23.1.
  • Deterministic, portable pixel math. UO's hue algorithm (Section 8.3) is a simple table lookup, not a general image-processing operation; implementing it directly is less code than adapting a general-purpose library's color-manipulation API to replicate a proprietary game's exact recoloring rule, and it guarantees pixel-perfect parity with the original client rather than an approximation.
  • Same code client- and server-side. @jsquash/* codecs and the hand-written hue/composite code run unmodified in both the Deno server process and, compiled for the browser, inside the designer island (Section 12.2), which is what makes the golden-image parity test in Section 22.3 possible in the first place — a native server-only library could never run in the browser to be compared against.
  • No headless browser for OG images. satori + resvg-wasm produce the Open Graph PNG (Section
    1. without launching a Chromium instance, which would be an order of magnitude heavier in both memory and startup latency for a single-VPS deployment (Section 4.8).

Trade-offs accepted:

  • WASM codecs are somewhat slower per-image than a native sharp call. This is acceptable because SkinForge's images are small (260×330 source canvas, Section 8.1) and render outputs are cached aggressively and immutably (Section 8.9); the cost is paid once per unique design/scale/format combination, never per request.
  • The team maintains its own hue/compositing implementation rather than delegating to a general-purpose library; this is intentional, per the determinism argument above, and the surface area is small (a handful of pure functions, Section 8.3–8.4), not a general image-processing engine.

Measured expectations: a single composite-and-encode operation (all 19 potential slots, WebP output, scale 1) is expected to complete in under 50ms on typical single-VPS-class CPU hardware; the performance budget and how it is enforced in CI is detailed in Section 19.1 and Section 22.7.

4.7 Data storage decisions #

  • PostgreSQL for all metadata. Every table in Section 6 — catalog data, designs, jobs, admin accounts, audit log, rate-limit state, counters — lives in Postgres. One database technology to operate, back up (Section 23.6), and query, with strong consistency for the operations that need it (e.g. the design short-code collision check in Section 13.2 relies on a transactional read-check- insert).
  • Object storage for pixels only. Rendered images, normalized source sprites, and OG images are never stored as database bytea columns; they live in the pluggable object storage driver (fs or s3, Section 1 D-02) addressed by content-hash keys (Section 8.9), keeping the database small and fast to back up, and letting a CDN (Section 19.3) serve image bytes directly from storage or its cache without touching the application process.
  • No Redis, no message broker, in v1. Background work (rendering a permalink's first request, running an import stage, generating an OG image) is modeled as rows in a Postgres jobs table, claimed with SELECT ... FOR UPDATE SKIP LOCKED so multiple job-runner loop iterations (up to SKINFORGE_RENDER_MAX_CONCURRENCY, Section 1 D-25) never claim the same job twice. Rate limiting (Section 16.7) uses a Postgres-backed token-bucket table for durability across process restarts, fronted by an in-process LRU cache so the hot path for a well-behaved client never touches the database.
  • Exact reasons this is sufficient at SkinForge's scale: job volume is bounded by request volume to a single small application (Section 2.6's target metrics imply low hundreds of jobs per day at launch scale), Postgres's SKIP LOCKED pattern is a well-established substitute for a dedicated queue at this volume, and removing Redis/a broker removes an entire additional stateful service to operate, back up, and secure on the single-VPS deployment target (Section 1 D-01).

4.8 Scaling posture #

What a single VPS handles. A modestly sized VPS (4 vCPU / 8GB RAM class) comfortably serves the target metrics in Section 2.6: the render path is CPU-bound but cached immutably per unique design/scale/format (Section 8.9), so steady-state traffic after a design has been viewed once is almost entirely served from object storage or the CDN with no compositing work at all. Postgres, object storage (if using the fs driver), and the web process share the one host under the default deployment (Section 1 D-01).

First bottleneck. The first bottleneck under load is CPU contention between (a) render-cache-miss compositing for many distinct new designs at once and (b) a concurrent staff import run's extraction and normalization work (Section 7, Stage D4/D6), because both are CPU-bound WASM/TS work on the same process pool. Section 1 D-25's job-runner concurrency cap exists specifically to bound this.

Horizontal-scaling path. The web process is stateless (all state is in Postgres and object storage), so the path to handling more traffic is: run multiple web process replicas behind the existing reverse proxy/CDN, point every replica at the same Postgres instance and the same object storage bucket (switching the storage driver to s3, Section 1 D-02, becomes necessary once more than one host needs to read/write the same files), and keep the job runner's SKIP LOCKED claiming model, which already works correctly with multiple concurrent claimers because it is a database-level lock, not an in-process one. Postgres itself would move to a managed or replicated instance before it became the bottleneck, given the read-heavy, small-row-size workload described in Section 6.

What would have to change to scale further: move the staff import/extraction workload (CPU-heavy, bursty, staff-triggered) to run on a separate host or a separate container from the public-facing web replicas, so a large import run never competes with public request latency; this requires no code change, only a deployment topology change, because cli/ already runs as an independent process from web/ (Section 4.3).

4.9 Alternatives considered and rejected #

Alternative Rejected because
Deno Deploy as the primary/only deployment target Deno Deploy's edge model does not support the staff extraction pipeline's need for staff-controlled, potentially large local filesystem access to the operator's game client directory, nor long-running CPU-bound import jobs; it is named in Section 23 only as an alternative for the stateless web tier, with the extraction/import tier staying self-hosted.
Node.js + Next.js Would require a separate TypeScript execution model for the CLI (ts-node or a build step) rather than Deno's native TS execution shared identically between web/ and cli/; Next.js's app-router rendering model provides no advantage over Fresh's islands for this product's static-heavy, low-interactivity page mix.
SQLite Insufficient concurrent-write characteristics for a Postgres-SKIP LOCKED job queue under concurrent public request load plus concurrent staff import work; no built-in full-text search comparable to Postgres's for Section 15's catalog search; would still require a migration to Postgres before any horizontal scaling (Section 4.8).
Client-only tool, visitor supplies their own game files Rejected outright by the scope boundaries in Section 2.5, restated in Section 20.8: end users never install the game client, never point a game directory at the tool, and never download raw game files. A client-only tool would also produce inconsistent results across visitors depending on each visitor's own client version.
Storing raw MUL/UOP files on the server for on-the-fly decode per request Rejected because on-the-fly decode of proprietary container formats on every cold request adds latency and CPU cost that normalized, pre-extracted, provenance-tracked assets (Section 7, Stage D6) avoid entirely; it would also mean redistributing the operator's raw client files indefinitely, which is a legal and scope concern (Section 20) SkinForge avoids by extracting and normalizing once, then discarding the need to touch the original container format again.

4.10 Non-functional requirements #

Dimension Requirement
Latency — page load (permalink, cached render) Under 300ms server response time at the 95th percentile, measured at the origin (before CDN), per Section 19.1.
Latency — first composited preview (cold, uncached) Under 1.5s from first paint to visible paperdoll at the 75th percentile on a mid-tier mobile device (Section 2.6 metric, Section 19.1 budget).
Availability target 99.5% monthly, consistent with a single-VPS deployment (Section 1 D-01) with automated restart (Section 23.2) rather than multi-region failover.
Storage growth Object storage grows primarily with distinct rendered design/scale/format combinations; budgeted and monitored per Section 19.4, with cache-eviction-by-age not applied (renders are immutable and cheap to keep, Section 8.9) but monitored for capacity planning.
Concurrency Job runner bounded at SKINFORGE_RENDER_MAX_CONCURRENCY (default 4, Section 1 D-25) concurrent background jobs; public request handling is otherwise limited only by the reverse proxy and rate limits (Section 1 D-13).
Browser support Last 2 major versions of Chrome, Firefox, Safari, and Edge; iOS Safari 17 and later. The designer island degrades to a clear "unsupported browser" notice (Section 11) rather than a silent failure on anything older; static permalink viewing (Section 10.3) works on any browser that renders HTML and <img>, including no-JS user agents, because the composite is server-rendered.

These figures are the targets Section 19's caching and performance design is built to meet, and the thresholds Section 22's performance test gate (Section 22.7) checks in CI before a release is considered shippable. They are not aspirational — every other architectural decision in Section 4 (pure TS/WASM rendering, immutable content-addressed caching, a stateless web tier) is chosen specifically because it makes these numbers achievable on the single-VPS deployment target in Section 1 D-01 without requiring a larger infrastructure footprint at launch.

5. Repository Layout, Conventions & Coding Standards #

5.1 Full repository tree #

skinforge/
├── deno.json                      # tasks, imports map, compilerOptions, lint/fmt config (Section 5.2)
├── deno.lock                      # generated, committed
├── main.ts                        # production entrypoint: starts the Fresh web app + job runner
├── dev.ts                         # development entrypoint: Fresh dev server with hot reload
├── fresh.config.ts                # Fresh 2.x App builder: middleware, Tailwind plugin registration,
│                                   # island discovery (Section 4.1)
├── .env.example                   # every env var from Section 24, with the working defaults
├── .gitignore
├── README.md                      # developer's own project readme (not this specification)
├── CHANGELOG.md                   # Conventional-Commits-derived changelog
├── docker/
│   ├── Dockerfile                 # multi-stage: deno cache -> deno compile-free runtime image
│   ├── docker-compose.yml         # app + postgres + optional minio + caddy (Section 23.1)
│   └── Caddyfile                  # reverse proxy + TLS config
├── .github/
│   └── workflows/
│       ├── ci.yml                 # lint, fmt check, type check, unit + golden-image + e2e tests
│       └── deploy.yml             # build + push image + trigger deploy (Section 23)
├── core/                          # pure domain logic, no HTTP/framework imports (Section 4.5)
│   ├── config.ts                  # typed env var loader, single source of truth for defaults (Section 24)
│   ├── errors.ts                  # AppError hierarchy + error code registry (Section 25)
│   ├── hue-preview-reference.ts    # static hue-swatch preview data (Section 10.3.7, Section 15.5)
│   ├── formats/
│   │   ├── mul.ts                 # MUL/IDX reader (Section 7 Stage D2/D4)
│   │   ├── uop.ts                 # UOP/MythicPackage reader
│   │   ├── tiledata.ts            # tiledata.mul parser
│   │   ├── hues.ts                # hues.mul parser (Section 3.6)
│   │   └── detect.ts              # format fingerprinting (Section 7 Stage D2/D3)
│   ├── hue/
│   │   ├── unpack.ts              # ARGB1555 -> RGB8 (Section 3.6, Section 8.3.1)
│   │   └── apply.ts               # full/partial hue application (Section 8.3)
│   ├── composite/
│   │   ├── canvas.ts              # RGBA buffer compositing primitives (Section 8.4)
│   │   ├── render-design.ts       # full design -> composited buffer (Section 8.4)
│   │   └── scale.ts               # nearest-neighbour integer upscaling (Section 8.6)
│   ├── codec/
│   │   ├── png.ts                 # @jsquash/png wrapper
│   │   ├── webp.ts                # @jsquash/webp wrapper
│   │   └── og.ts                  # satori + resvg-wasm wrapper (Section 14)
│   ├── domain/
│   │   └── slots.ts               # the slot registry, z-order and the SlotKey type; exports the
│   │                               # Zod slot-key validator (Section 3.3) — the single owner of all
│   │                               # three; nothing else in core/ redeclares them
│   ├── schema/
│   │   ├── design.ts              # Zod schema for design JSON (Section 13.2.5); imports the
│   │   │                          # slot-key validator from core/domain/slots.ts rather than
│   │   │                          # redeclaring it
│   │   ├── asset.ts               # Zod schema for asset/variant records
│   │   ├── hue.ts                 # Zod schema for hue records
│   │   └── admin.ts               # Zod schema for admin console forms
│   ├── http/
│   │   └── security-headers.ts    # CSP and the other security response headers (Section 20.4)
│   ├── design/
│   │   ├── canonicalize.ts        # sort keys, strip empties (Section 13.3)
│   │   └── short-code.ts          # Crockford Base32 hashing (Section 13.4)
│   ├── db/
│   │   ├── client.ts              # postgres.js pool setup
│   │   ├── assets.ts              # hand-written SQL repository module
│   │   ├── hues.ts
│   │   ├── designs.ts
│   │   ├── jobs.ts
│   │   └── ...                    # one module per table group (Section 6)
│   ├── storage/
│   │   ├── driver.ts              # pluggable object-storage interface (Section 1 D-02)
│   │   ├── fs.ts                  # local-disk driver
│   │   └── s3.ts                  # S3-compatible driver
│   ├── auth/
│   │   ├── session.ts             # admin session issuance/validation (Section 17.1)
│   │   └── totp.ts                # TOTP enrollment/verification (Section 17.2)
│   ├── api/
│   │   └── cursor.ts              # opaque, HMAC-signed pagination cursor encode/decode (Section 16.5)
│   ├── search/
│   │   └── tsvector.ts            # catalog full-text search query building (Section 15.6)
│   ├── util/
│   │   └── rng.ts                 # seeded PRNG for the Randomize feature (Section 11.8)
│   ├── jobs/
│   │   ├── runner.ts              # SKIP LOCKED claim loop (Section 4.7)
│   │   └── handlers/              # one file per job type (render, og-generate, import-stage)
│   ├── i18n/
│   │   └── en.ts                  # every user-facing string (Section 5.8, Section 1 D-17)
│   └── import/
│       ├── discover.ts            # Stage D1
│       ├── identify.ts            # Stage D2
│       ├── probe.ts               # Stage D3
│       ├── extract.ts             # Stage D4
│       ├── classify.ts            # Stage D5
│       └── normalize.ts           # Stage D6
├── web/
│   ├── routes/
│   │   ├── index.tsx               # `/` (Section 10)
│   │   ├── design.tsx               # `/design` (301 redirect to `/`)
│   │   ├── d/[code].tsx             # `/d/:code`
│   │   ├── catalog/
│   │   │   ├── index.tsx            # `/catalog`
│   │   │   ├── [slotKey]/
│   │   │   │   ├── index.tsx        # `/catalog/:slotKey`
│   │   │   │   └── [assetKey].tsx   # `/catalog/:slotKey/:assetKey`
│   │   ├── hues/
│   │   │   ├── index.tsx            # `/hues`
│   │   │   └── [hueIndex].tsx       # `/hues/:hueIndex`
│   │   ├── about.tsx
│   │   ├── faq.tsx
│   │   ├── legal.tsx
│   │   ├── support.tsx
│   │   ├── changelog.tsx
│   │   ├── sitemap.xml.ts
│   │   ├── robots.txt.ts
│   │   ├── opensearch.xml.ts
│   │   ├── render/                  # Section 8 render endpoints
│   │   ├── og/                      # Section 14 OG endpoints
│   │   ├── api/v1/                  # Section 16 JSON API
│   │   └── admin/                   # Section 17.17 admin API + admin console routes (`/admin/api/*`)
│   ├── islands/
│   │   ├── designer/                # the main interactive designer island tree (Section 11)
│   │   │   ├── actions.ts           # designer state-mutation actions over Preact Signals (Section 11.3)
│   │   │   └── virtual-list.ts      # option-panel grid windowing helper (Section 11.15)
│   │   ├── hue-picker/
│   │   ├── catalog-filters/
│   │   └── admin/                   # interactive admin console widgets
│   ├── components/
│   │   ├── ui/                      # server-rendered design-system primitives (Section 18.5)
│   │   │   ├── button.tsx
│   │   │   └── use-focus-trap.ts
│   │   └── ...                      # other server-rendered, non-interactive UI (Section 18)
│   ├── styles/
│   │   └── theme.css                # design token custom properties (Section 18.2, Section 18.4)
│   └── static/                      # non-generated static assets
│       ├── favicon.ico
│       ├── manifest.json
│       ├── social-card.png          # site-wide OG fallback card, 1200×630 (Section 14.9)
│       └── fonts/
│           └── PixelifySans-Regular.ttf   # OG image generator only (Section 14.4); satori cannot
│                                           # parse WOFF2, so the OG pipeline needs a TTF
├── scripts/
│   └── build-og-fallback.ts         # regenerates web/static/social-card.png (Section 14.9.1)
├── cli/
│   ├── main.ts                      # skinforge-cli entrypoint
│   └── commands/
│       ├── import-discover.ts
│       ├── import-identify.ts
│       ├── import-probe.ts
│       ├── import-extract.ts
│       ├── import-classify.ts
│       ├── import-normalize.ts
│       ├── import-run.ts            # `import run --build <id>`, runs D1-D6 against an existing build
│       ├── import-all.ts            # `import all --client-dir <path> --build-label <label>`, creates the build then runs D1-D6
│       ├── import-verify.ts
│       ├── builds-submit-review.ts
│       ├── builds-publish.ts
│       ├── builds-rollback.ts
│       ├── builds-diff.ts
│       ├── db-migrate.ts
│       ├── db-seed.ts               # `db seed`: reference/taxonomy data (hue_groups, tags)
│       ├── db-fixtures.ts           # `db fixtures`: local-dev sample assets/hues (Section 1 D-21)
│       └── admin-user.ts
├── db/
│   ├── migrations/                  # Section 6.1, numbered SQL files
│   │   ├── 0001_init.sql
│   │   └── 0002_seed.sql            # reference/taxonomy seed rows: hue_groups, tags (Section 6.31)
│   └── seed/
│       └── dev-fixtures.ts          # local-dev sample data, never a numbered migration (Section 1 D-21)
├── tools/
│   └── lint-plugin.ts               # custom `deno lint` plugin: floating-promise and JSX-text-node
│                                     # rules (Section 5.4, Section 5.8)
└── tests/                          # cross-cutting suites only — see the note below the tree
    ├── golden/                      # golden-image fixtures + comparison tests (Section 22.3)
    ├── integration/                 # db + storage integration tests (Section 22.4)
    ├── e2e/                         # Playwright specs (Section 22.5)
    ├── contract/
    │   └── api-v1/                  # OpenAPI contract fixtures (Section 16.10, Section 16.12)
    ├── fixtures/                    # committed regression fixtures (e.g. captured UOP hash vectors, Section 7.3.4)
    └── load/
        └── k6/                     # k6 load/performance test scripts (Section 19.11, Section 22.7)

Every path shown above is created by the developer in their own repository as part of executing this specification.

Section 5.1 is the sole authority on repository file and directory paths. Every module path, script path, or test path named anywhere else in this specification — including Sections 15, 16, 20, 22, 24, 26 and 27 — refers to a path listed in this tree. Where another section's prose and this tree ever disagree, this tree wins and the other section's text is the one that is wrong.

Unit tests are co-located, not gathered into a tests/unit/ tree. Every unit test lives beside the module it tests, inside core/ or cli/, named <module>_test.ts — the underscore-suffix form Deno's own test runner discovers natively, e.g. core/hue/apply_test.ts next to core/hue/apply.ts. Nothing under core/ or cli/ is ever tested from a parallel tests/unit/ or __tests__/ directory. The tests/ tree above holds only the suites that are not one-to-one with a single module: golden-image comparisons, integration tests, end-to-end specs, API contract fixtures, regression fixtures, and load tests.

5.2 deno.json contents #

Section 4 is the ONLY place this specification states dependency versions. The <major line from Section 4.1> placeholders below are filled in at scaffold time from Section 4.1's table, using the caret form for the stated major line; this block is a rendering of Section 4.1's table, and if the two ever disagree, Section 4.1 wins and this block must be regenerated. The test:e2e task's Playwright version is likewise pinned from Section 4.1 so it never floats independently of the E2E suite it drives.

{
  "tasks": {
    "dev": "deno run -A --watch=web/,core/ dev.ts",
    "build": "deno run -A dev.ts build",
    "start": "deno run -A main.ts",
    "check": "deno fmt --check && deno lint && deno check main.ts cli/main.ts",
    "test": "deno test -A core/ cli/ tests/integration",
    "test:golden": "deno test -A tests/golden",
    "test:e2e": "deno run -A npm:playwright@<major line from Section 4.1> test",
    "lint": "deno lint",
    "fmt": "deno fmt",
    "db:migrate": "deno run -A cli/main.ts db migrate",
    "db:seed": "deno run -A cli/main.ts db seed",
    "db:fixtures": "deno run -A cli/main.ts db fixtures",
    "import:discover": "deno run -A cli/main.ts import discover",
    "import:extract": "deno run -A cli/main.ts import extract",
    "builds:publish": "deno run -A cli/main.ts builds publish"
  },
  "imports": {
    "@fresh/core": "jsr:@fresh/core@^<major line from Section 4.1>",
    "@fresh/plugin-tailwind": "jsr:@fresh/plugin-tailwind@^<major line from Section 4.1>",
    "preact": "npm:preact@^<major line from Section 4.1>",
    "@preact/signals": "npm:@preact/signals@^<major line from Section 4.1>",
    "lucide-preact": "npm:lucide-preact@^<major line from Section 4.1>",
    "@std/http": "jsr:@std/http@^<major line from Section 4.1>",
    "@std/path": "jsr:@std/path@^<major line from Section 4.1>",
    "@std/ulid": "jsr:@std/ulid@^<major line from Section 4.1>",
    "@std/assert": "jsr:@std/assert@^<major line from Section 4.1>",
    "postgres": "npm:postgres@^<major line from Section 4.1>",
    "zod": "npm:zod@^<major line from Section 4.1>",
    "@jsquash/png": "npm:@jsquash/png@^<major line from Section 4.1>",
    "@jsquash/webp": "npm:@jsquash/webp@^<major line from Section 4.1>",
    "satori": "npm:satori@^<major line from Section 4.1>",
    "@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^<major line from Section 4.1>",
    "$core/": "./core/",
    "$web/": "./web/",
    "$cli/": "./cli/"
  },
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "jsx": "precompile",
    "jsxImportSource": "preact"
  },
  "lint": {
    "plugins": ["./tools/lint-plugin.ts"],
    "rules": {
      "tags": ["recommended"],
      "include": ["no-unused-vars", "no-explicit-any"]
    },
    "exclude": ["tests/golden/fixtures/"]
  },
  "fmt": {
    "lineWidth": 100,
    "indentWidth": 2,
    "singleQuote": false,
    "exclude": ["tests/golden/fixtures/"]
  }
}

Import specifiers use the version-range caret (^) form shown above so deno.lock resolves to the current patch within each major line at install time, consistent with the "known-good floor, not a lockfile" rule in Section 4.1.

5.3 Naming conventions #

Category Convention Example
Directories kebab-case web/islands/hue-picker/
TypeScript/TSX files kebab-case render-design.ts, hue-picker.tsx
Fresh route files Fresh's own file-routing convention (bracketed dynamic segments) d/[code].tsx, catalog/[slotKey]/[assetKey].tsx
Route parameters :param in prose and route tables everywhere in this document; [param].tsx only in Fresh file paths /d/:code in prose; d/[code].tsx on disk
TypeScript types, interfaces, classes PascalCase type DesignJson, class AppError
TypeScript functions, variables camelCase renderDesign(), slotKey
TypeScript constants (module-level, immutable config) SCREAMING_SNAKE_CASE MAX_RENDER_SCALE
SQL tables, columns snake_case design_renders, created_at
SQL indexes <table>_<columns>_idx designs_short_code_idx
SQL constraints <type>_<table>_<columns> (pk_, fk_, uq_, ck_) uq_designs_short_code
Env vars SKINFORGE_SCREAMING_SNAKE_CASE SKINFORGE_DATABASE_URL
CSS / Tailwind custom classes (rare, only for non-utility cases) kebab-case, prefixed sf- .sf-paperdoll-canvas
Test names describe("<unit under test>") / it("does X when Y") it("returns hue 0 as unhued")
Git branches <type>/<short-slug>, type from Conventional Commits types feat/hue-picker-keyboard-nav
Commit messages Conventional Commits (type(scope): summary) fix(render): correct partial-hue luminance index off-by-one

5.4 TypeScript standards #

  • Strict mode is mandatory (strict: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true, Section 5.2). CI fails the build on any type error (deno task check, Section 5.9).
  • No any policy. no-explicit-any is an enforced lint rule (Section 5.2). The escape hatch is unknown plus an explicit narrowing function (a Zod .parse() call, a type predicate, or an exhaustive switch), never a type assertion (as SomeType) except at the single boundary where external, already-validated data enters the system (e.g. immediately after postgres.js returns a row whose shape is guaranteed by the migration that created the table).
  • Error handling pattern. Domain code in core/ returns a typed Result<T, E> union ({ ok: true; value: T } | { ok: false; error: E }) for expected failure modes (validation failure, asset not found, format-detection failure) rather than throwing. Route handlers in web/ and command handlers in cli/ are the boundary: they call domain functions, inspect the Result, and either proceed or construct and throw an AppError (Section 25) carrying an SF- error code, which a single top-level error-handling middleware catches and converts into the error envelope (Section 16.4). Domain code never throws for an expected failure; it may throw only for a genuine programmer error (e.g. an assertion that should be unreachable).
  • Async rules. Every async function that can fail is awaited inside a try/catch at the nearest boundary that can produce a typed Result or AppError; unhandled promise rejections are treated as a bug and fail CI (Section 5.9) via a global unhandledrejection listener in tests. Floating promises are caught by deno check's strict mode where possible and by the custom lint plugin described in Section 5.8; require-await is enabled via the recommended tag (Section 5.2).
  • Typed-array reads under noUncheckedIndexedAccess. Every code block in this specification is illustrative: under Section 5.2's noUncheckedIndexedAccess, every array/typed-array index read has type T | undefined, so a shipped module must narrow each read (px[o] ?? 0) or open with // deno-lint-ignore-file plus a local const at = (a: Uint8Array, i: number) => a[i] as number; helper before the indexed reads compile as written.
  • Logging rules. core/ and cli/ log through a single injected structured logger interface (never console.log directly in library code); web/ route handlers use the same interface, configured per Section 21.1's log schema. Log calls never include secrets (session tokens, TOTP codes, S3 credentials) — Section 20.4 lists the exact fields that must never appear in a log line.
  • Import ordering. Three groups, blank line between each, enforced by deno fmt: (1) standard library and JSR imports, (2) npm imports, (3) local $core/, $web/, $cli/ imports. Within a group, alphabetical by specifier.
  • Barrel-file policy. No barrel files (index.ts re-exporting an entire directory) inside core/; every module is imported by its direct path so bundlers and deno check can tree-shake and so circular-import risk stays visible. web/components/ and web/islands/ may use a single barrel file per top-level component group purely for import ergonomics in route files, never nested more than one level.

5.5 Fresh-specific conventions #

  • Island vs. server component. A component is an island only if it needs client-side interactivity that cannot be expressed as a plain HTML form submission or a link — for example, the live composited canvas preview (Section 12.2), the hue swatch picker with keyboard navigation (Section 11.6), and the catalog's live filter-as-you-type (Section 15.3). Anything that only displays server-computed data, or that only needs a plain <form method="post">, is a server component, never an island.
  • Props serialization limits. Island props must be JSON-serializable (no functions, no class instances, no Map/Set — plain objects, arrays, strings, numbers, booleans, null) because Fresh serializes island props into the initial HTML payload for hydration; any richer client-side state is constructed inside the island from those primitive props using signals (Section 4.1), never passed in pre-constructed.
  • Signals usage. Each independently-changing piece of designer state (Section 11.2: body, skin hue, and one entry per occupied slot) is its own signal, not one large object signal, so a change to one slot does not trigger a recompute of unrelated slot UI; the composite canvas subscribes to a computed signal that depends on all occupied-slot signals and re-renders only when that computed value changes.
  • Partials/streaming usage. Fresh partials (out-of-band HTML swaps) are used for the catalog's filter results (Section 15.3) so filtering re-renders only the results grid, not the full page; streaming responses are used for the admin console's import-run progress view (Section 17.5) so long-running extraction stages show live progress without client-side polling.
  • Progressive enhancement. The designer requires JavaScript; its no-JS behaviour is the read-only fallback in Section 10.7. Catalog browsing and filtering work with plain link/GET-form navigation when JavaScript is unavailable, falling back from partial-swap to full page loads.

5.6 SQL conventions #

  • Migration file naming and immutability. db/migrations/NNNN_snake_case_description.sql, four- digit zero-padded sequence number, strictly increasing, never renumbered. Once merged, a migration file is never edited; a mistake is corrected by a new, later migration, never by amending history.
  • Up-only policy with explicit rollback scripts. Each migration file contains only the forward ("up") change. A corresponding db/migrations/NNNN_snake_case_description.down.sql file is written alongside it for local development rollback convenience; the down script is never executed automatically in any deployed environment (Section 23 has no automatic-rollback step) — recovery in production goes through the restore runbook in Section 23.6, not a down migration.
  • Index naming: <table>_<column_or_columns>_idx (Section 5.3). Every foreign key column gets an index unless the migration comment explicitly justifies why not (e.g. a column that is always queried together with a more selective leading column in a composite index).
  • Constraint naming: pk_<table>, fk_<table>_<column>, uq_<table>_<column_or_columns>, ck_<table>_<short_description> (Section 5.3).
  • Enum-vs-lookup-table policy. A fixed, small, code-referenced set of values (e.g. a job's status) uses a Postgres CHECK constraint against a literal string list, not a native ENUM type, so adding a value is a new migration that alters the constraint rather than a harder-to-manage ALTER TYPE. A set of values that grows through normal application/admin activity (e.g. tags, hue groups) is a real lookup table with a foreign key, never a CHECK-constrained string column.
  • Transaction boundaries. Every multi-statement write that must be atomic (e.g. the design short-code collision check plus insert, Section 13.4.2; an import-run's candidate promotion, Section 9.4) is wrapped in a single postgres.js transaction (sql.begin(...)). Read-only multi-statement operations do not open a transaction unless they require a consistent snapshot across statements.

5.7 Code documentation #

  • JSDoc on every exported symbol in core/. Every exported function, type, and class in core/ carries a JSDoc comment stating its purpose, parameters, return value, and any error Result variants it can produce. web/ and cli/ code is documented where non-obvious but does not require exhaustive JSDoc, since route handlers and CLI commands are thin wrappers over documented core/ functions.
  • Module headers. Every file in core/ begins with a one-paragraph comment stating what the module owns and which specification section it implements (e.g. // Implements the hue application algorithm from Section 8.3.), so a reader can trace code back to the specification section that defines its behavior.
  • ADR file convention. Architectural decisions made during actual implementation (not already captured by Section 4.9's alternatives-considered table) are recorded by the developer in their own repository at docs/adr/NNNN-title.md, numbered sequentially, using a short "Context / Decision / Consequences" format. This is a convention for the developer's own repository, not a deliverable of this specification.

5.8 Accessibility and i18n conventions #

Every component, island or server-rendered, follows the WCAG 2.2 AA rules detailed in full in Section 18: semantic HTML elements before ARIA roles, visible focus states on every interactive element, hue swatches identified by name (not color alone) for colorblind users, minimum touch target sizes, and color-contrast minimums for all text. Every user-facing string is imported from core/i18n/en.ts (Section 5.1) rather than inlined in a component, so a component never hard-codes English text directly — this is what keeps the locale decision (Section 1 D-17) a data change rather than a refactor, and it is enforced by a project lint plugin at tools/lint-plugin.ts, registered in deno.json as "lint": { "plugins": ["./tools/lint-plugin.ts"] } (Section 5.2), which flags any JSX text node containing a non-whitespace string literal and any promise-returning expression used as a statement.

5.9 Definition of done #

A unit of work (a pull request implementing one feature, fix, or section of this specification) is done only when all of the following hold:

  1. Types, lint and format. deno task check passes with zero findings (it runs format check, lint, and type check).
  2. Tests. New behavior has unit test coverage as <module>_test.ts beside the changed core/ or cli/ module (Section 5.1); any change to rendering, compositing, or hue math additionally has or updates a golden-image fixture in tests/golden/ (Section 22.3); any change to a public route or API endpoint has or updates an integration or e2e test (Section 22.4, Section 22.5). deno task test, deno task test:golden, and the relevant deno task test:e2e suite all pass.
  3. Lint and format. deno task lint and deno fmt --check both pass with zero findings.
  4. Migration. Any schema change ships as a new numbered migration file (Section 5.6) plus its .down.sql counterpart, and deno task db:migrate runs cleanly against a fresh database.
  5. Documentation. New exported core/ symbols carry JSDoc (Section 5.7); any behavior that deviates from or extends this specification is recorded as a new ADR in the developer's own docs/adr/ (Section 5.7).
  6. Changelog. An entry is added to CHANGELOG.md under an "Unreleased" heading, following Conventional Commits categorization (Added, Changed, Fixed, Removed).
  7. CI green. The full CI workflow (Section 22.11) passes on the pull request before merge; no step is skipped or force-merged.

6. Data Model & Database Schema #

6.1 Entity-Relationship Overview #

All persistent state lives in PostgreSQL (the major version line is fixed in Section 4.1). The schema has five clusters: build provenance and import pipeline, catalog (slots/assets/hues/tags), designs and renders, admin/auth, and operational tables (jobs, rate limits, stats, settings, takedowns).

 game_builds ──< source_files ──< import_run_items >── import_runs
      │                                                      │
      │                                              extraction_candidates
      │
      ├──< assets ──< asset_variants ──< asset_images
      │       │            │
      │       │            └── build_id ── game_builds   (build-scoped, Section 6.11)
      │       │
      │       ├──< asset_tags >── tags
      │       │
      │       └── slot_key ── slots
      │
      ├──< hues ──< hue_group_members >── hue_groups
      │
      └──< designs ──< design_renders
                 │
                 ├──< og_images
                 └──< takedown_requests

 admin_users ──< admin_sessions
      │
      ├──< admin_recovery_codes
      ├──< admin_pending_mfa
      └──< audit_log (actor_id, nullable FK)

 admin_login_failures, app_settings, jobs, rate_limit_buckets, stat_counters,
 page_view_daily, schema_migrations                                    (standalone)

One-line purpose of every table in the schema:

Table Purpose
schema_migrations Tracks which migration files have been applied and in what order.
game_builds An immutable snapshot of one imported set of client data, with a lifecycle status.
source_files Every file discovered on the operator's client during a build's inventory stage.
import_runs One row per pipeline stage execution for a build; eight stage values, enumerated in Section 6.6.
import_run_items Per-item outcome (ok/skipped/failed/needs input) inside an import run.
extraction_candidates A sprite or hue candidate proposed by the pipeline, pending staff review.
slots The 19-row registry of designable body/cosmetic slots and their z-order.
assets A catalog entry for one wearable/cosmetic item, keyed by a stable natural key.
asset_variants Per-body, per-build sprite geometry, hue mode and hash for one asset.
asset_images A rendered scale × format file for one asset variant.
hues One decoded hue table (32 colours) with its index, build scope and provenance.
hue_groups Curated groupings of hues for browsing (skin, hair, tattoo, clothing, event).
hue_group_members Many-to-many join between hues and hue_groups, recording how each membership was assigned.
tags Catalog facets used for browse/search filtering.
asset_tags Many-to-many join between assets and tags.
designs One immutable saved combination, addressable by a short code.
design_renders Cache rows for server-rendered composite, asset and swatch images.
og_images Generated Open Graph preview images for a design.
jobs The Postgres-backed background job queue.
admin_users Staff accounts with password + TOTP credentials.
admin_sessions Server-side session records backing the sf_admin cookie.
admin_recovery_codes One-time TOTP recovery codes per admin user.
admin_pending_mfa Short-lived rows for a login that has passed the password step and is awaiting TOTP.
admin_login_failures Rolling record of failed admin login attempts, backing the lockout policy in Section 17.2.3.
app_settings The small set of operator-editable runtime settings the admin console writes (Section 17.14).
audit_log Append-only record of every state-changing admin/pipeline action.
rate_limit_buckets Token-bucket state for API, render, and design-creation rate limits.
stat_counters Per-day and all-time aggregate counters, no PII.
page_view_daily Daily page-view aggregate per path, written server-side, no PII.
takedown_requests Legal/rights takedown requests and their resolution state.

6.2 Conventions Recap #

All identifiers are snake_case; every table except schema_migrations, slots, hue_group_members, asset_tags, rate_limit_buckets, stat_counters, page_view_daily and app_settings uses a CHAR(26) ULID surrogate primary key named id, with stable natural keys (asset_key, slot_key, short_code) used everywhere else in the system, per the SQL and naming conventions in Sections 5.3 and 5.6. A ULID is 26 characters of Crockford Base32 (0123456789ABCDEFGHJKMNPQRSTVWXYZ — no I, L, O or U), generated by the application, never by the database.

Timestamps are TIMESTAMPTZ named created_at / updated_at / published_at / retired_at; catalog rows are soft-retired via retired_at, never hard-deleted, so permalinks never break. No catalog or design table carries a deleted_at column: there is no soft-delete concept distinct from retirement, and no hard-delete path outside the takedown process in Section 20.8.

Index names use the suffix form <table>_<columns>_idx, matching Section 5.6's convention; unique constraints declared inline on a table use PostgreSQL's own generated <table>_<columns>_key names.

Status vocabularies are defined once, here, and every screen, API response and CLI message uses these exact strings:

Column Allowed values
extraction_candidates.status pending, needs_operator_input, approved, rejected
import_runs.status queued, running, succeeded, failed, cancelled
import_run_items.status ok, skipped, failed, needs_operator_input
game_builds.status draft, in_review, published, archived, rolled_back
takedown_requests.status new, triaging, upheld, rejected, withdrawn
jobs.status queued, running, succeeded, failed, cancelled
admin_users.role viewer, curator, owner

6.3 schema_migrations #

Tracks applied migrations. Managed exclusively by the migration runner in Section 6.32; application code never writes to it.

Column Type Null Default Description
filename TEXT not null Migration file name, e.g. 0001_init.sql. Primary key.
checksum CHAR(64) not null SHA-256 of the file's contents at apply time (64 hex characters).
applied_at TIMESTAMPTZ not null now() When the migration was applied.

Primary key: filename. No foreign keys. No additional indexes. Typical row count: tens (one per migration file, growing slowly over the project's life).

6.4 game_builds #

The build lifecycle root. Every catalog row and hue row belongs to exactly one build.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
label TEXT not null Operator-chosen human label, e.g. 2026-09-01-patch-47.
status TEXT not null 'draft' One of draft, in_review, published, archived, rolled_back (Section 6.2).
build_hash CHAR(64) null Content fingerprint of the whole build, written once by the publish transaction (Section 9.5). Null until first published.
source_client_version TEXT null Free-text client version string supplied by the operator.
notes TEXT null Operator notes about this build.
created_by CHAR(26) null FK to admin_users.id.
published_at TIMESTAMPTZ null When this build became the active published build.
published_by CHAR(26) null FK to admin_users.id.
archived_at TIMESTAMPTZ null When this build was superseded by a later publish.
created_at TIMESTAMPTZ not null now() Row creation time.
updated_at TIMESTAMPTZ not null now() Last modification time.

Primary key: id. Unique: label. Foreign keys: created_byadmin_users(id) ON DELETE SET NULL; published_byadmin_users(id) ON DELETE SET NULL. Check: status IN ('draft','in_review', 'published','archived','rolled_back').

build_hash is sha256 over the concatenation, in asset_key order, of every asset_variants.sha256 belonging to this build, followed by, in hue_index order, every hues.colors array belonging to this build. It is computed and written once inside the publish transaction (Section 9.5) and never changes thereafter, which is what makes it safe to use as the salt of the render cache key in Section 8.9. It is nullable because a draft or in_review build has no fingerprint yet; a designs row can only reference a build that has been published (Section 9.1), so every build a render resolves against always has one.

archived and rolled_back are both terminal, non-current states. archived means the build was superseded by a normal later publish; rolled_back means it was explicitly withdrawn by the rollback procedure in Section 9.6. The distinction exists solely so the builds screen and the audit trail can tell an ordinary supersession from a withdrawal.

CREATE UNIQUE INDEX game_builds_published_singleton
  ON game_builds ((1)) WHERE status = 'published';

This partial unique index enforces at most one published build at a time; publishing a new build must archive the previous one in the same transaction (Section 9.5). Typical row count: dozens over the product's lifetime (one per game patch re-import).

6.5 source_files #

Every file the discovery stage (Section 7.2.1, D1) found on the operator's client directory for a build. No file bytes are stored in the database; only metadata and provenance.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
build_id CHAR(26) not null FK to game_builds.id.
relative_path TEXT not null Path relative to the operator's client root.
file_type TEXT not null One of mul, idx, uop, def, txt, image, archive, unknown.
size_bytes BIGINT not null File size in bytes.
sha256 CHAR(64) not null Content hash, 64 hex characters.
magic_bytes TEXT null Hex string of the first 16 bytes, for diagnostics.
entropy REAL null Shannon entropy of a sample, used for D3 compression sniffing.
discovered_at TIMESTAMPTZ not null now() When D1 recorded this file.

Primary key: id. Foreign key: build_idgame_builds(id) ON DELETE CASCADE. Unique: (build_id, relative_path).

CREATE INDEX source_files_build_type_idx
  ON source_files (build_id, file_type);
-- Supports the source-file list on the import detail screen (Section 17.5), filtered by classification.

Typical row count: 500–5,000 per build (client asset directories are large but bounded).

6.6 import_runs #

One row per execution of a pipeline stage (Section 7.2) against a build.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
build_id CHAR(26) not null FK to game_builds.id.
stage TEXT not null One of inventory, identify, probe, extract, classify, normalize, import, verify.
status TEXT not null 'queued' One of queued, running, succeeded, failed, cancelled (Section 6.2).
started_at TIMESTAMPTZ null When the stage started executing.
finished_at TIMESTAMPTZ null When the stage finished (success or failure).
triggered_by CHAR(26) null FK to admin_users.id; null for scheduled/CLI-unattended runs.
summary JSONB not null '{}' Stage-specific counters (files scanned, candidates found, errors).
error TEXT null Top-level error message if status = 'failed'.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Foreign keys: build_idgame_builds(id) ON DELETE CASCADE; triggered_byadmin_users(id) ON DELETE SET NULL. Check: stage and status constrained to the enumerations above.

The granularity is one row per stage, not one row per import. A screen that wants a single progress indicator for a whole build rolls up the latest row per stage for that build_id (Section 17.5); the stage identifiers used by every screen, every metric label and every log event are exactly the eight values in the stage CHECK above, never a D1D6 abbreviation.

D1D6 are the prose shorthand Section 7 uses when discussing the extraction pipeline as a narrative; they are never written to this column, never rendered in a UI and never used as a metric label. The mapping is fixed and total:

Section 7 shorthand import_runs.stage value Owner
D1 inventory Section 7.2.1
D2 identify Section 7.2.2
D3 probe Section 7.2.3
D4 extract Section 7.2.4
D5 classify Section 7.2.5
D6 normalize Section 7.2.6
import Section 9.2's ingestion step, which has no D shorthand
verify Section 7.9's import verify, run as a publish precondition (Section 9.5)

Eight stage values, six of which the extraction pipeline produces and two of which ingestion adds.

CREATE INDEX import_runs_build_stage_idx
  ON import_runs (build_id, stage, created_at DESC);
-- Lets the admin imports screen (Section 17.5) list the latest run per stage for a build.

Typical row count: 8–16 per build (one per stage, occasionally re-run).

6.7 import_run_items #

Per-item outcome inside an import run, used for progress reporting and failure triage.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
import_run_id CHAR(26) not null FK to import_runs.id.
source_file_id CHAR(26) null FK to source_files.id, when the item corresponds to one file.
item_type TEXT not null Free-text item classification, e.g. gump-block, hue-group.
status TEXT not null One of ok, skipped, failed, needs_operator_input.
detail JSONB not null '{}' Item-specific structured detail (offsets, error text, counts, reason).
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Foreign keys: import_run_idimport_runs(id) ON DELETE CASCADE; source_file_idsource_files(id) ON DELETE SET NULL. Check: status IN ('ok','skipped','failed', 'needs_operator_input').

CREATE INDEX import_run_items_run_status_idx
  ON import_run_items (import_run_id, status);
-- Supports the per-run failure/needs-input count shown in the admin imports detail screen (Section 17.5).

Typical row count: thousands per run (one row per file or extracted unit); the largest table in the pipeline cluster.

6.8 extraction_candidates #

A sprite or hue candidate proposed by classification (Section 7.6), awaiting or having received staff review.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
import_run_id CHAR(26) not null FK to import_runs.id.
source_file_id CHAR(26) null FK to source_files.id.
candidate_key TEXT not null Deterministic identifier derived from gump id or content hash.
suggested_slot_key TEXT null FK to slots.slot_key, the pipeline's best guess.
suggested_body TEXT null One of m, f, or null if body-independent/unknown.
confidence REAL not null 0 Classification confidence in [0,1].
status TEXT not null 'pending' One of pending, needs_operator_input, approved, rejected (Section 6.2).
image_ref TEXT null Work-directory-relative path of a preview PNG generated for staff review; never an object-storage key and never publicly addressable (Section 7.11).
metadata JSONB not null '{}' Raw extraction metadata (dimensions, offsets, format notes, perceptualHash, rejectionReason).
reviewed_by CHAR(26) null FK to admin_users.id.
reviewed_at TIMESTAMPTZ null When staff resolved this candidate.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Foreign keys: import_run_idimport_runs(id) ON DELETE CASCADE; source_file_idsource_files(id) ON DELETE SET NULL; suggested_slot_keyslots(slot_key) ON DELETE SET NULL; reviewed_byadmin_users(id) ON DELETE SET NULL. Check: confidence BETWEEN 0 AND 1; suggested_body IN ('m','f') or null; status constrained to the enumeration above. Unique: (import_run_id, candidate_key), which is the conflict target every D3/D5 write uses so re-running a stage is idempotent (Section 9.9).

CREATE INDEX extraction_candidates_status_confidence_idx
  ON extraction_candidates (status, confidence DESC);
-- Drives the staff review queue (Section 17.6): pending items ordered by ascending confidence surface first.

Typical row count: thousands per build; the queue drains toward zero pending rows as staff work through it, but historical approved/rejected rows are retained for audit.

6.9 slots #

The fixed registry of designable slots from Section 3.3. Rows are seeded once (Section 6.31) and never change afterwards: z_order participates in the canonicalization that produces a design's short code (Section 13.3), so editing it would silently change the meaning of every existing permalink. The admin slots screen (Section 17.9) is therefore read-only with respect to z_order.

Column Type Null Default Description
slot_key TEXT not null Natural key, e.g. hair. Primary key.
display_name TEXT not null Human label, e.g. Hair.
z_order INT not null Composite stacking order, lowest drawn first.
display_order INT not null Order the slot rail and catalog list the slot in, independent of z_order.
uo_layer INT null Classic UO layer id, null for slots with no classic layer.
hueable BOOLEAN not null true Whether this slot accepts a hue selection.
gender_scope TEXT not null 'both' One of both, male, female.
is_required BOOLEAN not null false True only for body.
is_always_visible BOOLEAN not null false True only for backpack.
visible BOOLEAN not null true Whether the slot is exposed in the designer, catalog and API at all.
hue_swatch_crop TEXT null Optional CSS object-position/crop hint for hue swatch thumbnails; null means the default centered 60%-scale crop.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: slot_key. Unique: z_order; display_order. Check: gender_scope IN ('both','male', 'female').

There is no stored choosable column. The choosable field returned by the API (Section 16.8.1) is the derived value NOT is_required: exactly one slot (body) is required, and the other 18 — backpack included — are choosable. gender_scope is the single name and the single vocabulary for the gender rule; the API exposes it as genderScope with the same three values. There is no genderRule field and no male_only/female_only spelling anywhere in the system.

visible is a presentation switch and nothing more. visible = false hides a slot from the slot rail (Section 11.5), the catalog overview and slot pages (Sections 15.2–15.3), and the /api/v1/slots listing (Section 16.8.1). It never changes CHOOSABLE_SLOT_KEYS (Section 13.2.5), never invalidates or re-canonicalizes an existing design, and never affects the render path — a design that already carries an entry for a hidden slot still renders that slot, exactly as it does for a retired asset (Section 9.7). The registry is 19 rows whatever visible says; hiding a slot changes what a visitor is offered, never what a permalink means. owner toggles it on the admin slots screen (Section 17.9), which is also the only column on this table staff may write.

The unique constraint on z_order already creates the index the renderer and every catalog listing use to iterate slots in composite order, so no separate index is declared.

Fixed row count: 19.

6.10 assets #

The catalog entry for one wearable or cosmetic item.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
asset_key TEXT not null Stable natural key, e.g. hair.long-wavy.
slot_key TEXT not null FK to slots.slot_key.
display_name TEXT not null Human label shown in the catalog and designer.
display_order INT not null 0 Curated order within a slot; ties break on display_name.
source_file_id CHAR(26) null FK to source_files.id, provenance of the original art.
source_offset BIGINT null Byte offset into the source file, provenance.
gump_id INT null Classic UO gump id, when applicable.
partial_hue BOOLEAN not null false The asset-level default hue mode D6 copies into each new asset_variants.hue_mode row.
search_tsv tsvector null Full-text search vector, maintained by trigger (Section 6.30); read by Section 15.6.
first_build_id CHAR(26) not null FK to game_builds.id, the build this asset first appeared in. Informational.
last_build_id CHAR(26) not null FK to game_builds.id, the most recent build that still carries it. Informational.
retired_at TIMESTAMPTZ null Set when a patch removes the underlying art; asset stays renderable.
created_at TIMESTAMPTZ not null now() Row creation time.
updated_at TIMESTAMPTZ not null now() Last modification time.

Primary key: id. Unique: asset_key. Foreign keys: slot_keyslots(slot_key) ON DELETE RESTRICT; source_file_idsource_files(id) ON DELETE SET NULL; first_build_idgame_builds(id) ON DELETE RESTRICT; last_build_idgame_builds(id) ON DELETE RESTRICT. Check: assets_asset_key_shape, asset_key ~ '^[a-z0-9]+([-_.][a-z0-9]+)*$' AND length(asset_key) <= 128 — the same shape AssetKeySchema enforces at the API boundary (Section 13.2.5), applied at the database so the ingest path, which never passes through an API schema, cannot write a key that would later be interpolated into an object-storage path.

ON DELETE RESTRICT on slot_key and the build FKs prevents removing a slot or build that catalog rows still reference; the correct way to remove an asset from circulation is retired_at, never a row delete, and there is no deleted_at column on this table.

first_build_id/last_build_id are provenance only. Build scoping that the renderer depends on lives on asset_variants.build_id (Section 6.11); no lookup on the render path consults either of these two columns.

partial_hue is the asset-level default, not the value the renderer reads. D6 (Section 7.8) copies it into asset_variants.hue_mode when it creates a variant row, and the renderer resolves the mode from the variant, so a single asset can carry a corrected mode in a newer build without rewriting the older build's variant.

CREATE INDEX assets_slot_key_idx ON assets (slot_key, display_order) WHERE retired_at IS NULL;
-- The hot path: list active assets for one slot in the designer and catalog, already ordered.
CREATE INDEX assets_gump_id_idx ON assets (gump_id) WHERE gump_id IS NOT NULL;
-- Used by classification (Section 7.6) to detect duplicate gump ids across builds.
CREATE INDEX assets_search_tsv_idx ON assets USING GIN (search_tsv);
-- Serves the full-text half of Section 15.6's search.
CREATE INDEX assets_display_name_trgm_idx ON assets USING GIN (display_name gin_trgm_ops);
-- Serves the fuzzy/similarity half of Section 15.6's search.

Typical row count: 2,000–6,000 (cosmetic items across the 18 choosable slots, both genders where distinct).

6.11 asset_variants #

Per-body, per-build sprite geometry for one asset. An asset with identical art for both genders still gets two rows per build (Section 8.5 requires per-body offsets even when the pixels match).

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
asset_id CHAR(26) not null FK to assets.id.
build_id CHAR(26) not null FK to game_builds.id; the build whose art this row describes.
body TEXT not null One of m, f.
width INT not null Sprite width in pixels.
height INT not null Sprite height in pixels.
offset_x INT not null Horizontal blit offset within the 260×330 canvas (Section 8.1).
offset_y INT not null Vertical blit offset within the 260×330 canvas.
hue_mode TEXT not null 'full' One of full, partial; the mode Section 8.3.3 applies to this sprite.
sha256 CHAR(64) not null Hash of the decoded RGBA pixel buffer, 64 hex characters.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Unique: (asset_id, body, build_id). Foreign keys: asset_idassets(id) ON DELETE CASCADE; build_idgame_builds(id) ON DELETE RESTRICT. Check: body IN ('m','f'); hue_mode IN ('full','partial'); width > 0 AND height > 0.

Why build_id is on this table. Permalink immutability is the product's core promise, and it is structural, not aspirational. A design pins designs.build_id at creation; the renderer resolves every asset and every hue inside that build. hues has always been build-scoped by (hue_index, build_id) (Section 6.13), and asset_variants is scoped the same way for the same reason: when a patch changes an asset's art, D6 inserts a new variant row for the new build instead of overwriting the old one, so every permalink created before the patch keeps resolving the bytes it was created against. Without this column the "Changed" bucket in Section 9.3 would have to overwrite a single row per (asset_id, body) and would silently re-render every historical design with the new art.

CREATE INDEX asset_variants_sha256_idx ON asset_variants (sha256);
-- Used by diffing (Section 9.3) to detect unchanged art across builds without pixel comparison.
CREATE INDEX asset_variants_build_idx ON asset_variants (build_id);
-- Serves the render path's build-scoped lookup and the per-build enumeration the publish
-- transaction uses to compute game_builds.build_hash (Section 9.5).

Typical row count: roughly double assets per build (most slots have both-gender variants; facial_hair has male only), and the table therefore grows once per build rather than being rewritten in place.

6.12 asset_images #

A rendered scale × format output file for one asset variant, produced by normalization (Section 7.8).

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
asset_variant_id CHAR(26) not null FK to asset_variants.id.
scale SMALLINT not null One of 1, 2, 3.
format TEXT not null One of webp, png.
storage_key TEXT not null Object storage key under the catalog/ prefix, content-addressed on the variant's sha256: catalog/:assetKey/:body/:hash12/:scale.:format (Section 7.8).
byte_size INT not null Encoded file size in bytes.
content_hash CHAR(64) not null SHA-256 of the encoded file bytes.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Unique: (asset_variant_id, scale, format). Foreign key: asset_variant_idasset_variants(id) ON DELETE CASCADE. Check: scale IN (1,2,3); format IN ('webp','png').

storage_key is deliberately not unique, and that is a direct consequence of build-scoped variants (Section 6.11). The key embeds the variant's content hash, so two builds whose art for an asset is byte-identical produce the same key and share one stored object — the common case, since most assets are unchanged by any given patch. Each build still gets its own asset_variants row and its own six asset_images rows, and those rows point at the shared object. A unique constraint here would reject the second build outright.

The same property is what stops a patch from overwriting history: because the key changes whenever the bytes change, D6 never writes new art over an object an older build's design still resolves. A (asset_variant_id, scale, format) pair is still unique, so a variant can never have two rows for the same scale and format.

Two formats and no others, and the CHECK is the enforcement: the pipeline produces encoded images only. There is no rgba format value, no raw-buffer object under catalog/, and no per-sprite mask or index sidecar anywhere in this system — the hue table index is derived from the sprite pixel itself (Section 8.3), so nothing needs to be stored beside the image. The renderer's input is the scale-1 PNG, catalog/:assetKey/:body/:hash12/1.png, decoded to RGBA in process (Section 8.2.1); PNG is lossless, so that decode reproduces exactly the buffer extraction wrote (Section 7.8). Decoded RGBA buffers do exist during import, but only as scratch files inside the operator's work directory (Section 7.5); they are never uploaded, never get an asset_images row, and never leave the machine running the pipeline.

CREATE INDEX asset_images_storage_key_idx ON asset_images (storage_key);
-- Resolves "which rows reference this object", used by the restore validation in Section 6.34.

Typical row count: 6× asset_variants (3 scales × 2 formats each); distinct stored objects are far fewer, since unchanged art is shared across every build that carries it.

6.13 hues #

One decoded 32-colour hue table, per the hue model in Section 3.6.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
hue_index INT not null The UO hue index (0 = unhued is not stored as a row; see note below).
source TEXT not null One of base, outlands, derived.
name TEXT not null Decoded 20-byte name from the hue table, trimmed; never blank (Section 7.7 substitutes Hue <index>).
colors INTEGER[32] not null 32 packed 0xRRGGBB colours, index 0..31.
table_start INT not null Decoded tableStart field (Section 7.3.3). INT, not SMALLINT: the field is an unsigned 16-bit value and values above 32,767 overflow SMALLINT.
table_end INT not null Decoded tableEnd field, same reasoning.
swatch_color INTEGER not null Packed 0xRRGGBB representative colour for UI swatches, computed per Section 7.7.
swatch_override_argb INTEGER null Staff override for the swatch colour, set on the hues admin screen (Section 17.8); when non-null it wins over swatch_color.
partial BOOLEAN not null false Whether this hue is conventionally applied as partial-hue.
build_id CHAR(26) not null FK to game_builds.id.
retired_at TIMESTAMPTZ null Set if a later build removes or renumbers this hue.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Unique: (hue_index, build_id) — the same numeric hue index can be re-imported per build with a possibly different colour table. Foreign key: build_idgame_builds(id) ON DELETE RESTRICT. Check: source IN ('base','outlands','derived'); array_length(colors,1) = 32.

Because the unique key is (hue_index, build_id) and not hue_index alone, no query anywhere resolves a hue without a build_id. A lookup that filters on hue_index only returns one row per build and, if the caller takes the first, renders an old design with a newer colour table — precisely the failure Sections 9.8 and 13.7 exist to prevent. Section 19.7 states the same rule for the hot render-path query.

Hue index 0 (unhued) is never stored as a row; it is a reserved sentinel handled entirely in application code and Section 8.3.3's rendering algorithm, since it has no colour table to decode. Because there is no row, there is also no name: a design carrying skinHue: 0 is described as "default skin", never by a hue name (Section 12.10).

Who writes this table. hues rows are inserted by stage D6 (Sections 7.2.6 and 7.7), in the same run that writes assets, asset_variants and asset_images, and by nothing else. D4 decodes the hue tables into the work directory and D5 records a proposed group set on the candidate row, but neither touches this table — a hues row cannot exist before D6 runs, which is why hue_group_members (Section 6.15) is written by D6 as well. Staff edits on the hues admin screen (Section 17.8) update name, source and swatch_override_argb on existing rows; they never insert one.

CREATE INDEX hues_source_idx ON hues (source) WHERE retired_at IS NULL;
-- Powers the hue browser (Section 15.5) filtered by base/outlands/derived.

Typical row count: 3,000–4,000 active per build (UO's base hue space) plus Outlands custom hues.

6.14 hue_groups #

Curated groupings of hues for browsing and filtering.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
group_key TEXT not null Natural key, e.g. skin, hair, tattoo, clothing, event.
display_name TEXT not null Human label.
sort_order INT not null Display order in the hue browser and picker.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Unique: group_key. Fixed row count: 5 (seeded, Section 6.31).

A hue_group here is a curated UI taxonomy, not a file-format structure. The 8-hue records inside hues.mul described in Section 7.3.3 are hue blocks, a decoding detail that never becomes a row in this table. Anything joining on group membership joins hue_groups.group_key; there is no hue_group column on asset_variants or on any other table.

6.15 hue_group_members #

Many-to-many join between hues and hue_groups. A hue may belong to more than one group (e.g. an Outlands cosmetic hue tagged both skin and event).

Column Type Null Default Description
hue_id CHAR(26) not null FK to hues.id.
hue_group_id CHAR(26) not null FK to hue_groups.id.
method TEXT not null 'manual' How this membership was assigned: heuristic (written by D6 as it inserts each hues row, applying Section 7.7's keyword/range rules) or manual (set by staff in Section 17.8).
assigned_at TIMESTAMPTZ not null now() When the membership was assigned.

Primary key: (hue_id, hue_group_id). Foreign keys: hue_idhues(id) ON DELETE CASCADE; hue_group_idhue_groups(id) ON DELETE CASCADE. Check: method IN ('manual','heuristic').

method is what lets the curation policy change without a schema change: the import path writes heuristic rows, staff edits write manual rows, and a staff edit is never overwritten by a later import because the import only touches rows it owns.

Membership rows are inserted by D6, never by D5. hue_id is a foreign key to hues.id, and Section 6.13 establishes that no hues row exists until D6 creates it, so a D5 write here could only ever violate the constraint. D5's contribution is the proposal: it records the group keys its rule tables selected on extraction_candidates.metadata.hueGroups, and D6 turns that proposal into rows at the moment the hue itself becomes real (Section 7.7).

CREATE INDEX hue_group_members_group_idx ON hue_group_members (hue_group_id);
-- Supports "list all hues in group X" for the hue picker (Section 11.6).

Typical row count: roughly equal to active hues (most hues belong to exactly one group).

6.16 tags #

Catalog facets for browse/search filtering (Sections 15.3 and 15.6).

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
tag_key TEXT not null Natural key, e.g. style-fantasy.
display_name TEXT not null Human label.
category TEXT not null One of style, era, event, source.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Unique: tag_key. Check: category IN ('style','era','event','source'). The API names this field tagKey and matches on the tag_key value; there is no separate short tag column. Typical row count: 20–60, curated by staff.

6.17 asset_tags #

Many-to-many join between assets and tags.

Column Type Null Default Description
asset_id CHAR(26) not null FK to assets.id.
tag_id CHAR(26) not null FK to tags.id.

Primary key: (asset_id, tag_id). Foreign keys: asset_idassets(id) ON DELETE CASCADE; tag_idtags(id) ON DELETE CASCADE.

CREATE INDEX asset_tags_tag_idx ON asset_tags (tag_id);
-- Supports "list all assets with tag X" for catalog facet filtering (Section 15.3).

An insert or delete here also refreshes the owning asset's search_tsv through the trigger defined in Section 6.30, so tag names stay searchable without a rebuild step.

Typical row count: 2–6× assets (an asset commonly carries several tags).

6.18 designs #

An immutable saved combination, per the canonical design JSON in Section 13.2.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
short_code TEXT not null 10-character Crockford Base32 short code (Section 13.4).
salt SMALLINT null Collision-resolution salt byte appended before hashing (Section 13.4.2). NULL means no salt byte was appended; 1255 are collision retries.
canonical_json TEXT not null The canonical design document, stored verbatim as produced by canonicalize() (Section 13.3.2); never re-serialized.
canonical_jsonb JSONB not null generated canonical_json::jsonb, a stored generated column, so the "designs like this" query in Section 13.8 can index and search inside the document without disturbing the byte-exact original.
build_id CHAR(26) not null FK to game_builds.id, the build this design was created against.
view_count BIGINT not null 0 Lifetime count of permalink views, incremented server-side by the bot-filtered write path in Section 13.10.1.
render_count BIGINT not null 0 Lifetime count of composite render requests, incremented server-side.
taken_down_at TIMESTAMPTZ null Set by the takedown process in Section 20.8; a non-null value makes every surface serve 410.
created_at TIMESTAMPTZ not null now() Row creation time; designs have no updated_at since they are immutable.

Primary key: id. Unique: short_code. Foreign key: build_idgame_builds(id) ON DELETE RESTRICT (a build must never be hard-deleted while designs reference it; see Section 6.33).

Checks: short_code ~ '^[0-9A-HJKMNP-TV-Z]{10}$' (exactly ten Crockford Base32 characters — the alphabet excludes I, L, O and U); salt IS NULL OR salt BETWEEN 1 AND 255.

salt is nullable and never 0, because "unsalted" and "salted with the byte 0x00" are two different hash inputs and would otherwise be indistinguishable on a stored row. Any code that re-derives a short code from canonical_json must append a salt byte if and only if salt IS NOT NULL.

canonical_json is TEXT, not JSONB, because Section 13.4.2's collision procedure compares the stored document byte-for-byte against a freshly canonicalized one. jsonb normalizes whitespace, reorders object keys and drops duplicates, so the value read back would never be the bytes that were hashed, and every re-share of an identical design would be misclassified as a true hash collision. The generated canonical_jsonb column exists so the queryable form is still available without sacrificing that guarantee.

taken_down_at is the only removal mechanism. Designs are never hard-deleted and there is no age-based or view-based pruning of this table; the reasoning and the storage-growth answer are in Section 6.33.

No free-text user-supplied field exists on this table by deliberate moderation decision: designs are combinations of catalog references only, never arbitrary strings, so there is no surface for abusive user text to appear on a permalink.

CREATE INDEX designs_build_id_idx ON designs (build_id);
-- Used by re-sync (Section 9.8) to find all designs pinned to a given build for hue/asset lookups.
CREATE INDEX designs_canonical_gin_idx ON designs USING GIN (canonical_jsonb jsonb_path_ops);
-- Serves Section 13.8's containment query on the permalink page without a sequential scan.
CREATE INDEX designs_created_at_idx ON designs (created_at DESC) WHERE taken_down_at IS NULL;
-- Serves the sitemap and the random-design endpoint (Sections 10.6 and 16.8.13).

Typical row count: unbounded growth, the largest table over time (every share action inserts one row); expect hundreds of thousands within the first year at moderate traffic.

6.19 design_renders #

Cache rows for server-composited images, per the cache-key scheme in Section 8.9. The table backs all three render URL shapes in Section 8.8, not only whole-design composites.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
render_kind TEXT not null One of design, asset, swatch.
design_id CHAR(26) null FK to designs.id; non-null exactly when render_kind = 'design'.
subject_ref TEXT not null What was rendered: the design's short_code, or the asset_key, or the slot_key plus hue index, depending on render_kind.
cache_key CHAR(64) not null sha256(buildHash + canonicalDesignJson + scale + format) for design renders, and the analogous per-subject digest for the other two kinds, per Section 8.9.
scale SMALLINT not null One of 1, 2, 3.
format TEXT not null One of webp, png.
storage_key TEXT not null Object storage key under the renders/ prefix.
byte_size INT not null Encoded file size in bytes.
content_hash CHAR(64) not null SHA-256 of the encoded bytes, so a read can detect a corrupt or truncated cache object (Section 8.14).
created_at TIMESTAMPTZ not null now() Row creation time.
last_served_at TIMESTAMPTZ null Updated on cache hit, for LRU-style storage pruning.

Primary key: id. Unique: cache_key; storage_key. Foreign key: design_iddesigns(id) ON DELETE CASCADE. Checks: render_kind IN ('design','asset','swatch'); (render_kind = 'design') = (design_id IS NOT NULL); scale IN (1,2,3); format IN ('webp','png').

design_id is nullable because asset and swatch renders have no owning design. The paired CHECK makes the discriminator honest in both directions, so a design row can never be orphaned and an asset row can never smuggle in a design reference.

The column is named storage_key, matching asset_images and og_images; no section uses object_key.

CREATE INDEX design_renders_design_id_idx ON design_renders (design_id) WHERE design_id IS NOT NULL;
-- Enumerates all cached render variants for a design, used by the takedown path (Section 20.8).
CREATE INDEX design_renders_last_served_idx ON design_renders (last_served_at NULLS FIRST);
-- Serves the regenerable-artifact purge runbook (Section 23.8).

Typical row count: up to 6× designs (3 scales × 2 formats), in practice lower since most permalinks are only ever viewed at one scale/format combination, plus one row per cached asset/swatch render.

6.20 og_images #

Generated Open Graph preview images for a design (Section 14.5).

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
design_id CHAR(26) not null FK to designs.id.
cache_key CHAR(64) not null Digest over the design composite's scale = 1, format = png render cache key and the OG template version, exactly as Section 14.5 computes it. Because that render key already folds in the build hash and the canonical JSON, both are covered transitively.
storage_key TEXT not null Object storage key under the og/ prefix.
byte_size INT not null Encoded PNG size in bytes.
content_hash CHAR(64) not null SHA-256 of the encoded file.
created_at TIMESTAMPTZ not null now() When the OG image was generated.
last_served_at TIMESTAMPTZ null Updated when a crawler fetches it; drives the pruning policy in Section 14.8.

Primary key: id. Unique: cache_key; storage_key. Foreign key: design_iddesigns(id) ON DELETE CASCADE.

There is deliberately no unique constraint on design_id: bumping the OG template version produces a second, differently-keyed row for the same design, and the old row stays until the pruning job removes it. Lookups are by cache_key.

CREATE INDEX og_images_design_idx ON og_images (design_id);
-- Enumerates every generated card for a design, used by the takedown path and the pruning job.

Typical row count: equal to the subset of designs that have been shared at least once and crawled, generated lazily per Section 14.5.

6.21 jobs #

The Postgres-backed background job queue (Section 4.7).

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
job_type TEXT not null One of render, og_render, import_stage, stat_rollup, notify, reimport_reminder, prune.
payload JSONB not null '{}' Job-specific arguments.
dedupe_key TEXT null Optional collapse key; at most one non-terminal job may hold a given value.
priority SMALLINT not null 0 Higher runs first among jobs whose run_after has passed.
status TEXT not null 'queued' One of queued, running, succeeded, failed, cancelled (Section 6.2).
run_after TIMESTAMPTZ not null now() Earliest time this job may be claimed.
attempts INT not null 0 Number of claim attempts so far.
max_attempts INT not null 5 Attempts allowed before marking permanently failed.
claimed_at TIMESTAMPTZ null Set when a worker claims the job.
claimed_by TEXT null Worker process identifier holding the claim.
last_error TEXT null Error message from the most recent failed attempt.
created_at TIMESTAMPTZ not null now() Row creation time.
updated_at TIMESTAMPTZ not null now() Last state change.

Primary key: id. Checks: job_type and status constrained to the enumerations above; attempts >= 0; max_attempts > 0.

There is no done status and no pending status. A finished job is succeeded; a job waiting to run is queued. Any write that sets another value violates the CHECK.

dedupe_key is what makes the render mutex in Section 8.10 work: a request that wants a render inserts a render job keyed on the render's cache_key with ON CONFLICT DO NOTHING, and the partial unique index below is the arbiter, so a thundering herd on a viral permalink produces exactly one render.

CREATE UNIQUE INDEX jobs_dedupe_key_idx ON jobs (dedupe_key)
  WHERE dedupe_key IS NOT NULL AND status IN ('queued','running');
-- The ON CONFLICT arbiter: one live job per dedupe key, freed as soon as the job reaches a terminal state.
CREATE INDEX jobs_claim_idx ON jobs (priority DESC, run_after)
  WHERE status = 'queued';
-- The claim query: SELECT ... WHERE status = 'queued' AND run_after <= now()
--   ORDER BY priority DESC, run_after LIMIT n FOR UPDATE SKIP LOCKED, using this partial index.

Typical row count: low thousands transient (queued/running), with completed rows pruned per Section 6.33's retention policy.

6.22 admin_users #

Staff accounts.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
email TEXT not null Login email, stored lowercase.
password_hash TEXT not null Argon2id hash.
totp_secret TEXT not null Encrypted-at-rest TOTP shared secret.
role TEXT not null 'viewer' One of viewer, curator, owner (Section 6.2).
disabled_at TIMESTAMPTZ null Set when an account is disabled; a disabled account cannot authenticate.
last_login_at TIMESTAMPTZ null Updated on successful login.
created_at TIMESTAMPTZ not null now() Row creation time.
updated_at TIMESTAMPTZ not null now() Last modification time.

Primary key: id. Unique: email. Check: role IN ('viewer','curator','owner').

There are exactly three roles, ordered viewer < curator < owner; the per-action permission matrix that gives them meaning is Section 17.3.1, and this table stores nothing beyond the role name. An account is active when disabled_at IS NULL; there is no separate is_active flag, so the two can never disagree.

Rows are created only by the skinforge-cli admin user create command (Section 17.15); there is no public self-signup and no admin-console endpoint that creates a user without going through the CLI. Typical row count: under 20.

6.23 admin_sessions #

Server-side sessions backing the sf_admin cookie.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
admin_user_id CHAR(26) not null FK to admin_users.id.
session_token_hash CHAR(64) not null SHA-256 of the opaque session token; the raw token is only ever in the cookie.
expires_at TIMESTAMPTZ not null Absolute expiry, set from SKINFORGE_ADMIN_SESSION_TTL_HOURS (Section 24.2).
last_seen_at TIMESTAMPTZ not null now() Updated on every authenticated request; backs the idle timeout in Section 17.1.
revoked_at TIMESTAMPTZ null Set on explicit logout or forced revocation.
ip_address INET null Client IP at session creation, for audit.
user_agent TEXT null Client user agent at session creation.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Unique: session_token_hash. Foreign key: admin_user_idadmin_users(id) ON DELETE CASCADE.

CREATE INDEX admin_sessions_user_active_idx
  ON admin_sessions (admin_user_id) WHERE revoked_at IS NULL;
-- Supports "list my active sessions" and bulk revoke-all-sessions-for-user.

Typical row count: low, one to a few per active staff member; expired/revoked rows pruned per Section 6.33.

6.24 admin_recovery_codes #

One-time TOTP recovery codes.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
admin_user_id CHAR(26) not null FK to admin_users.id.
code_hash CHAR(64) not null SHA-256 of the one-time recovery code.
used_at TIMESTAMPTZ null Set the first time the code is redeemed; codes are single-use.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Foreign key: admin_user_idadmin_users(id) ON DELETE CASCADE. Ten rows are generated per admin user at account creation and at explicit staff-initiated regeneration.

The hash is SHA-256, matching Section 17.2.2, not a slow KDF: a recovery code carries 50 bits of entropy, verification costs ten hashes per attempt, and the login-failure lockout in Section 17.2.3 bounds attempts anyway.

6.25 audit_log #

Append-only record of every state-changing admin or pipeline action.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
actor_id CHAR(26) null FK to admin_users.id; null for system/scheduled actions.
actor_label TEXT not null Denormalized email or system:<job_type> at time of action, survives actor deletion.
action TEXT not null Action key, e.g. build.publish, asset.retire, candidate.approve.
entity_type TEXT not null Entity table name affected.
entity_id TEXT null Entity id or natural key affected.
before JSONB null Entity state before the action, when applicable.
after JSONB null Entity state after the action, when applicable.
reason TEXT null Free-text justification, required for rejections and takedown actions.
ip_address INET null Client IP of the acting staff session; null for system actions.
user_agent TEXT null Client user agent of the acting staff session; null for system actions.
metadata JSONB not null '{}' Action-specific structured extras (bulk-action batch id, affected counts, request id).
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Foreign key: actor_idadmin_users(id) ON DELETE SET NULL (the row survives account deletion via the denormalized actor_label).

CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, created_at DESC);
-- Powers "show history for this entity" on every admin detail screen (Section 17.13).
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC);
-- Powers "show this staff member's recent actions" (Section 17.13).

Typical row count: unbounded, append-only; the second-largest table over time after designs. Rows are retained indefinitely and never pruned (Section 6.33).

6.26 rate_limit_buckets #

Token-bucket state for the rate limits in Section 16.7.

Column Type Null Default Description
bucket_key TEXT not null Composite key, e.g. api:ip:203.0.113.4. Primary key.
tokens REAL not null Current token count.
updated_at TIMESTAMPTZ not null now() Last refill/consume time, used to compute elapsed-time refill.

Primary key: bucket_key. No foreign keys. Rows are upserted on every rate-limited request; stale rows (untouched for 24 hours) are pruned by a scheduled job (Section 6.33). Typical row count: proportional to distinct recent client IPs, low thousands under normal traffic.

6.27 stat_counters #

Aggregate counters computed server-side, no PII.

Column Type Null Default Description
counter_key TEXT not null Counter name, e.g. designs_created_total, design_view:AB12CD34EF.
day DATE not null The UTC day the count belongs to, or the reserved sentinel 0001-01-01 for an all-time running total.
value BIGINT not null 0 Current counter value.
updated_at TIMESTAMPTZ not null now() Last increment time.

Primary key: (counter_key, day). This composite key is the conflict target every increment uses: INSERT INTO stat_counters (counter_key, day, value) VALUES ($1, current_date, 1) ON CONFLICT (counter_key, day) DO UPDATE SET value = stat_counters.value + 1, updated_at = now();.

Two shapes share the table. Per-day counters use the real UTC date and are pruned on the same 400-day window as page_view_daily (Section 6.33). All-time totals use the sentinel day 0001-01-01, are never pruned, and are the handful of counters the public stats endpoint reads. Row count is therefore bounded by (named counters × retained days) plus one sentinel row per counter.

6.28 page_view_daily #

Daily page-view aggregate per path, written server-side at request time, no cookies, no PII (Section 21.6).

Column Type Null Default Description
day DATE not null Calendar day (UTC).
path TEXT not null Normalized request path (query string stripped).
views BIGINT not null 0 Count of requests for that path on that day.

Primary key: (day, path). No foreign keys.

CREATE INDEX page_view_daily_day_idx ON page_view_daily (day);
-- Supports the admin dashboard's traffic-by-day rollup (Section 17.4).

Typical row count: distinct paths × days; bounded by pruning rows older than the retention window in Section 6.33.

6.29 takedown_requests #

Legal/rights takedown requests (Section 20.8).

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
design_id CHAR(26) null FK to designs.id, when the request targets a specific permalink.
asset_id CHAR(26) null FK to assets.id, when the request targets catalog art.
requester_email TEXT not null Contact email supplied by the requester.
reason TEXT not null Requester's stated reason.
status TEXT not null 'new' One of new, triaging, upheld, rejected, withdrawn (Section 6.2).
resolution_note TEXT null Staff note on how the request was resolved.
handled_by CHAR(26) null FK to admin_users.id.
created_at TIMESTAMPTZ not null now() Row creation time.
resolved_at TIMESTAMPTZ null When the request reached upheld, rejected or withdrawn.

Primary key: id. Foreign keys: design_iddesigns(id) ON DELETE SET NULL; asset_idassets(id) ON DELETE SET NULL; handled_byadmin_users(id) ON DELETE SET NULL. Check: status IN ('new','triaging','upheld','rejected','withdrawn').

upheld is the only status that changes anything on the public site: it sets designs.taken_down_at (or retires the asset), after which every surface serves 410 per Section 20.8. Typical row count: low, expected to stay under a few hundred for the life of the product.

Three further tables — admin_pending_mfa, admin_login_failures and app_settings — are specified in Sections 6.35, 6.36 and 6.37; they appear in the DDL below in dependency order alongside the rest of the schema.

6.30 Full DDL — db/migrations/0001_init.sql #

The file contains no BEGIN;/COMMIT;. The migration runner (Section 6.32) opens and commits one transaction per file; a file that opened its own would leave the runner's bookkeeping insert outside it.

-- 0001_init.sql
-- SkinForge initial schema. Applied by the Deno migration runner (Section 6.32).
-- The runner owns the transaction; this file must not contain BEGIN or COMMIT.

-- pg_trgm backs the similarity half of catalog search (Section 15.6). Creating an extension
-- requires a superuser or a role with rds_superuser-equivalent rights; the first-time
-- provisioning runbook (Section 23.4) grants it before the first migration runs.
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE schema_migrations (
  filename    TEXT PRIMARY KEY,
  checksum    CHAR(64) NOT NULL,
  applied_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE FUNCTION set_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

CREATE TABLE admin_users (
  id              CHAR(26) PRIMARY KEY,
  email           TEXT NOT NULL UNIQUE,
  password_hash   TEXT NOT NULL,
  totp_secret     TEXT NOT NULL,
  role            TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer','curator','owner')),
  disabled_at     TIMESTAMPTZ,
  last_login_at   TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER admin_users_set_updated_at
  BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION set_updated_at();

CREATE TABLE admin_sessions (
  id                  CHAR(26) PRIMARY KEY,
  admin_user_id       CHAR(26) NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
  session_token_hash  CHAR(64) NOT NULL UNIQUE,
  expires_at          TIMESTAMPTZ NOT NULL,
  last_seen_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  revoked_at          TIMESTAMPTZ,
  ip_address          INET,
  user_agent          TEXT,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX admin_sessions_user_active_idx
  ON admin_sessions (admin_user_id) WHERE revoked_at IS NULL;

CREATE TABLE admin_recovery_codes (
  id             CHAR(26) PRIMARY KEY,
  admin_user_id  CHAR(26) NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
  code_hash      CHAR(64) NOT NULL,
  used_at        TIMESTAMPTZ,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE admin_pending_mfa (
  id             CHAR(26) PRIMARY KEY,
  admin_user_id  CHAR(26) NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
  token_hash     CHAR(64) NOT NULL UNIQUE,
  attempts       SMALLINT NOT NULL DEFAULT 0 CHECK (attempts >= 0),
  expires_at     TIMESTAMPTZ NOT NULL,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE admin_login_failures (
  id               CHAR(26) PRIMARY KEY,
  admin_user_id    CHAR(26) REFERENCES admin_users(id) ON DELETE CASCADE,
  email_attempted  TEXT NOT NULL,
  ip_address       INET,
  phase            TEXT NOT NULL CHECK (phase IN ('password','totp','recovery_code')),
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX admin_login_failures_email_time_idx
  ON admin_login_failures (email_attempted, created_at DESC);
CREATE INDEX admin_login_failures_ip_time_idx
  ON admin_login_failures (ip_address, created_at DESC);

CREATE TABLE app_settings (
  setting_key  TEXT PRIMARY KEY
                 CHECK (setting_key IN ('admin_ip_allowlist','maintenance_mode',
                                        'takedown_response_template','featured_designs',
                                        'designs_like_this_threshold','reimport_reminder_cadence_days')),
  value        JSONB NOT NULL,
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_by   CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL
);

CREATE TABLE audit_log (
  id           CHAR(26) PRIMARY KEY,
  actor_id     CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
  actor_label  TEXT NOT NULL,
  action       TEXT NOT NULL,
  entity_type  TEXT NOT NULL,
  entity_id    TEXT,
  before       JSONB,
  after        JSONB,
  reason       TEXT,
  ip_address   INET,
  user_agent   TEXT,
  metadata     JSONB NOT NULL DEFAULT '{}',
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, created_at DESC);
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC);

CREATE TABLE game_builds (
  id                      CHAR(26) PRIMARY KEY,
  label                   TEXT NOT NULL UNIQUE,
  status                  TEXT NOT NULL DEFAULT 'draft'
                            CHECK (status IN ('draft','in_review','published','archived','rolled_back')),
  build_hash              CHAR(64),
  source_client_version   TEXT,
  notes                   TEXT,
  created_by              CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
  published_at            TIMESTAMPTZ,
  published_by            CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
  archived_at             TIMESTAMPTZ,
  created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at              TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX game_builds_published_singleton
  ON game_builds ((1)) WHERE status = 'published';
CREATE TRIGGER game_builds_set_updated_at
  BEFORE UPDATE ON game_builds FOR EACH ROW EXECUTE FUNCTION set_updated_at();

CREATE TABLE source_files (
  id              CHAR(26) PRIMARY KEY,
  build_id        CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE CASCADE,
  relative_path   TEXT NOT NULL,
  file_type       TEXT NOT NULL
                    CHECK (file_type IN ('mul','idx','uop','def','txt','image','archive','unknown')),
  size_bytes      BIGINT NOT NULL,
  sha256          CHAR(64) NOT NULL,
  magic_bytes     TEXT,
  entropy         REAL,
  discovered_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (build_id, relative_path)
);
CREATE INDEX source_files_build_type_idx ON source_files (build_id, file_type);

CREATE TABLE import_runs (
  id            CHAR(26) PRIMARY KEY,
  build_id      CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE CASCADE,
  stage         TEXT NOT NULL
                  CHECK (stage IN ('inventory','identify','probe','extract','classify','normalize',
                                    'import','verify')),
  status        TEXT NOT NULL DEFAULT 'queued'
                  CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
  started_at    TIMESTAMPTZ,
  finished_at   TIMESTAMPTZ,
  triggered_by  CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
  summary       JSONB NOT NULL DEFAULT '{}',
  error         TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX import_runs_build_stage_idx ON import_runs (build_id, stage, created_at DESC);

CREATE TABLE import_run_items (
  id               CHAR(26) PRIMARY KEY,
  import_run_id    CHAR(26) NOT NULL REFERENCES import_runs(id) ON DELETE CASCADE,
  source_file_id   CHAR(26) REFERENCES source_files(id) ON DELETE SET NULL,
  item_type        TEXT NOT NULL,
  status           TEXT NOT NULL CHECK (status IN ('ok','skipped','failed','needs_operator_input')),
  detail           JSONB NOT NULL DEFAULT '{}',
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX import_run_items_run_status_idx ON import_run_items (import_run_id, status);

CREATE TABLE slots (
  slot_key            TEXT PRIMARY KEY,
  display_name        TEXT NOT NULL,
  z_order             INT NOT NULL UNIQUE,
  display_order       INT NOT NULL UNIQUE,
  uo_layer            INT,
  hueable             BOOLEAN NOT NULL DEFAULT true,
  gender_scope        TEXT NOT NULL DEFAULT 'both' CHECK (gender_scope IN ('both','male','female')),
  is_required         BOOLEAN NOT NULL DEFAULT false,
  is_always_visible   BOOLEAN NOT NULL DEFAULT false,
  visible             BOOLEAN NOT NULL DEFAULT true,
  hue_swatch_crop     TEXT,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE assets (
  id               CHAR(26) PRIMARY KEY,
  asset_key        TEXT NOT NULL UNIQUE,
  slot_key         TEXT NOT NULL REFERENCES slots(slot_key) ON DELETE RESTRICT,
  display_name     TEXT NOT NULL,
  display_order    INT NOT NULL DEFAULT 0,
  source_file_id   CHAR(26) REFERENCES source_files(id) ON DELETE SET NULL,
  source_offset    BIGINT,
  gump_id          INT,
  partial_hue      BOOLEAN NOT NULL DEFAULT false,
  search_tsv       tsvector,
  first_build_id   CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
  last_build_id    CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
  retired_at       TIMESTAMPTZ,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT assets_asset_key_shape
    CHECK (asset_key ~ '^[a-z0-9]+([-_.][a-z0-9]+)*$' AND length(asset_key) <= 128)
);
CREATE INDEX assets_slot_key_idx ON assets (slot_key, display_order) WHERE retired_at IS NULL;
CREATE INDEX assets_gump_id_idx ON assets (gump_id) WHERE gump_id IS NOT NULL;
CREATE INDEX assets_search_tsv_idx ON assets USING GIN (search_tsv);
CREATE INDEX assets_display_name_trgm_idx ON assets USING GIN (display_name gin_trgm_ops);
CREATE TRIGGER assets_set_updated_at
  BEFORE UPDATE ON assets FOR EACH ROW EXECUTE FUNCTION set_updated_at();

CREATE TABLE extraction_candidates (
  id                   CHAR(26) PRIMARY KEY,
  import_run_id        CHAR(26) NOT NULL REFERENCES import_runs(id) ON DELETE CASCADE,
  source_file_id       CHAR(26) REFERENCES source_files(id) ON DELETE SET NULL,
  candidate_key        TEXT NOT NULL,
  suggested_slot_key   TEXT REFERENCES slots(slot_key) ON DELETE SET NULL,
  suggested_body       TEXT CHECK (suggested_body IN ('m','f')),
  confidence           REAL NOT NULL DEFAULT 0 CHECK (confidence BETWEEN 0 AND 1),
  status               TEXT NOT NULL DEFAULT 'pending'
                         CHECK (status IN ('pending','needs_operator_input','approved','rejected')),
  image_ref            TEXT,
  metadata             JSONB NOT NULL DEFAULT '{}',
  reviewed_by          CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
  reviewed_at          TIMESTAMPTZ,
  created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (import_run_id, candidate_key)
);
CREATE INDEX extraction_candidates_status_confidence_idx
  ON extraction_candidates (status, confidence DESC);

CREATE TABLE asset_variants (
  id           CHAR(26) PRIMARY KEY,
  asset_id     CHAR(26) NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
  build_id     CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
  body         TEXT NOT NULL CHECK (body IN ('m','f')),
  width        INT NOT NULL CHECK (width > 0),
  height       INT NOT NULL CHECK (height > 0),
  offset_x     INT NOT NULL,
  offset_y     INT NOT NULL,
  hue_mode     TEXT NOT NULL DEFAULT 'full' CHECK (hue_mode IN ('full','partial')),
  sha256       CHAR(64) NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (asset_id, body, build_id)
);
CREATE INDEX asset_variants_sha256_idx ON asset_variants (sha256);
CREATE INDEX asset_variants_build_idx ON asset_variants (build_id);

CREATE TABLE asset_images (
  id                  CHAR(26) PRIMARY KEY,
  asset_variant_id    CHAR(26) NOT NULL REFERENCES asset_variants(id) ON DELETE CASCADE,
  scale               SMALLINT NOT NULL CHECK (scale IN (1,2,3)),
  format              TEXT NOT NULL CHECK (format IN ('webp','png')),
  storage_key         TEXT NOT NULL,
  byte_size           INT NOT NULL,
  content_hash        CHAR(64) NOT NULL,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (asset_variant_id, scale, format)
);
CREATE INDEX asset_images_storage_key_idx ON asset_images (storage_key);

CREATE TABLE hues (
  id                    CHAR(26) PRIMARY KEY,
  hue_index             INT NOT NULL,
  source                TEXT NOT NULL CHECK (source IN ('base','outlands','derived')),
  name                  TEXT NOT NULL,
  colors                INTEGER[32] NOT NULL CHECK (array_length(colors,1) = 32),
  table_start           INT NOT NULL,
  table_end             INT NOT NULL,
  swatch_color          INTEGER NOT NULL,
  swatch_override_argb  INTEGER,
  partial               BOOLEAN NOT NULL DEFAULT false,
  build_id              CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
  retired_at            TIMESTAMPTZ,
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (hue_index, build_id)
);
CREATE INDEX hues_source_idx ON hues (source) WHERE retired_at IS NULL;

CREATE TABLE hue_groups (
  id            CHAR(26) PRIMARY KEY,
  group_key     TEXT NOT NULL UNIQUE,
  display_name  TEXT NOT NULL,
  sort_order    INT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE hue_group_members (
  hue_id        CHAR(26) NOT NULL REFERENCES hues(id) ON DELETE CASCADE,
  hue_group_id  CHAR(26) NOT NULL REFERENCES hue_groups(id) ON DELETE CASCADE,
  method        TEXT NOT NULL DEFAULT 'manual' CHECK (method IN ('manual','heuristic')),
  assigned_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (hue_id, hue_group_id)
);
CREATE INDEX hue_group_members_group_idx ON hue_group_members (hue_group_id);

CREATE TABLE tags (
  id             CHAR(26) PRIMARY KEY,
  tag_key        TEXT NOT NULL UNIQUE,
  display_name   TEXT NOT NULL,
  category       TEXT NOT NULL CHECK (category IN ('style','era','event','source')),
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE asset_tags (
  asset_id  CHAR(26) NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
  tag_id    CHAR(26) NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (asset_id, tag_id)
);
CREATE INDEX asset_tags_tag_idx ON asset_tags (tag_id);

-- Full-text search vector, maintained by trigger. A GENERATED column cannot be used here:
-- PostgreSQL rejects a generation expression containing a subquery or a cross-table reference,
-- and the vector must include tag names from asset_tags.
--
-- These two functions and three triggers are defined here and nowhere else. Section 15.6 owns the
-- *query* that reads assets.search_tsv and the weighting rationale; it does not redeclare the
-- trigger. Both triggers fire AFTER, not BEFORE: the refresh function re-reads the row through a
-- sub-select, so on BEFORE INSERT the row would not yet be visible and every newly imported asset
-- would land with a NULL vector. The refresh writes only search_tsv, while the assets trigger is
-- scoped to UPDATE OF display_name, asset_key -- so the write cannot re-fire the trigger and the
-- recursion terminates after one pass.
-- tags columns are (id, tag_key, display_name, category, created_at): the vector reads
-- tags.display_name. There is no tags.name and no tags.slug.
CREATE FUNCTION assets_search_tsv_refresh(target_asset_id CHAR(26)) RETURNS void
LANGUAGE sql AS $$
  UPDATE assets a
     SET search_tsv =
           setweight(to_tsvector('english', coalesce(a.display_name, '')), 'A')
        || setweight(to_tsvector('english', replace(coalesce(a.asset_key, ''), '.', ' ')), 'B')
        || setweight(to_tsvector('english', coalesce((
             SELECT string_agg(t.display_name, ' ')
               FROM asset_tags xt
               JOIN tags t ON t.id = xt.tag_id
              WHERE xt.asset_id = a.id), '')), 'C')
   WHERE a.id = target_asset_id;
$$;

CREATE FUNCTION assets_search_tsv_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  PERFORM assets_search_tsv_refresh(NEW.id);
  RETURN NULL;
END;
$$;
CREATE TRIGGER assets_search_tsv_aiu
  AFTER INSERT OR UPDATE OF display_name, asset_key ON assets
  FOR EACH ROW EXECUTE FUNCTION assets_search_tsv_trigger();

CREATE FUNCTION asset_tags_search_tsv_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  -- NEW is unassigned in a DELETE trigger, so it must not be referenced there at all.
  IF TG_OP = 'DELETE' THEN
    PERFORM assets_search_tsv_refresh(OLD.asset_id);
  ELSE
    PERFORM assets_search_tsv_refresh(NEW.asset_id);
  END IF;
  RETURN NULL;
END;
$$;
CREATE TRIGGER asset_tags_search_tsv_aid
  AFTER INSERT OR DELETE ON asset_tags
  FOR EACH ROW EXECUTE FUNCTION asset_tags_search_tsv_trigger();

CREATE TABLE designs (
  id               CHAR(26) PRIMARY KEY,
  short_code       TEXT NOT NULL UNIQUE
                     CHECK (short_code ~ '^[0-9A-HJKMNP-TV-Z]{10}$'),
  salt             SMALLINT CHECK (salt IS NULL OR salt BETWEEN 1 AND 255),
  canonical_json   TEXT NOT NULL,
  canonical_jsonb  JSONB NOT NULL GENERATED ALWAYS AS (canonical_json::jsonb) STORED,
  build_id         CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
  view_count       BIGINT NOT NULL DEFAULT 0,
  render_count     BIGINT NOT NULL DEFAULT 0,
  taken_down_at    TIMESTAMPTZ,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX designs_build_id_idx ON designs (build_id);
CREATE INDEX designs_canonical_gin_idx ON designs USING GIN (canonical_jsonb jsonb_path_ops);
CREATE INDEX designs_created_at_idx ON designs (created_at DESC) WHERE taken_down_at IS NULL;

CREATE TABLE design_renders (
  id               CHAR(26) PRIMARY KEY,
  render_kind      TEXT NOT NULL CHECK (render_kind IN ('design','asset','swatch')),
  design_id        CHAR(26) REFERENCES designs(id) ON DELETE CASCADE,
  subject_ref      TEXT NOT NULL,
  cache_key        CHAR(64) NOT NULL UNIQUE,
  scale            SMALLINT NOT NULL CHECK (scale IN (1,2,3)),
  format           TEXT NOT NULL CHECK (format IN ('webp','png')),
  storage_key      TEXT NOT NULL UNIQUE,
  byte_size        INT NOT NULL,
  content_hash     CHAR(64) NOT NULL,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_served_at   TIMESTAMPTZ,
  CONSTRAINT design_renders_kind_ref
    CHECK ((render_kind = 'design') = (design_id IS NOT NULL))
);
CREATE INDEX design_renders_design_id_idx ON design_renders (design_id) WHERE design_id IS NOT NULL;
CREATE INDEX design_renders_last_served_idx ON design_renders (last_served_at NULLS FIRST);

CREATE TABLE og_images (
  id               CHAR(26) PRIMARY KEY,
  design_id        CHAR(26) NOT NULL REFERENCES designs(id) ON DELETE CASCADE,
  cache_key        CHAR(64) NOT NULL UNIQUE,
  storage_key      TEXT NOT NULL UNIQUE,
  byte_size        INT NOT NULL,
  content_hash     CHAR(64) NOT NULL,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_served_at   TIMESTAMPTZ
);
CREATE INDEX og_images_design_idx ON og_images (design_id);

CREATE TABLE jobs (
  id             CHAR(26) PRIMARY KEY,
  job_type       TEXT NOT NULL
                   CHECK (job_type IN ('render','og_render','import_stage','stat_rollup',
                                       'notify','reimport_reminder','prune')),
  payload        JSONB NOT NULL DEFAULT '{}',
  dedupe_key     TEXT,
  priority       SMALLINT NOT NULL DEFAULT 0,
  status         TEXT NOT NULL DEFAULT 'queued'
                   CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
  run_after      TIMESTAMPTZ NOT NULL DEFAULT now(),
  attempts       INT NOT NULL DEFAULT 0 CHECK (attempts >= 0),
  max_attempts   INT NOT NULL DEFAULT 5 CHECK (max_attempts > 0),
  claimed_at     TIMESTAMPTZ,
  claimed_by     TEXT,
  last_error     TEXT,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX jobs_dedupe_key_idx ON jobs (dedupe_key)
  WHERE dedupe_key IS NOT NULL AND status IN ('queued','running');
CREATE INDEX jobs_claim_idx ON jobs (priority DESC, run_after) WHERE status = 'queued';
CREATE TRIGGER jobs_set_updated_at
  BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION set_updated_at();

CREATE TABLE rate_limit_buckets (
  bucket_key   TEXT PRIMARY KEY,
  tokens       REAL NOT NULL,
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE stat_counters (
  counter_key  TEXT NOT NULL,
  day          DATE NOT NULL,
  value        BIGINT NOT NULL DEFAULT 0,
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (counter_key, day)
);

CREATE TABLE page_view_daily (
  day     DATE NOT NULL,
  path    TEXT NOT NULL,
  views   BIGINT NOT NULL DEFAULT 0,
  PRIMARY KEY (day, path)
);
CREATE INDEX page_view_daily_day_idx ON page_view_daily (day);

CREATE TABLE takedown_requests (
  id                 CHAR(26) PRIMARY KEY,
  design_id          CHAR(26) REFERENCES designs(id) ON DELETE SET NULL,
  asset_id           CHAR(26) REFERENCES assets(id) ON DELETE SET NULL,
  requester_email    TEXT NOT NULL,
  reason             TEXT NOT NULL,
  status             TEXT NOT NULL DEFAULT 'new'
                       CHECK (status IN ('new','triaging','upheld','rejected','withdrawn')),
  resolution_note    TEXT,
  handled_by         CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  resolved_at        TIMESTAMPTZ
);

Cascade discipline. Two rules govern every foreign key above, and they are deliberately opposite:

  • Rows a permalink depends on use ON DELETE RESTRICTdesigns.build_id, asset_variants.build_id, hues.build_id, assets.first_build_id/last_build_id, assets.slot_key. A build or slot that designs still reference cannot be deleted at all.
  • Rows that are regenerable derived artifacts use ON DELETE CASCADEdesign_renders.design_id, og_images.design_id, asset_images.asset_variant_id. This is what lets the artifact-pruning runbook in Section 23.8 delete cached renders and OG cards without a manual cleanup step, and it is safe precisely because those rows can always be regenerated from the design and its pinned build.

6.31 Seed Data #

Seed data is applied by db/migrations/0002_seed.sql, which — like every migration file — contains no BEGIN;/COMMIT;. It runs in every environment (production, staging, dev) except the dev-only fixture build, which is gated behind SKINFORGE_ENV = 'development' at the application layer, not inside the SQL file — the migration runner applies every file unconditionally, and Section 6.32 explains why fixture data is instead seeded by a separate skinforge-cli db fixtures command, never a numbered migration.

Every literal id below is a valid 26-character Crockford Base32 ULID: the alphabet is 0123456789ABCDEFGHJKMNPQRSTVWXYZ, with I, L, O and U excluded so a hand-transcribed id cannot be confused with 1 or 0.

-- 0002_seed.sql
-- The runner owns the transaction; this file must not contain BEGIN or COMMIT.

INSERT INTO slots (slot_key, display_name, z_order, display_order, uo_layer, hueable, gender_scope, is_required, is_always_visible, visible) VALUES
  ('body',          'Body',              10,  10,  NULL, true,  'both',   true,  false, true),
  ('tattoo_body',   'Body Tattoo',       20,  20,  NULL, true,  'both',   false, false, true),
  ('footwear',      'Footwear',          30,  30,  3,    true,  'both',   false, false, true),
  ('legs_inner',    'Pants',             40,  40,  4,    true,  'both',   false, false, true),
  ('torso_inner',   'Shirt',             50,  50,  5,    true,  'both',   false, false, true),
  ('torso_middle',  'Chest Piece',       60,  60,  17,   true,  'both',   false, false, true),
  ('arms',          'Arms',              70,  70,  19,   true,  'both',   false, false, true),
  ('gloves',        'Gloves',            80,  80,  7,    true,  'both',   false, false, true),
  ('waist',         'Belt / Sash',       90,  90,  12,   true,  'both',   false, false, true),
  ('legs_outer',    'Skirt / Kilt',      100, 100, 23,   true,  'both',   false, false, true),
  ('torso_outer',   'Robe / Outer Torso',110, 110, 22,   true,  'both',   false, false, true),
  ('neck',          'Neck',              120, 120, 10,   true,  'both',   false, false, true),
  ('hair',          'Hair',              130, 130, 11,   true,  'both',   false, false, true),
  ('facial_hair',   'Beard',             140, 140, 16,   true,  'male',   false, false, true),
  ('face',          'Face Art',          150, 150, 15,   true,  'both',   false, false, true),
  ('earrings',      'Earrings',          160, 160, 18,   true,  'both',   false, false, true),
  ('head',          'Hat / Helm',        170, 170, 6,    true,  'both',   false, false, true),
  ('cloak',         'Cloak',             180, 180, 20,   true,  'both',   false, false, true),
  ('backpack',      'Backpack',          190, 190, 21,   true,  'both',   false, true,  true);

INSERT INTO hue_groups (id, group_key, display_name, sort_order) VALUES
  ('01J8Z3K9H0HGRPSKN000000001', 'skin',     'Skin Hues',      1),
  ('01J8Z3K9H0HGRPHA1R00000002', 'hair',     'Hair Hues',      2),
  ('01J8Z3K9H0HGRPTATT00000003', 'tattoo',   'Tattoo Hues',    3),
  ('01J8Z3K9H0HGRPCTH000000004', 'clothing', 'Clothing Hues',  4),
  ('01J8Z3K9H0HGRPEVNT00000005', 'event',    'Event Hues',     5);

INSERT INTO tags (id, tag_key, display_name, category) VALUES
  ('01J8Z3K9H1TAGSTYFANTASY001', 'style-fantasy', 'Fantasy',       'style'),
  ('01J8Z3K9H1TAGSTYR0GVE00002', 'style-rogue',   'Rogue',         'style'),
  ('01J8Z3K9H1TAGSTYN0B1E00003', 'style-noble',   'Noble',         'style'),
  ('01J8Z3K9H1TAGSTYRSTC000004', 'style-rustic',  'Rustic',        'style'),
  ('01J8Z3K9H1TAGERA0R1G1N0005', 'era-original',  'Original UO',   'era'),
  ('01J8Z3K9H1TAGERA0VT1ANDS06', 'era-outlands',  'Outlands',      'era'),
  ('01J8Z3K9H1TAGSRCSH0P000007', 'source-shop',   'Item Shop',     'source'),
  ('01J8Z3K9H1TAGSRCDR0P000008', 'source-drop',   'In-World Drop', 'source');

INSERT INTO app_settings (setting_key, value) VALUES
  ('admin_ip_allowlist',             '[]'),
  ('maintenance_mode',               'false'),
  ('takedown_response_template',     '""'),
  ('featured_designs',               '[]'),
  ('designs_like_this_threshold',    '2'),
  ('reimport_reminder_cadence_days', '30');

The 19 seeded slots are the whole registry: one required slot (body) and 18 choosable cosmetic slots, backpack among them. backpack is is_always_visible = true because a paperdoll always draws one, and it is nonetheless choosable — the visitor may change its asset and its hue — which is why the design document in Section 13.2 may carry a backpack entry like any other choosable slot, and why omitting it falls back to backpack.default at hue 0 rather than rendering nothing. That default asset key is produced by the pipeline (Section 7.8) and guaranteed present in every published build.

is_always_visible is about the rendered paperdoll, not about the stored document: the backpack is always drawn, but the entry that names which backpack is optional, exactly like every other choosable slot.

A dev-only fixture build is provided by skinforge-cli db fixtures, which runs a Deno script (not raw SQL) at db/seed/dev-fixtures.ts — the path Section 5.1 declares, and the only name for it — that inserts one game_builds row with label = 'dev-fixture-001' and status = 'published', a handful of assets/asset_variants/asset_images rows pointing at placeholder sprites checked into tests/fixtures/sprites/, and 16 representative hues rows spanning all five hue groups, each with its hue_group_members rows. The script generates every id with the application's own ULID generator, so fixture rows satisfy the same 26-character shape as production rows. It refuses to run when SKINFORGE_ENV is not development, to guarantee fixture data never reaches a production database.

6.32 Migration Policy and Runner #

Migration files live in db/migrations/, named NNNN_snake_case_description.sql with a zero-padded, strictly increasing four-digit sequence number. NNNN is never reused, even if a migration is later found to be wrong; a correction ships as a new migration with the next number.

A migration file never contains BEGIN; or COMMIT;. The runner owns transaction boundaries, and a file that opened its own transaction would commit the schema change before the runner recorded it, leaving schema_migrations and the actual schema able to disagree after a crash.

The Deno migration runner (skinforge-cli db migrate, implemented at cli/commands/db-migrate.ts, sharing its DB connection module with core/db/) does the following, in order, inside a single transaction per file:

  1. Connect using SKINFORGE_DATABASE_URL (Section 24.2).
  2. Ensure schema_migrations exists (bootstrap CREATE TABLE IF NOT EXISTS before the transactional loop, since the table cannot track its own creation).
  3. List db/migrations/*.sql sorted lexically (equivalent to numeric order given the zero-padded prefix).
  4. For each file not present in schema_migrations, in ascending order: open a transaction, run the file's SQL verbatim, insert a schema_migrations row with the file's SHA-256 checksum, commit.
  5. If any file fails, roll back that file's transaction, leave all prior files applied, print the failing filename and the database error, and exit non-zero. The runner never applies a later file after an earlier one fails.
  6. On every run, for files already recorded in schema_migrations, verify the stored checksum still matches the on-disk file; a mismatch aborts immediately with a checksum-drift error, since it means a migration was edited after being applied — the fix is always a new migration, never an edit.

Reversible-change rules: a migration must never be written as a destructive DROP COLUMN or DROP TABLE in the same release that also removes the application code reading that column, because a zero-downtime rolling deploy briefly runs old and new code against the same database. The pattern is: release N adds the new column/table (never dropping anything old); release N+1 switches application code to the new shape; release N+2 (a later migration file) drops the now-unused old column/table. Column additions must either be nullable or carry a DEFAULT, since adding a NOT NULL column without a default locks and rewrites the table. Renaming a column is always two migrations: add the new column and backfill in a batched UPDATE, then drop the old column in a later migration once application code no longer references it.

Zero-downtime rules for indexes: every CREATE INDEX on a table expected to hold more than a few thousand rows at migration time (designs, audit_log, import_run_items) uses CREATE INDEX CONCURRENTLY, which cannot run inside the runner's per-file transaction; such migrations are marked with a -- concurrent comment on the first line, and the runner detects that marker and runs the file outside a transaction, in autocommit mode, for that one file only. 0001_init.sql is exempt: it creates every table empty, so its indexes build instantly.

6.33 Data Lifecycle and Retention #

Table Growth pattern Retention rule
designs Unbounded, grows forever Never deleted. There is no age-based, view-based or size-based pruning of this table. The only removal path is the takedown process in Section 20.8, which sets taken_down_at and serves 410 while leaving the row in place. Permalink immutability depends on this, and the privacy text published at /legal (Section 20.7) promises it.
design_renders, og_images Unbounded, grows with traffic Regenerable derived artifacts. The purge runbook in Section 23.8 deletes renders/ objects and their rows once last_served_at is older than 90 days (or null and created_at older than 90 days); an OG card older than 90 days with no recent service is pruned the same way. Both regenerate on the next request, so a purge is never data loss.
assets, asset_variants, asset_images, hues Grows per build, soft-retired Never hard-deleted; retired_at hides an asset from catalog/designer while keeping every historical render working. asset_variants accumulates one row per (asset, body, build); that growth is the mechanism of immutability, not a leak.
source_files, import_runs, import_run_items, extraction_candidates Grows per import run Retained 2 years for provenance/audit. A scheduled prune job then deletes rows belonging to game_builds whose created_at is older than 2 years and whose status IN ('archived','rolled_back'), deleting from these four pipeline tables directly, oldest build first. The game_builds row itself is never deleted: designs, assets and hues reference it ON DELETE RESTRICT, so a build delete could never succeed and attempting one would only produce a failing job every night.
audit_log Append-only, unbounded Never deleted; this is the compliance record for admin and pipeline actions, and Section 20.8's legal posture requires it to be permanent.
jobs Transient Rows with status IN ('succeeded','cancelled') older than 7 days are deleted by the scheduled prune job; failed rows are kept 90 days for triage.
admin_sessions Transient Rows with expires_at < now() or revoked_at IS NOT NULL older than 30 days are deleted by a scheduled job.
admin_pending_mfa Transient Rows with expires_at < now() are deleted on every login attempt and by the scheduled job; nothing survives more than a few minutes.
admin_login_failures Transient Rows older than 30 days are deleted by a scheduled job; the lockout windows in Section 17.2.3 are minutes long, so nothing older is ever read.
rate_limit_buckets Transient Rows with updated_at older than 24 hours are deleted by a scheduled job. This 24-hour figure is the one the privacy statement in Section 20.7 must quote.
page_view_daily Grows daily Rows older than 400 days are deleted by a scheduled job (13 months of history retained for year-over-year comparison).
stat_counters Grows daily for per-day rows Per-day rows older than 400 days are deleted alongside page_view_daily; the all-time sentinel rows (day = 0001-01-01) are never deleted.
slots, hue_groups, tags, app_settings Static No pruning; row counts are bounded by design.
game_builds, takedown_requests, admin_users, admin_recovery_codes Slow, bounded growth No automatic pruning.

Permalink immutability is the reason catalog rows are never hard-deleted: a designs row references assets and hues only by the build_id it was created against, and Sections 9.7 and 9.8 guarantee that a retired asset or a superseded hue table still renders exactly as it did at design-creation time. Deleting the underlying assets, asset_variants, asset_images or hues rows would break every design ever created against that build, which is the one outcome the schema is designed to prevent.

The corollary matters just as much: storage growth is bounded by pruning regenerable artifacts, never by pruning designs. A design row is a few hundred bytes; its cached renders are hundreds of kilobytes. Deleting the renders reclaims essentially all of the space and costs only a re-render on the next view, whereas deleting the design would break a link somebody has already shared.

6.34 Backup and Restore Expectations #

The database is the single source of truth for every table above. Object storage (Section 4.7) holds image bytes under four prefixes — catalog/, renders/, og/ and imports/ — of which catalog/ and og/ are backed up and renders//imports/ are excluded as regenerable or scratch. The content_hash columns on asset_images, design_renders and og_images let a restore verify that restored object storage still matches the database's expectation.

Backup requirements at the data level (operational mechanics — schedule, tooling, retention location — are Section 23.6's concern):

  • Full logical backups (pg_dump) must be taken at an interval no coarser than daily, and must succeed before being counted; a failed backup triggers an alert (Section 21.5).
  • Point-in-time recovery must be possible to within 5 minutes of any point in the last 7 days, which requires continuous WAL archiving in addition to daily logical dumps, and therefore requires WAL segments to be retained for at least those 7 days (Section 23.6 states the operational retention).
  • A restore is validated periodically by restoring into a scratch database and running: SELECT count(*) FROM schema_migrations matches the expected migration count; SELECT count(*) FROM designs is non-zero and non-decreasing versus the prior validation; and one asset_images row's storage_key is fetched from object storage and its bytes hash to the row's content_hash, proving database and object storage stayed consistent. asset_images is the right table to sample because catalog/ is backed up; sampling design_renders would fail legitimately on any object the purge job has reclaimed.
  • Object storage itself (the fs or s3 driver, Section 4.7) must be backed up or replicated independently of the database; a database-only restore without matching object storage produces a site that returns correct JSON but broken images, which the restore validation step above is designed to catch before it reaches production.
  • Because designs and audit_log are permanent, append-only records, a restore must never be treated as an opportunity to prune them; retention rules (Section 6.33) are enforced only by the scheduled cleanup jobs, never by excluding tables from a backup.

The catalog/ objects a restore validates are the content-addressed keys described in Section 6.12; because those keys never change once written, a restored bucket and a restored database agree or the content_hash check fails loudly, with no third possibility.

6.35 admin_pending_mfa #

A login that has passed the password step and is waiting for a TOTP or recovery code (Section 17.2.1).

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
admin_user_id CHAR(26) not null FK to admin_users.id.
token_hash CHAR(64) not null SHA-256 of the opaque token carried in the short-lived sf_admin_pending cookie.
attempts SMALLINT not null 0 TOTP attempts made against this pending login.
expires_at TIMESTAMPTZ not null Absolute expiry, five minutes after creation.
created_at TIMESTAMPTZ not null now() Row creation time.

Primary key: id. Unique: token_hash. Foreign key: admin_user_idadmin_users(id) ON DELETE CASCADE. Rows are deleted on successful login and pruned on expiry (Section 6.33). Typical row count: zero to a handful at any moment.

6.36 admin_login_failures #

The rolling record the lockout policy in Section 17.2.3 counts.

Column Type Null Default Description
id CHAR(26) not null ULID surrogate key.
admin_user_id CHAR(26) null FK to admin_users.id; null when the submitted email matched no account.
email_attempted TEXT not null The lowercase email that was submitted.
ip_address INET null Client IP of the attempt.
phase TEXT not null One of password, totp, recovery_code.
created_at TIMESTAMPTZ not null now() When the failure occurred.

Primary key: id. Foreign key: admin_user_idadmin_users(id) ON DELETE CASCADE. Check: phase IN ('password','totp','recovery_code').

CREATE INDEX admin_login_failures_email_time_idx
  ON admin_login_failures (email_attempted, created_at DESC);
CREATE INDEX admin_login_failures_ip_time_idx
  ON admin_login_failures (ip_address, created_at DESC);
-- The two lockout counters in Section 17.2.3: per-account and per-IP, each over a rolling window.

Only failures are recorded; a successful login updates admin_users.last_login_at and writes an audit_log row instead. Rows older than 30 days are pruned (Section 6.33).

6.37 app_settings #

The small, explicitly enumerated set of operator-editable runtime settings the admin console writes (Section 17.14). Everything else about this system is configured by environment variable (Section 24.1); this table is the carved-out exception for the handful of values staff must be able to change without a redeploy.

Column Type Null Default Description
setting_key TEXT not null Setting name. Primary key.
value JSONB not null The setting's current value.
updated_at TIMESTAMPTZ not null now() Last change time.
updated_by CHAR(26) null FK to admin_users.id.

Primary key: setting_key. Foreign key: updated_byadmin_users(id) ON DELETE SET NULL. Check: setting_key IN ('admin_ip_allowlist','maintenance_mode','takedown_response_template', 'featured_designs','designs_like_this_threshold','reimport_reminder_cadence_days') — a fixed vocabulary, so an unknown key cannot be smuggled in and silently ignored.

Every write also writes an audit_log row. Fixed row count: six — the CHECK above is the complete list, and adding a seventh setting is a migration, not a configuration change.

The division of labour is stated once, in Section 24.1, and this table is its other half: environment variables configure the process at boot and are immutable for the life of that process, while these six values are read at request time through a cached accessor so an operator can change them from the admin console without a redeploy. No value appears in both places.

7. Asset Discovery & Extraction Pipeline #

7.1 Operating Model #

Extraction runs entirely on the operator's own machine or the same server that hosts the application, against the operator's own legally installed UO Outlands client. Nothing in this pipeline downloads game files from any third party, and the public web application never touches raw client files. End users of the public site never supply files, never see a file picker, and never interact with this pipeline in any form; everything in Section 7 is staff/operator tooling exposed through skinforge-cli (Section 4.3) and surfaced read-only in the admin console (Section 17.5).

The operator points the CLI at a local directory:

skinforge-cli import discover --client-dir /path/to/uo-outlands/client --build-label 2026-09-01-patch-47

This creates a game_builds row (Section 6.4) in status = 'draft' and begins Stage D1. Every later stage operates against that build_id and its findings end up in source_files, import_runs, import_run_items, and extraction_candidates (Sections 6.5–6.8). No stage mutates the operator's client directory; it is opened read-only throughout.

The work directory for a build (SKINFORGE_IMPORT_WORKDIR/<build_id>/, Section 24.2) holds all intermediate artifacts — decoded sprite buffers before normalization, decoded hue tables, the stage journal, manifest JSON, format notes — and is never web-accessible (Section 7.11).

Process model, and why it is split in two. Every byte this pipeline parses is untrusted input from a binary file format with no integrity guarantees. So the code that parses it is never the code that holds a network socket:

  • The extractor child. Each stage runs its decoding work in a child process launched with deno run --allow-read=<client-dir>,<workdir> --allow-write=<workdir> and no --allow-net flag at all. It cannot open a socket to anything — not the database, not object storage, not the internet. It reads client bytes, decodes them, and appends its findings as newline-delimited JSON to the stage journal under <workdir>/<build_id>/journal/, plus binary artifacts under extracted/ and normalized/.
  • The driver parent. skinforge-cli itself holds the database connection and the object-storage client, launched with --allow-net=<database-host>,<storage-endpoint> and nothing else. It tails the child's journal as it is written and mirrors each entry into source_files, import_runs, import_run_items and extraction_candidates, and uploads the encoded images the child wrote. This is why import_runs.summary reflects live progress (Section 7.10) even though the decoder itself cannot reach the database.

This split is what makes the isolation guarantee in Section 20.6 literally true rather than merely intended: a malicious or corrupt game file that achieves code execution inside the decoder lands in a process with no network permission and write access to one scratch directory.

7.2 The Six Stages #

The pipeline is six ordered stages, D1 through D6, each a separate CLI subcommand and a separate import_runs row (stage column, Section 6.6). A stage can be re-run independently once its predecessor has succeeded; each stage reads only the database rows and work-directory artifacts its predecessor produced, never the operator's client directory directly except D1, D2 and D4, which read raw client bytes.

D1D6 is prose shorthand used inside this section only. The value actually written to import_runs.stage, shown on every screen and used as every metric label, is the stage name from Section 6.6's eight-value vocabulary — inventory, identify, probe, extract, classify, normalize, plus the two ingestion stages import and verify that this section does not produce. Section 6.6 carries the full mapping; no surface outside this section ever writes or renders a D abbreviation.

Throughout this section, "records a source_files row" and "writes an import_run_items row" describe the outcome, not the mechanism: the decoding child appends the entry to the stage journal and the driver parent performs the actual INSERT (Section 7.1). The row shapes, conflict targets and status vocabularies are exactly those in Sections 6.5–6.8, and every write uses the natural key as its ON CONFLICT target so re-running a stage is idempotent (Section 9.9).

7.2.1 Stage D1 — Inventory #

Input: the operator's client directory path. Output: one source_files row per file found, and an import_runs row summarizing counts by file_type. Command: skinforge-cli import discover --client-dir <path> --build-label <label> (creates the build) or skinforge-cli import discover --build <build_id> --client-dir <path> (re-run D1 against an existing build).

D1 walks the directory tree recursively, and for every regular file:

  1. Records relative_path relative to the client directory root.
  2. Records size_bytes from the filesystem.
  3. Reads the first 4,096 bytes (or the whole file if smaller) and computes sha256 over the full file streamed in 64 KB chunks (never loading a multi-hundred-MB file fully into memory), magic_bytes as the hex of the first 16 bytes, and entropy (Shannon entropy in bits/byte) over the sampled prefix.
  4. Classifies file_type by extension first (.mulmul, .idxidx, .uopuop, .defdef, .txt/.cfgtxt, .png/.bmp/.tgaimage, .zip/.rar/.7zarchive), then corrects the classification using magic bytes when the extension is missing or ambiguous (e.g. a MYP\0 magic reclassifies any extension as uop).
  5. Inserts the source_files row.

Failure modes: unreadable file (permission denied) is recorded as an import_run_items row with status = 'failed' and the OS error in detail, and D1 continues past it — a single unreadable file never aborts inventory. A client directory that does not exist or is empty aborts the whole run immediately with a non-zero CLI exit code, since that indicates operator error, not pipeline risk.

7.2.2 Stage D2 — Identify #

Input: source_files rows from D1. Output: an import_run_items row per file recording its identified structure (or "unidentified"), feeding D3 for anything unidentified. Command: skinforge-cli import identify --build <build_id>.

D2 probes every mul, idx, uop, and unknown-typed file against the known UO structures from Section 7.3, in this order, stopping at the first match:

  1. MUL/IDX pair: file has a same-stem .idx companion (or is itself a .idx with a same-stem .mul) and the .idx file's size is an exact multiple of 12 bytes → identified as mul_idx_pair.
  2. UOP MythicPackage: first 4 bytes equal 4D 59 50 00 (MYP\0) → identified as uop.
  3. hues.mul: filename is exactly hues.mul (case-insensitive) or file size is an exact multiple of 708 bytes (4-byte header + 8 × 88-byte hue entries per group, Section 7.3.3) → identified as hues_table.
  4. tiledata.mul: filename is exactly tiledata.mul → identified as tiledata.
  5. .def/.txt control files: matched by filename against a fixed dictionary (body.def, bodyconv.def, mobtypes.txt) → identified as control_file with a kind sub-field.
  6. Loose image: file_type = 'image' from D1 → identified as loose_image.
  7. Anything not matched by 1–6 is left status = 'needs_operator_input' and queued for D3.

Each identification is written as an import_run_items row with item_type = 'identify' and detail.structure set to the matched structure name.

7.2.3 Stage D3 — Probe Unknown Containers #

Input: files D2 left needs_operator_input. Output: extraction_candidates rows of a special candidate_key prefix probe: describing what D3 learned, still status = 'needs_operator_input' unless a later heuristic upgrades it. Command: skinforge-cli import probe --build <build_id>.

For every unidentified file, D3 runs, in order:

  1. Header fingerprinting: compares the first 32 bytes against a signature table (zlib 78 9C/78 01/78 DA, gzip 1F 8B, LZ4 frame 04 22 4D 18, generic PNG/BMP/TGA signatures already handled by D1's classification). A match is recorded even though D2 did not resolve it to a known UO structure, since it may be a compressed variant of one.
  2. Entropy scoring: entropy from D1, already computed, is bucketed as low (< 5.0, likely structured/uncompressed data or padding), medium (5.0–7.2, likely mixed binary), high (> 7.2, likely compressed or encrypted). High-entropy unidentified files are flagged for compression sniffing before anything else.
  3. Compression sniffing: for files flagged high-entropy without a header match, D3 attempts a speculative decompress of the first 256 KB using DecompressionStream('deflate'), DecompressionStream('deflate-raw'), and DecompressionStream('gzip') (Section 7.3.5); the first variant that decompresses without a stream error and yields entropy < 6.5 on the decompressed sample is recorded as the working transform.
  4. Repeated-record-stride detection: computes byte-difference histograms at candidate strides (4, 8, 12, 16, 20, 24, 32, 64 bytes) over the first 64 KB, looking for a stride at which a fixed-position sub-field repeats a recognizable pattern (e.g. a monotonically increasing pseudo-offset, consistent with an index file at a stride D1/D2 did not anticipate). A stride with a fit score above 0.8 is recorded as detail.likelyStride.
  5. Manual format note: D3 checks for a file named format-note.json in the work directory (Section 7.4) authored by the operator, matched by relative_path glob; if present, its declared structure and parameters are trusted and the file is re-run through D4 as that structure instead of being left unidentified.

Every file leaves D3 as an extraction_candidates row: either upgraded (a later stage can now attempt extraction) or still status = 'needs_operator_input' with all of the above evidence recorded in metadata, ready for a human to read on the import detail screen (Section 17.5) and, if needed, write a format-note.json. D3 never crashes on a file it cannot resolve and never silently drops it — every input file has a corresponding output row by the end of D3.

7.2.4 Stage D4 — Extract #

Input: source_files identified by D2 (directly) or upgraded by D3. Output: decoded raw RGBA sprite buffers and decoded hue tables written to the work directory, each with an extraction_candidates row recording source offset and provenance. Command: skinforge-cli import extract --build <build_id> [--only mul_idx_pair|uop|hues_table|...].

For mul_idx_pair and uop structures identified as containing gump art, D4 walks every index entry (Section 7.3.1/7.3.4), decodes the gump run-length encoding (Section 7.3.2) into a raw RGBA buffer, and writes it to <workdir>/<build_id>/extracted/gumps/<gump_id>.rgba alongside a JSON sidecar with width, height, and source provenance (source_file_id, byte offset, and for UOP entries the 64-bit path hash that resolved it). For hues_table structures, D4 decodes every one of the 375 groups (Section 7.3.3) into 3,000 individual hue records held in memory, then written to <workdir>/<build_id>/extracted/hues.json.

Failure modes: a corrupt index entry (Section 7.12) is recorded as import_run_items with status = 'failed' and extraction continues with the next entry — one bad record never aborts a 50,000-entry file. A gump whose decoded byte length does not match its declared extra dimensions (Section 7.3.1) is recorded as status = 'failed' with detail.reason = 'dimension_mismatch' and is not written to the work directory.

7.2.5 Stage D5 — Classify #

Input: extracted sprites and hue records from D4. Output: extraction_candidates rows with suggested_slot_key, suggested_body, and confidence populated (Section 7.6). Command: skinforge-cli import classify --build <build_id>.

D5 runs the heuristics in Section 7.6 against every D4 output, writing or updating the corresponding extraction_candidates row. Rows scoring at or above the acceptance threshold (0.85, or 0.65 under the cold-start rule in Section 7.6) are marked status = 'approved' automatically; rows below it stay status = 'pending' for the staff review queue (Section 17.6).

7.2.6 Stage D6 — Normalize #

Input: approved candidates from D5 (automatically or via staff approval), plus the decoded hue records D4 wrote to the work directory. Output: assets, asset_variants, asset_images, hues and hue_group_members rows (Sections 6.10–6.13 and 6.15), with encoded WebP/PNG files written to object storage. Command: skinforge-cli import normalize --build <build_id>.

D6 performs the trimming, offset preservation, multi-scale generation, and encoding described in Section 7.8, and is the only stage that writes to the permanent catalog tables rather than pipeline scratch tables. D6 never runs against a candidate that has not reached status = 'approved'. Nothing else in the system writes assets, asset_variants, asset_images or hues: approving a candidate in the admin console (Section 17.6) only sets its status, and the catalog rows appear when the operator next runs D6.

Hues are D6's output too, and this is the only place they are written. D4 decodes every hue table into <workdir>/<build_id>/extracted/hues.json and D5 proposes a group assignment for each one, but neither writes a row: hues is a permanent catalog table, so it follows the same rule as every other one. In the same run, and inside the same per-build transaction boundary, D6 inserts one hues row per decoded hue for this build_id, substitutes the Hue <index> placeholder for any blank decoded name (Section 7.7), and inserts the hue_group_members rows that realize D5's proposal. The ordering is forced rather than stylistic — hue_group_members.hue_id is a foreign key to hues.id (Section 6.15), so a membership row written before D6 has no row to point at.

7.3 File Format Reference #

This reference is sufficient, on its own, to implement every reader without consulting another source. All multi-byte integers are little-endian unless stated otherwise, matching the original client's x86 origin.

7.3.1 MUL/IDX Pairs #

A MUL/IDX pair is two files: a .mul file holding concatenated binary records, and a .idx file holding one fixed 12-byte index entry per logical record, pointing into the .mul file.

Each index entry:

Offset Size Field Meaning
0 4 lookup uint32. Byte offset into the .mul file where this record's data begins. 0xFFFFFFFF means the record is absent (sentinel).
4 4 length uint32. Byte length of this record's data in the .mul file. 0xFFFFFFFF or 0 alongside a 0xFFFFFFFF lookup means absent.
8 4 extra uint32. Record-type-specific metadata. For gump art, this packs width in the high 16 bits and height in the low 16 bits: width = extra >>> 16, height = extra & 0xFFFF.

Walking a pair: recordCount = Math.floor(idxFileSize / 12). For i in 0..recordCount, read the 12-byte entry at offset i * 12. If lookup === 0xFFFFFFFF, the record at index i does not exist — skip it, never treat it as an error. Otherwise read length bytes from the .mul file starting at lookup.

interface MulIdxEntry {
  lookup: number;
  length: number;
  extra: number;
}

function readIdxEntries(idxBytes: Uint8Array): MulIdxEntry[] {
  const view = new DataView(idxBytes.buffer, idxBytes.byteOffset, idxBytes.byteLength);
  // Floored: a .idx whose size is not an exact multiple of 12 has a partial trailing entry,
  // and reading it would run past the end of the DataView and throw a RangeError.
  const count = Math.floor(idxBytes.byteLength / 12);
  const entries: MulIdxEntry[] = [];
  for (let i = 0; i < count; i++) {
    const base = i * 12;
    entries.push({
      lookup: view.getUint32(base, true),
      length: view.getUint32(base + 4, true),
      extra: view.getUint32(base + 8, true),
    });
  }
  return entries;
}

7.3.2 Gump Art Encoding #

A gump art record (the .mul bytes located by one MUL/IDX entry, or one decompressed UOP entry) is a per-row lookup table followed by run-length-encoded pixel data.

Layout:

Offset Size Field
0 4 * height Row lookup table: height entries, each a uint32 byte offset (relative to the end of this lookup table, i.e. relative to offset 4 * height) to that row's run data.
4 * height onward variable Run data for all rows, concatenated.

width and height come from the owning MUL/IDX entry's extra field (Section 7.3.1) or, for UOP gump entries, from a 8-byte header prefix (uint32 width, uint32 height) placed before the row lookup table by the UOP-specific packaging (Section 7.3.4).

Each row's run data is a sequence of runs, each 4 bytes: uint16 color (ARGB1555 packed pixel value) followed by uint16 runLength (count of consecutive pixels with that color). A row ends when the sum of runLength values for that row reaches width; there is no explicit end-of-row marker — the reader must track the running x-position and stop consuming runs for the current row once it reaches width.

The packed value 0x0000 is the transparency sentinel: a run whose color is zero writes runLength fully transparent pixels. Every other packed value decodes as opaque. Bit 15 is never consulted — UO gump art carries no per-pixel alpha channel beyond that zero/opaque split, so 0x8000 (alpha bit set, all colour bits zero) is still transparent.

The unpacking function has exactly one definition in this system; Section 8.3.1 owns it, and it is reproduced here because D4 runs long before any rendering code is loaded. Its signature and its semantics are identical in both places:

interface Rgba { r: number; g: number; b: number; a: number }

function unpackArgb1555(c: number): Rgba {
  // ARGB1555: bits 14-10 = R, 9-5 = G, 4-0 = B. Bit 15 is not consulted.
  const r5 = (c >> 10) & 0x1f;
  const g5 = (c >> 5) & 0x1f;
  const b5 = c & 0x1f;
  // 5-bit to 8-bit expansion: replicate the top 3 bits into the low 3 bits for full dynamic range.
  return {
    r: (r5 << 3) | (r5 >> 2),
    g: (g5 << 3) | (g5 >> 2),
    b: (b5 << 3) | (b5 >> 2),
    a: c === 0x0000 ? 0 : 255,
  };
}

The decoder validates every offset it reads out of the file before using it. A gump record is untrusted input, and three separate malformed shapes — a truncated lookup table, a row offset pointing outside the record, and a run whose declared length is zero — will otherwise throw a RangeError or spin forever, because a zero-length run advances the cursor but never advances x:

class GumpDecodeError extends Error {
  constructor(public readonly reason: string) {
    super(`gump decode failed: ${reason}`);
    this.name = 'GumpDecodeError';
  }
}

function decodeGump(data: Uint8Array, width: number, height: number): Uint8ClampedArray {
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
  const rgba = new Uint8ClampedArray(width * height * 4);
  const lookupBase = 0;
  const runDataBase = 4 * height;

  if (data.byteLength < runDataBase) throw new GumpDecodeError('lookup_table_truncated');

  for (let row = 0; row < height; row++) {
    const rowOffset = runDataBase + view.getUint32(lookupBase + row * 4, true);
    if (rowOffset < runDataBase || rowOffset > data.byteLength - 4) {
      throw new GumpDecodeError('row_offset_out_of_bounds');
    }
    let x = 0;
    let cursor = rowOffset;
    while (x < width) {
      if (cursor + 4 > data.byteLength) throw new GumpDecodeError('run_data_truncated');
      const color = view.getUint16(cursor, true);
      const runLength = view.getUint16(cursor + 2, true);
      cursor += 4;
      if (runLength === 0) throw new GumpDecodeError('zero_length_run');
      if (color === 0) {
        // Transparent run: leave RGBA buffer at its zero-initialized (0,0,0,0) default.
        x += runLength;
        continue;
      }
      const { r, g, b, a } = unpackArgb1555(color);
      for (let i = 0; i < runLength && x < width; i++, x++) {
        const pixelIndex = (row * width + x) * 4;
        rgba[pixelIndex] = r;
        rgba[pixelIndex + 1] = g;
        rgba[pixelIndex + 2] = b;
        rgba[pixelIndex + 3] = a;
      }
    }
  }
  return rgba;
}

Transparency rule: any pixel never written by a run (which does not occur in well-formed data, since runs sum to exactly width per row) is fully transparent because the buffer is zero-initialized.

Failure rule: the decoder throws a typed GumpDecodeError on any malformed input and never returns a partially-decoded buffer. D4 catches it per Section 7.2.4 and records an import_run_items row with status = 'failed' and detail.reason set to the error's reason code — one of lookup_table_truncated, row_offset_out_of_bounds, run_data_truncated or zero_length_run (Section 7.12). A throw inside the decoder is therefore a handled outcome for one record, not an unhandled exception, which is what Section 7.4's "nothing in D1–D3 ever throws an unhandled exception" means: the throw is caught at the stage boundary, one row down.

No mask, index map or palette sidecar is produced alongside the decoded buffer. Gump art is direct-colour ARGB1555, not palette-indexed, so there is no palette position to capture; the hue table index for a pixel is derived from the pixel itself at render time (Section 8.3), never stored.

7.3.3 hues.mul #

hues.mul holds 375 groups of 8 hues each (3,000 hues total), each hue occupying a fixed 88 bytes.

Group layout (708 bytes: 4-byte header + 8 × 88-byte entries):

Offset (within group) Size Field
0 4 Header (unused padding in the original format; skip).
4 8 * 88 8 hue entries, 88 bytes each.

Each 88-byte hue entry:

Offset (within entry) Size Field
0 32 * 2 = 64 colorTable: 32 × uint16 ARGB1555 colours, index 0 (darkest) to 31 (lightest).
64 2 tableStart: uint16.
66 2 tableEnd: uint16.
68 20 name: fixed 20-byte ASCII, NUL-padded.

Total per entry: 64 + 2 + 2 + 20 = 88 bytes, confirming the layout. Global hue index for group g (0-based) and slot s (0-based, 0..7) is g * 8 + s + 1 (hue indices are 1-based; index 0 is the reserved "unhued" sentinel per Section 3.6, never present in this file).

interface DecodedHue {
  hueIndex: number;
  colors: number[]; // 32 entries, packed 0xRRGGBB after ARGB1555 expansion
  tableStart: number;
  tableEnd: number;
  name: string;
}

function decodeHuesMul(data: Uint8Array): DecodedHue[] {
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
  const hues: DecodedHue[] = [];
  const GROUP_SIZE = 708;
  const groupCount = Math.floor(data.byteLength / GROUP_SIZE);
  for (let g = 0; g < groupCount; g++) {
    const groupBase = g * GROUP_SIZE + 4; // skip 4-byte header
    for (let s = 0; s < 8; s++) {
      const entryBase = groupBase + s * 88;
      const colors: number[] = [];
      for (let c = 0; c < 32; c++) {
        const argb1555 = view.getUint16(entryBase + c * 2, true);
        const { r, g: gc, b } = unpackArgb1555(argb1555);
        colors.push((r << 16) | (gc << 8) | b);
      }
      const tableStart = view.getUint16(entryBase + 64, true);
      const tableEnd = view.getUint16(entryBase + 66, true);
      const nameBytes = data.subarray(entryBase + 68, entryBase + 88);
      const nul = nameBytes.indexOf(0);
      const name = new TextDecoder('ascii').decode(nul >= 0 ? nameBytes.subarray(0, nul) : nameBytes).trim();
      hues.push({ hueIndex: g * 8 + s + 1, colors, tableStart, tableEnd, name });
    }
  }
  return hues;
}

7.3.4 UOP (MythicPackage) #

UOP files package many records behind a hashed-filename index rather than a sequential MUL/IDX pair. Header:

Offset Size Field
0 4 Magic: ASCII MYP\0 (bytes 4D 59 50 00).
4 4 version: uint32.
8 4 misc: uint32 (format-specific, informational).
12 8 firstBlockOffset: uint64 (as two uint32 reads combined, since Deno's DataView.getBigUint64 returns a bigint; the offset fits in 53 safe integer bits for all known UOP files, so Number(view.getBigUint64(12, true)) is safe).
20 4 blockSize (aka filesPerBlock in some tooling; entry-count field, name varies by source — treat as entriesPerBlockHint; the authoritative per-block count is read from each block header below, this field is advisory only).
24 4 entryCount: uint32, total number of hashed entries across all blocks.

Each block, starting at firstBlockOffset and chained via nextBlockOffset:

Offset (within block) Size Field
0 4 entryCount: uint32, number of entries in this block.
4 8 nextBlockOffset: uint64 (0 = last block).
12 onward entryCount * 34 Entries, 34 bytes each.

Each 34-byte entry:

Offset (within entry) Size Field
0 8 dataOffset: uint64. Absolute file offset of the entry's payload.
8 4 headerLength: uint32. Extra per-entry header preceding the payload at dataOffset (commonly 0).
12 4 compressedLength: uint32.
16 4 decompressedLength: uint32.
20 8 pathHash: uint64. 64-bit hash of the entry's logical filename (Section 7.3.4, Hash algorithm).
28 4 dataHash: uint32 (Adler32 of the payload, used for integrity verification, not identification).
32 2 compressionFlag: uint16. 0 = uncompressed, 1 = zlib-compressed.

To read one entry's payload: seek to dataOffset + headerLength, read compressedLength bytes, and if compressionFlag === 1 decompress with DecompressionStream('deflate') (raw zlib per Section 7.3.5) to decompressedLength bytes; if 0, use the bytes as-is (decompressedLength should equal compressedLength).

Traversal bounds (required, not optional — a UOP is untrusted input and the block chain is a linked list read straight out of the file). The reader maintains a Set<number> of visited block offsets. It aborts the file with a typed UopDecodeError('block_chain_cycle'), recorded as an import_run_items failure, on any of: a repeated offset; an offset that is not strictly greater than the previous block's offset; an offset at or beyond the file length; or a cumulative block count whose entries exceed the header's entryCount. Per entry, dataOffset + headerLength + compressedLength must be <= fileSize, and decompressedLength must be <= MAX_DECOMPRESSED_ENTRY_BYTES (16 MiB), both checked before any decompression is attempted, so a decompression bomb is rejected rather than expanded. Because the offset must strictly increase and is bounded by the file length, traversal terminates on every input, including a block that points at itself.

The hashed-filename problem: a UOP entry carries no filename, only a 64-bit pathHash. UOP packaging was designed for the client to look up known logical paths (e.g. build/gumpartlegacymul/000000c8.tga) by hashing the candidate string and comparing against pathHash. To read a UOP whose contents are gump art, D4 builds a dictionary of candidate path strings using the pattern build/gumpartlegacymul/%08x.tga for every plausible gump id in the range 0x000000000x0000FFFF (65,536 candidates, covering every documented classic gump id range with headroom for Outlands-added ids), hashes each candidate, and builds a Map<hash, gumpId> used to resolve every entry's pathHash back to a gump id in one pass. Candidate patterns are configurable per source-file classification (Section 7.6) so a UOP later found to hold a different content type (e.g. tiledata) uses a different dictionary pattern without a code change — patterns live in core/pipeline/uop-dictionaries.ts as a Record<contentKind, (id: number) => string> map.

Hash algorithm: the classic UOP hash is a specific 64-bit variant of the "hashlittle2" / Jenkins one-at-a-time family used by the original client. It combines two 32-bit halves seeded from the string length, producing (upper32 << 32n) | lower32. Implementation:

function uopHash(pathAscii: string): bigint {
  const bytes = new TextEncoder().encode(pathAscii.toLowerCase());
  let length = bytes.length;
  let a = 0xdeadbeef + length;
  let b = 0xdeadbeef + length;
  let c = 0xdeadbeef + length;
  a = (a + 0x9e3779b9) >>> 0;
  b = (b + 0x9e3779b9) >>> 0;
  c = (c + 0x9e3779b9) >>> 0;

  let i = 0;
  const rot = (x: number, k: number) => ((x << k) | (x >>> (32 - k))) >>> 0;

  while (length > 12) {
    a = (a + (bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24))) >>> 0;
    b = (b + (bytes[i + 4] | (bytes[i + 5] << 8) | (bytes[i + 6] << 16) | (bytes[i + 7] << 24))) >>> 0;
    c = (c + (bytes[i + 8] | (bytes[i + 9] << 8) | (bytes[i + 10] << 16) | (bytes[i + 11] << 24))) >>> 0;

    a = (a - c) >>> 0; a ^= rot(c, 4);  c = (c + b) >>> 0;
    b = (b - a) >>> 0; b ^= rot(a, 6);  a = (a + c) >>> 0;
    c = (c - b) >>> 0; c ^= rot(b, 8);  b = (b + a) >>> 0;
    a = (a - c) >>> 0; a ^= rot(c, 16); c = (c + b) >>> 0;
    b = (b - a) >>> 0; b ^= rot(a, 19); a = (a + c) >>> 0;
    c = (c - b) >>> 0; c ^= rot(b, 4);  b = (b + a) >>> 0;

    i += 12;
    length -= 12;
  }

  const rem = bytes.slice(i, i + length);
  const get = (idx: number) => (idx < rem.length ? rem[idx] : 0);
  c = (c + (get(11) << 24)) >>> 0;
  c = (c + (get(10) << 16)) >>> 0;
  c = (c + (get(9) << 8)) >>> 0;
  c = (c + get(8)) >>> 0;
  b = (b + (get(7) << 24)) >>> 0;
  b = (b + (get(6) << 16)) >>> 0;
  b = (b + (get(5) << 8)) >>> 0;
  b = (b + get(4)) >>> 0;
  a = (a + (get(3) << 24)) >>> 0;
  a = (a + (get(2) << 16)) >>> 0;
  a = (a + (get(1) << 8)) >>> 0;
  a = (a + get(0)) >>> 0;

  c ^= b; c = (c - rot(b, 14)) >>> 0;
  a ^= c; a = (a - rot(c, 11)) >>> 0;
  b ^= a; b = (b - rot(a, 25)) >>> 0;
  c ^= b; c = (c - rot(b, 16)) >>> 0;
  a ^= c; a = (a - rot(c, 4)) >>> 0;
  b ^= a; b = (b - rot(a, 14)) >>> 0;
  c ^= b; c = (c - rot(b, 24)) >>> 0;

  return (BigInt(c) << 32n) | BigInt(b);
}

A silently wrong hash implementation would resolve zero UOP entries with no other symptom, so the function is covered by a regression test rather than by trust. The test is one the developer can write without any external data: compute uopHash("build/gumpartlegacymul/000000c8.tga") on first implementation, commit the resulting value as a fixture, and assert against it thereafter (Section 22.2). This proves the implementation is stable across refactors, which is the property that actually matters. It is a unit test, not a startup check: extract never aborts on it. If a real UOP resolves zero entries, D4 records that as an import_run_items failure naming the file and continues, and the uop-unresolved: candidates in Section 7.12 make the miss visible to staff.

7.3.5 Compression #

Both UOP zlib-compressed entries and speculative D3 probing use Deno's built-in DecompressionStream. Two caveats:

  1. Raw-vs-zlib header: DecompressionStream('deflate') expects a 2-byte zlib header (78 xx) followed by the raw deflate stream and a 4-byte Adler32 trailer. DecompressionStream('deflate-raw') expects the deflate stream with no header/trailer at all. UOP compressionFlag = 1 payloads are zlib-wrapped, so use 'deflate'. Bare speculative D3 probing tries 'deflate-raw' first (more common for hand-rolled containers) and falls back to 'deflate'.
  2. Deno's streams API requires wrapping a Uint8Array in a ReadableStream to decompress:
async function inflate(compressed: Uint8Array, mode: 'deflate' | 'deflate-raw' | 'gzip'): Promise<Uint8Array> {
  const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream(mode));
  const buf = await new Response(stream).arrayBuffer();
  return new Uint8Array(buf);
}

A DecompressionStream that throws (malformed stream) rejects the returned promise; D4 and D3 both wrap this call in try/catch and record the failure as an import_run_items/extraction_candidates row rather than letting the exception propagate and abort the whole stage.

7.3.6 tiledata.mul, body.def, bodyconv.def, mobtypes.txt #

  • tiledata.mul: fixed-record land/static tile metadata (flags, name, texture id). SkinForge uses only the static-tile flag bits that indicate "wearable"/"translucent" for cross-checking gump id ranges during classification (Section 7.6); it does not import land tiles at all, since SkinForge renders paperdolls, not the game world.
  • body.def: text control file mapping body id to animation/paperdoll body group and gender. Body ids and paperdoll gump ids are two different namespaces in every UO client, so SkinForge does not assert that one equals the other. It records whatever body ids this file maps to human/male/female as metadata.bodyIds on the relevant candidates, for staff reference. A disagreement with the expected paperdoll gump ids in Section 3.2 is recorded as status = 'needs_operator_input' on the affected candidates; it never aborts a stage and never blocks the other bodies.
  • bodyconv.def: text control file mapping one body id to an alternate body id used for equipment-driven body swaps in the original client (e.g. mounted forms). SkinForge parses it only to confirm no swap rule affects human_male/human_female; any that do are logged and otherwise ignored, since SkinForge has no equipment-driven body-swap feature (an explicit non-goal, Section 2.5).
  • mobtypes.txt: text control file mapping body id to a broad creature-type category (human/animal/monster/sea/equip). SkinForge parses it solely as a sanity cross-check that the body ids body.def mapped to human are categorized human here too; a mismatch is recorded as metadata.mobtypesDisagreement and sets the affected candidates to needs_operator_input. It never aborts a stage: a control file this pipeline reads for corroboration must never be able to stop an import, because a correct-but-unexpected client file would then halt the whole build.

7.3.7 ClassicUO Override Folders and Loose Images #

Some UO Outlands distributions layer a ClassicUO-compatible client with an Assets/ or Data/Client/ override folder containing loose PNG/BMP files named by gump id (gumpart/0x0C34.png-style) instead of packed MUL/UOP data. D1 classifies these as file_type = 'image'; D2 identifies them as loose_image; D4 reads them directly with Deno's WASM PNG/BMP decoders (the same @jsquash/png used for encoding, Section 4.1, decodes as well as encodes) rather than the gump run-length format. This path exists as a fallback source when MUL/UOP data is absent or incomplete for a given id — D5's classification step treats a loose_image match for an id already resolved from MUL/UOP as a corroborating signal that raises confidence, and a loose_image match for an id with no MUL/UOP counterpart as the sole source for that id.

7.4 Unknown-Container Strategy #

When D2 and D3 (Section 7.2.3) cannot resolve a file to a known structure, the pipeline never crashes and never silently drops the file. The full strategy:

  1. Magic-byte catalogue: core/pipeline/magic-bytes.ts holds an extensible table of { bytes: Uint8Array, structure: string } entries, checked in D2 and D3. Adding support for a newly discovered format is a data change to this table plus a new decoder module, never a change to the pipeline's control flow.
  2. Entropy scoring: computed once in D1, reused by D3, bucketed as described in Section 7.2.3.
  3. Record-stride detection: the byte-difference histogram approach in Section 7.2.3, surfaced in the admin console as a small histogram chart so staff can visually confirm a stride guess.
  4. format-note.json: an operator can hand-write this file into <workdir>/<build_id>/notes/format-note.json, keyed by relative_path, with this Zod-validated shape:
import { z } from 'zod';

export const FormatNoteSchema = z.object({
  relativePath: z.string(),
  structure: z.enum(['mul_idx_pair', 'uop', 'hues_table', 'tiledata', 'control_file', 'loose_image']),
  params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
  note: z.string().min(1),
});
export type FormatNote = z.infer<typeof FormatNoteSchema>;

params carries structure-specific overrides, e.g. { "recordStride": 16, "headerBytes": 8 } for a mul_idx_pair variant that does not match the default 12-byte index stride. D3 re-validates every format-note.json against this schema on every run and rejects (with a clear CLI error, not a crash) a note that fails validation, so a typo in a hand-written note is caught immediately. 5. needs_operator_input candidate state: the terminal state (Section 6.8) for anything D3 cannot resolve even with a format note attempt. It is a first-class, visible state in the admin console (Section 17.5), not a hidden failure — the admin imports screen shows a count of needs_operator_input items per build and links directly to each one's evidence (magic bytes, entropy, stride guesses).

Nothing in D1–D3 ever throws an unhandled exception for a malformed or unrecognized file; every code path that reads file bytes is wrapped so a failure becomes a row with status IN ('failed', 'needs_operator_input'), never a crashed CLI process. A crashed process is treated as a pipeline bug to be fixed, not an acceptable outcome for unusual input.

7.5 Extraction Outputs #

Work directory layout (SKINFORGE_IMPORT_WORKDIR/<build_id>/):

<build_id>/
  notes/
    format-note.json           # optional, operator-authored (Section 7.4)
  journal/
    d1-inventory.jsonl         # one JSON object per finding, tailed by the driver (Section 7.1)
    d2-identify.jsonl
    d3-probe.jsonl
    d4-extract.jsonl
    d5-classify.jsonl
    d6-normalize.jsonl
  extracted/
    gumps/
      <gump_id>.rgba            # decoded RGBA buffer, width*height*4 bytes
      <gump_id>.json             # sidecar: width, height, provenance
    hues.json                    # all decoded hue records (Section 7.3.3)
  manifest.json                  # full-run manifest (schema below)
  normalized/
    <asset_key>/
      m/
        1.webp  1.png  2.webp  2.png  3.webp  3.png
      f/
        1.webp  1.png  2.webp  2.png  3.webp  3.png

That is the complete list of artifact kinds. In particular there is no per-sprite .mask file and no per-pixel index sidecar anywhere in the pipeline, at any stage: nothing downstream needs one, because the hue table index for a pixel is derived from the pixel's own red channel at render time (Section 8.3). The only per-sprite artifacts are the decoded raw buffer, its .json metadata sidecar, and the encoded WebP/PNG files under normalized/.

The decoded raw buffer is scratch and never leaves this directory. D4 writes it so D5 and D6 have something to analyse and encode without re-parsing the client; D6 then encodes it and uploads only the encoded results. It is never uploaded to object storage, never gets an asset_images row, and is not a storage format: asset_images.format admits webp and png and nothing else (Section 6.12), and object storage holds no raw-buffer object under any prefix. The renderer's input is the scale-1 PNG (Section 8.2.1), which is lossless and therefore decodes back to exactly these bytes. Deleting a build's work directory after publish costs nothing but the re-encode shortcut below.

Files under normalized/ are the exact bytes uploaded to object storage by D6; they are kept in the work directory after upload as a local cache so a re-run of normalize can skip re-encoding unchanged assets (Section 7.9's resumability). The journal/ files are append-only and are the only channel by which the decoding child reports anything (Section 7.1); they are safe to delete once the build is published.

Manifest JSON schema — one manifest per build, written incrementally as each stage completes, validated against this Zod schema before any downstream stage reads it:

import { z } from 'zod';

const ProvenanceSchema = z.object({
  sourceFileId: z.string().length(26),
  relativePath: z.string(),
  byteOffset: z.number().int().nonnegative(),
  structure: z.enum(['mul_idx_pair', 'uop', 'hues_table', 'loose_image']),
  uopPathHash: z.string().optional(), // present only when structure === 'uop'
});

const ExtractedGumpSchema = z.object({
  gumpId: z.number().int().nonnegative(),
  width: z.number().int().positive(),
  height: z.number().int().positive(),
  sha256: z.string().length(64),
  provenance: ProvenanceSchema,
});

const ExtractedHueSchema = z.object({
  hueIndex: z.number().int().min(1).max(65535),
  name: z.string(),
  tableStart: z.number().int(),
  tableEnd: z.number().int(),
  colors: z.array(z.number().int()).length(32),
  provenance: ProvenanceSchema,
});

export const ManifestSchema = z.object({
  buildId: z.string().length(26),
  buildLabel: z.string(),
  generatedAt: z.string().datetime(),
  stagesCompleted: z.array(z.enum(['inventory', 'identify', 'probe', 'extract', 'classify', 'normalize'])),
  gumpCount: z.number().int().nonnegative(),
  hueCount: z.number().int().nonnegative(),
  needsOperatorInputCount: z.number().int().nonnegative(),
  gumps: z.array(ExtractedGumpSchema),
  hues: z.array(ExtractedHueSchema),
});
export type Manifest = z.infer<typeof ManifestSchema>;

Example (truncated to two gumps and one hue for illustration; a real manifest holds every extracted item). Every value below satisfies the schema above, so it can be pasted into a test fixture as-is: identifiers are 26-character ULIDs, hashes are 64 hex characters, and the hue's byteOffset is the one Section 7.3.3's layout actually produces — hue index 1002 sits at group g = 125, slot s = 1, so 125 * 708 + 4 + 1 * 88 = 88,592. One exception, stated so nobody builds a golden test on it: the 32 colors values below are illustrative placeholders chosen to read as a smooth ramp, not decoder output. Real values are always 5-bit expansions per Section 8.3.1 ((c5 << 3) | (c5 >> 2)), whose channel bytes can only be 0, 8, 16, 24, 33, 41, …, 255; any fixture that asserts decoder behaviour must be generated from a real hues.mul byte range rather than copied from here:

{
  "buildId": "01J8Z3K9QABCDEFGHJKMNPQRST",
  "buildLabel": "2026-09-01-patch-47",
  "generatedAt": "2026-09-01T14:32:07Z",
  "stagesCompleted": ["inventory", "identify", "probe", "extract"],
  "gumpCount": 4821,
  "hueCount": 3000,
  "needsOperatorInputCount": 3,
  "gumps": [
    {
      "gumpId": 200,
      "width": 44,
      "height": 68,
      "sha256": "b1946ac92492d2347c6235b4d2611184d0d1c58d8c1f0c9c5c9c5c9c5c9c5c97",
      "provenance": {
        "sourceFileId": "01J8Z3K9SRCF0AB3CDEFGH2345",
        "relativePath": "gumpart.mul",
        "byteOffset": 883412,
        "structure": "mul_idx_pair"
      }
    },
    {
      "gumpId": 201,
      "width": 44,
      "height": 68,
      "sha256": "d2a8c1f4e9b7a3c6f1e8d4b2a9c7e5f3b1d8a6c4e2f0b8d6a4c2e0f8b6d4a2c0",
      "provenance": {
        "sourceFileId": "01J8Z3K9SRCF0AB3CDEFGH2346",
        "relativePath": "Gumps.uop",
        "byteOffset": 1204890,
        "structure": "uop",
        "uopPathHash": "8f3a2c1e9b7d4f60"
      }
    }
  ],
  "hues": [
    {
      "hueIndex": 1002,
      "name": "Sea Green",
      "tableStart": 25,
      "tableEnd": 31,
      "colors": [534046, 929060, 1323817, 1718831, 2113589, 2508602, 2903360, 3298374, 3758667, 4153681, 4548438, 4943452, 5338210, 5733223, 6127981, 6522995, 6917752, 7312766, 7707524, 8102537, 8497295, 8892309, 9287066, 9682080, 10142373, 10537387, 10932145, 11327158, 11721916, 12116930, 12511687, 12906701],
      "provenance": {
        "sourceFileId": "01J8Z3K9SRCF0AB3CDEFGH2347",
        "relativePath": "hues.mul",
        "byteOffset": 88592,
        "structure": "hues_table"
      }
    }
  ]
}

Provenance fields (sourceFileId, relativePath, byteOffset, and structure) are carried forward into assets.source_file_id/assets.source_offset (Section 6.10) at D6, so every catalog row remains traceable back to the exact byte range it came from, which is essential for triaging a bad extraction without re-running the whole pipeline. The perceptual hash used for diffing (Section 9.3) is computed in D6 alongside the sha256, using a 64-bit difference hash (dHash) over a downscaled 9×8 greyscale of the sprite, stored in extraction_candidates.metadata.perceptualHash and not persisted onto the final assets/asset_variants rows, since it is a pipeline-internal diffing aid, not catalog data.

7.6 Classification #

D5 maps every extracted gump to a slot_key and, for slots with per-gender art, a body, then assigns a confidence score.

Signal 1 — gump id range. UO's classic client groups gump ids into broad, documented ranges per equipment category (e.g. certain ranges are conventionally hats, others robes). D5 maintains a range table in core/pipeline/gump-ranges.ts mapping [start, end] → slot_key for every range the operator or a prior build's staff review has confirmed; an id inside a confirmed range contributes +0.4 to that slot's confidence.

Signal 2 — dimensions. Sprite width/height are compared against a learned expected envelope per slot (e.g. hair sprites cluster tightly around a known width/height band derived from previously-approved assets in the same slot from earlier builds). A dimension within one standard deviation of the slot's learned mean contributes +0.2; outside two standard deviations contributes -0.2. On a build with no prior approved assets for a slot (first-ever import), this signal contributes 0 (neutral) rather than penalizing, since there is no learned envelope yet.

Signal 3 — alpha silhouette heuristics. The decoded RGBA buffer's alpha channel is analyzed for gross shape signals: a bounding-box aspect ratio and an approximate vertical centroid of opaque pixels. Slots with a known expected silhouette (e.g. footwear sprites are wide and short, hair sprites are tall and narrow with mass concentrated in the upper third) get +0.15 when the candidate matches, -0.15 when it strongly contradicts (e.g. a candidate proposed as footwear with an aspect ratio and centroid matching hair instead).

Signal 4 — body.def cross-check. For candidates whose gump id is referenced (directly or via a known offset convention) in body.def/bodyconv.def, agreement contributes +0.15; a direct contradiction (the control file asserts this id belongs to a non-human body) is a hard rejection regardless of other signals, since it means the sprite is not a player cosmetic at all.

Signal 5 — name dictionary. When a candidate arrived via loose_image (Section 7.3.7) with a filename that embeds a descriptive token (e.g. robe_plain.png), the token is matched against a per-slot keyword dictionary (core/pipeline/name-dictionary.ts); a match contributes +0.1.

Confidence is the sum of all applicable signals, clamped to [0, 1]. Automatic acceptance threshold: confidence >= 0.85 is approved automatically by D5 and proceeds to D6 without staff action. Everything below the threshold is written with status = 'pending' and appears in the staff review queue (Section 17.6), ordered ascending by confidence so the least-certain items surface first for review.

Cold-start rule. The five signals sum to at most 0.4 + 0.2 + 0.15 + 0.15 + 0.1 = 1.0, but on a build where a slot has no prior approved assets, Signal 2 has no learned envelope and contributes 0, and Signal 5 applies only to loose_image candidates. A first-ever import of MUL/UOP data therefore caps at 0.4 + 0 + 0.15 + 0.15 = 0.70, below the threshold, which would push 100% of the first build's several thousand candidates into the manual queue. So: on a build where the slot has no prior approved assets, Signal 2 is unavailable and the threshold for that slot drops to 0.65. The import_runs.summary for D5 records coldStartSlots — the list of slots approved under the reduced threshold — so staff know exactly which slots to spot-check in the diff view (Section 9.3). Once a slot has approved assets from any earlier build, the envelope exists and the threshold returns to 0.85 for that slot.

Hard rejection. A Signal-4 direct contradiction is not a low score; it is a decision. It sets status = 'rejected', confidence = 0 and metadata.rejectionReason = 'body_def_contradiction', and the candidate never enters the review queue — asking staff to adjudicate a sprite the control files say is not a player cosmetic wastes the queue's only scarce resource, which is attention.

Hue mode. D5 also decides the candidate's hue mode and records it as metadata.hueMode (full or partial), from the same silhouette analysis Signal 3 uses: a sprite whose opaque pixels are predominantly grey (r8 === g8 && g8 === b8 for a majority of them) is a partial-hue candidate, since partial hue is exactly the rule that recolours only grey pixels (Section 8.3.3). D6 copies this onto assets.partial_hue when it first creates the asset and onto asset_variants.hue_mode on every variant it writes (Sections 6.10 and 6.11); without this step every asset would be full forever and no asset would ever partial-hue.

Body assignment for slots with gender_scope = 'both' (Section 6.9) uses the same signal set evaluated independently per body id found in body.def's cross-reference, or, when the source structure carries no body distinction at all (some cosmetic layers share one sprite across genders in the original client), D5 assigns the single extracted sprite to both asset_variants rows (m and f) with identical geometry — this is recorded explicitly in extraction_candidates.metadata.sharedAcrossGenders = true so staff reviewing the catalog understand why only one sprite backs two variant rows.

7.7 Hue Extraction and Validation #

D4 decodes every hue in hues.mul (or its UOP equivalent, when Outlands ships hues packaged as UOP) using Section 7.3.3's algorithm, producing 3,000 base-range DecodedHue records unconditionally. Those records live in the work directory. The hues table itself is written by D6, in the same run that writes the rest of the catalog (Section 7.2.6); everything this subsection specifies — source tagging, naming, grouping and the swatch colour — is computed here and persisted there.

Detecting Outlands custom hues: the rule is an index-range rule, decided here and stated in full, and it deliberately depends on no external data file. The operator has exactly one client — the Outlands one (Section 2.7) — so any rule requiring a pristine stock client's hues.mul for comparison could never be executed and would block every import.

  • A hue with hue_index in 1..3000 is tagged source = 'base'.
  • A hue with hue_index > 3000 is tagged source = 'outlands'.
  • source = 'derived' is reserved for any hue this pipeline itself synthesizes rather than reads from client data. v1 never produces derived hues; the taxonomy leaves room for a future feature (e.g. procedurally generated skin-hue ramps) without a schema change.

Colour-table divergence within 1..3000 — Outlands overriding a stock hue slot — does not change source. It is detected by comparing the hue against the same hue_index in the previous build (the diff in Section 9.3 already does this) and recorded as extraction_candidates.metadata.overridesStockHue = true for staff visibility on the hues admin screen (Section 17.8). It matters because a design created against an older build renders a visibly different colour than one created after the override, which is exactly why designs.build_id pins every design to the hue table active at creation time (Sections 9.8 and 13.7). The first build has no previous build to compare against, so no hue is flagged on it; that is correct, since "overrides the stock table" is only meaningful once there is a prior table to have overridden.

Staff may correct source on the hues admin screen (Section 17.8); that edit is a normal audited write, not a pipeline concern.

Naming: name comes directly from the decoded 20-byte name field (Section 7.3.3), trimmed. A blank decoded name (all-NUL or all-whitespace) is replaced with a generated placeholder — the literal string "Hue " followed by the decoded hueIndex, so hue 1847 becomes Hue 1847 — written by D6 as it inserts the row, never left blank, since the admin console and public hue browser (Section 15.5) both require a non-empty display string. The placeholder is a prefix form and it applies to any hue whose decoded name was blank, whatever its source; it has nothing to do with derived hues, which v1 never produces.

Grouping: every hue ends up in one or more of the five hue_groups (Section 6.14), decided by a rule table: hues whose name matches a skin-tone keyword dictionary ("flesh", "tan", "pale", "bronze", etc.) or whose index falls in a curated range confirmed by staff in a prior build are tagged skin; a parallel keyword/range approach handles hair, tattoo, and event; every hue not matched by a more specific rule falls into clothing as the default group, since most of the 3,000 base hues are general-purpose dye colours with no skin/hair/event connotation. This grouping is data-driven (the keyword and range tables), so refining it in a later build never requires a code change, only an update to the rule tables under core/pipeline/hue-grouping-rules.ts.

The rules run at D5; the rows are written at D6. D5 evaluates the rule table and records the resulting group keys on extraction_candidates.metadata.hueGroups — a proposal, held on a pipeline scratch row. D6 inserts the corresponding hue_group_members rows at the moment it creates each hues row (Section 7.2.6). The split is not a matter of taste: hue_group_members.hue_id is a foreign key to hues.id (Section 6.15), and no hues row exists until D6 writes it, so D5 has nothing to point a membership row at. The same is true of the placeholder name below, which is why that substitution also happens at D6.

Every membership row D6 writes carries hue_group_members.method = 'heuristic' (Section 6.15). Staff additions and removals in the admin console write method = 'manual', and a later import never overwrites a manual row — that is the whole point of the column. Curation policy can therefore move between "heuristic with staff corrections" and "staff-curated allowlist" without a migration: it is a question of which rows exist, not of what the schema can express.

Swatch colour rule: swatch_color (Section 6.13) is computed as the colour at index 16 of the 32-entry colors array — the visual midpoint of the hue ramp, chosen because index 0 and index 31 are frequently near-black/near-white outliers that make a poor representative swatch, while index 16 is consistently the most saturated, recognizable tone across the sampled hue table. Index 16 is the one and only swatch index: a picker filled from index 0 would render every skin tone as near-black and be unusable, since the ramp runs darkest-to-lightest (Section 8.3.2). Staff can override the computed value per hue via hues.swatch_override_argb (Section 6.13), which wins whenever it is non-null.

7.8 Normalization #

D6 turns an approved extraction_candidates row into permanent catalog rows and stored image files. For every approved candidate:

  1. Trim: the decoded RGBA buffer is scanned for the tightest bounding box containing any non-transparent pixel (alpha > 0). The buffer is cropped to that bounding box. If the entire buffer is transparent (a zero-length sprite, Section 7.12), the candidate is rejected rather than normalized.
  2. Offset preservation: cropping shifts the sprite's origin, and the crop's top-left (dx, dy) is the offset. Paperdoll gump art is authored full-canvas — the untrimmed sprite's origin is the canvas origin (0, 0) — so offset_x = dx and offset_y = dy, with no external offset source required and nothing to recover from tiledata.mul or a gump header. The trimmed sprite therefore composites at the exact same visual position on the 260×330 canvas (Section 8.1) as the untrimmed original would have. A sprite whose untrimmed dimensions exceed 260×330 cannot have been authored full-canvas for this paperdoll and is rejected with detail.reason = 'oversize' (Section 7.12) rather than silently mis-positioned.
  3. Scale generation: for each of scale 1, 2, 3, the trimmed RGBA buffer is upscaled by nearest-neighbour pixel replication (never smoothed — this is pixel art, Section 8.6): scale n maps source pixel (x, y) to the n×n block of output pixels starting at (x*n, y*n), each copied verbatim.
  4. Encoding: each of the 3 scaled buffers is encoded to both webp (via @jsquash/webp) and png (via @jsquash/png), per the dependency table in Section 4.1, producing 6 files per variant.
  5. Storage keys: uploaded to object storage at catalog/:assetKey/:body/:hash12/:scale.:format, where :hash12 is the first 12 hex characters of the variant's sha256 — e.g. catalog/hair.long-wavy/m/9f2c41ab77de/2.webp. This is the key shape Section 8 names wherever it refers to a catalog object, and the encoded formats are webp and png only (Section 6.12); no raw buffer is ever uploaded. The scheme is deterministic and human-diagnosable, and the hash segment is what makes it safe across builds: identical art in two builds produces the same key and shares one object, while changed art produces a different key, so D6 can never overwrite the bytes an older build's designs still resolve. The build id is deliberately not in the key — putting it there would give every build its own copy of unchanged art — and content addressing is what replaces it. Because several builds legitimately share one object, asset_images.storage_key is not unique and carries a plain index rather than a unique one (Section 6.12); uniqueness lives on (asset_variant_id, scale, format) instead. If the object already exists at that key, the upload is skipped. catalog/ is one of exactly four object-storage prefixes this system uses — catalog/, renders/, og/ and imports/ — enumerated in Section 8.9; D6 writes only under catalog/.
  6. Row writes: one assets row per unique asset_key (first variant processed for that key creates it; subsequent variants for the same key add asset_variants rows only), one asset_variants row per body per build, and one asset_images row per scale × format, all in a single transaction per asset so a partial failure never leaves an assets row with no variants. Each variant row carries build_id and hue_mode (Section 6.11): build_id is the build D6 is running against, and hue_mode is copied from the candidate's metadata.hueMode (Section 7.6). D6 never updates an existing variant row belonging to an earlier build — when a patch changes an asset's art, it inserts a new row for the new build, which is exactly what keeps every previously-created permalink rendering the bytes it was created against (Section 9.3).
  7. Hue rows: in the same run, D6 reads <workdir>/<build_id>/extracted/hues.json (D4's output) and inserts one hues row per decoded hue for this build_id, applying the naming, source tagging and swatch-colour rules in Section 7.7, followed by the hue_group_members rows realizing the group proposal D5 left on each candidate. This is the only write path to either table (Sections 6.13 and 6.15). It is idempotent on hues (hue_index, build_id) and on hue_group_members (hue_id, hue_group_id), and it never overwrites a membership row whose method is manual.
  8. Backpack default: every published build is required to contain the asset backpack.default, the standard leather backpack. If classification produced no backpack candidate for a build, D6 carries the previous published build's backpack.default art forward as a new build-scoped variant rather than leaving the slot empty, because the paperdoll always draws a backpack (Section 3.3) and a design that omits one has no valid render. A build with no backpack.default from any source fails verify (Section 7.9) and cannot be published.

asset_key generation: every generated key must satisfy the pattern ^[a-z0-9]+([-_.][a-z0-9]+)*$ with a maximum length of 128 characters — the same shape AssetKeySchema enforces at the API boundary (Section 13.2.5) and the database enforces as a CHECK constraint (Section 6.10). D6 interpolates the key straight into an object-storage path, so this is the check that keeps a path-traversal sequence out of a storage key on the ingest side, where no API schema ever runs. D5 assigns a stable natural key of the form <slot_key_short>.<slug>, where slot_key_short drops the _inner/_outer/_middle/_body suffixes for readability (e.g. torso_outerrobe, chosen from a fixed per-slot label map, not a mechanical string transform) and slug is a kebab-case slug derived from the D5 name-dictionary match (Section 7.6) when available, or from "item-" + gumpId when no descriptive name was recoverable. Collisions on asset_key within one build are resolved by appending -2, -3, etc.; a collision against a different build's asset with different underlying art is treated as a data error and routed to needs_operator_input, since it means two visually distinct items would otherwise share one catalog identity.

7.9 CLI Surface #

The CLI is a single binary, skinforge-cli, and every command is skinforge-cli <noun> <verb>. This is the same form used in Sections 4.4, 5.2 and 9, and it is the only form: there is no bare skinforge alias and no flat-verb spelling.

Command Purpose
skinforge-cli import discover --client-dir <path> --build-label <label> Create a build and run D1 (inventory).
skinforge-cli import identify --build <id> Run D2 against an existing build's inventory.
skinforge-cli import probe --build <id> Run D3 against files D2 left unidentified.
skinforge-cli import extract --build <id> [--only <structure>] Run D4. --only restricts to one structure type for incremental re-runs.
skinforge-cli import classify --build <id> Run D5.
skinforge-cli import normalize --build <id> Run D6.
skinforge-cli import run --build <id> Run D1–D6 in sequence against an existing build, stopping at the first stage that leaves any needs_operator_input item above a --max-unresolved threshold (default 0).
skinforge-cli import all --client-dir <path> --build-label <label> Create the build, then run D1–D6 in one command. The one-shot form of discover followed by run.
skinforge-cli import verify --build <id> Re-read every asset_images row for the build, re-fetch the object from storage, and confirm its hash matches content_hash; also confirm the build contains backpack.default. Used before publish (Section 9.5) and after a restore (Section 6.34).
skinforge-cli builds submit-review --build <id> Move a build draftin_review (Section 9.1).
skinforge-cli builds diff --from <id> --to <id> Print the build diff (Section 9.3).
skinforge-cli builds publish --build <id> Run the publish transaction (Section 9.5).
skinforge-cli builds rollback --build <id> Re-publish an earlier build (Section 9.6).
skinforge-cli db migrate / db seed / db fixtures Migration runner and seed data (Sections 6.31 and 6.32).
skinforge-cli admin user create Create a staff account (Section 17.15).

import run requires a build that already exists, because only discover can create one; import all is the command that does both, and it is the one an operator uses after a patch.

Every command accepts:

  • --dry-run: performs all reads and computation but writes nothing to the database or object storage; prints a summary of what would have changed. Used by staff to preview a re-import's impact before committing.
  • --json: emits a single machine-readable JSON object to stdout on completion (counts, timings, error list) instead of human-readable text, for CI and scripting.
  • --build <id>: every import verb except discover and all requires an explicit build id; there is no implicit "current build" state, so operators can run stages for multiple builds concurrently without cross-contamination.

Process permissions: each command launches the decoding child described in Section 7.1 with --allow-read scoped to the client directory and the work directory, --allow-write scoped to the work directory, and no network permission. The driver process itself runs with --allow-net=<database-host>,<storage-endpoint> and --allow-env for the SKINFORGE_* variables in Section 24.2. Neither process is ever granted a broader network permission, and neither is ever run inside the public-facing web process.

Exit codes: 0 success with zero failed/needs_operator_input items; 1 success with at least one needs_operator_input item (recoverable, needs staff attention, not a pipeline bug); 2 stage aborted before completion (unreadable client directory, checksum drift, schema validation failure); 3 invalid CLI arguments. Scripts and CI treat 0 and 1 as "the command ran"; only 2 and 3 are treated as pipeline failures worth alerting on.

Resumability: every stage is idempotent per Section 7.5's manifest — re-running import extract --build <id> re-reads manifest.json, skips any gump whose sha256 already matches a prior run's recorded value for the same gump_id, and only re-decodes gumps that are new or whose source bytes changed. import normalize similarly skips re-encoding a variant whose normalized files already exist in the work directory with a hash matching the current asset_variants.sha256 for this build. An interrupted run (process killed mid-stage) leaves partially-written import_run_items/extraction_candidates rows with whatever status they had at interruption; re-running the same stage command re-processes only items not yet in a terminal state (ok, approved, rejected), so no work is duplicated and no row is silently skipped.

7.10 Performance #

Expected volumes: tens of thousands of sprites (roughly 5,000–8,000 catalog-relevant gumps after classification, out of a raw index that may contain 50,000+ total MUL/UOP entries once non-cosmetic game art is counted) and 3,000 hues per build.

  • Streaming decode: D1's hashing and D4's MUL/IDX reads never load an entire multi-hundred-MB .mul file into memory at once; both stream in 64 KB chunks using Deno's Deno.open + readable stream API, seeking only for the specific byte ranges an index entry names.
  • Memory ceilings: the pipeline enforces a soft in-process ceiling of 512 MB for any single stage invocation, checked via Deno.memoryUsage().heapUsed sampled every 500 processed items; exceeding it triggers a forced flush of in-memory buffers to the work directory and, if still over budget after flushing twice, an early, clean stage abort with exit code 2 and a message naming the offending item, rather than an uncontrolled out-of-memory crash.
  • Parallelism: D4 (extract) and D6 (normalize) — the two CPU-bound stages — use a worker pool (Deno Worker threads) sized to SKINFORGE_RENDER_MAX_CONCURRENCY, whose default is 4 (Section 24.2, reused for both render-time and import-time concurrency budgeting since both are CPU-bound image work competing for the same cores). Each worker processes one gump or one asset variant at a time, claimed from a shared in-process queue; database writes are batched by the main process, not performed from workers, to avoid connection-pool contention.
  • Progress reporting: every stage command prints a progress line to stderr every 2 seconds (item count processed / total, current throughput, ETA) when not run with --json; with --json, the same information is instead available by polling import_runs.summary (Section 6.6), which the driver process updates every 2 seconds from the child's journal (Section 7.1), so the admin console imports screen (Section 17.5) can show a live progress bar by polling that column.

No raw game file, and no byte-for-byte extracted sprite, is ever served to a public visitor. The public application only ever serves the outputs of D6 normalization (WebP/PNG files the pipeline itself generated) through the render endpoints in Section 8.8, or composite renders built from those normalized files. The work directory (SKINFORGE_IMPORT_WORKDIR) is not mounted under any public web route, is not in the object storage bucket, and the application's HTTP router has no handler that resolves any path under it — there is no code path, intentional or accidental, that could expose a raw MUL/IDX/UOP byte range or an untrimmed pre-normalization sprite to a public request. This holds even in the admin console: admin preview images shown during candidate review (Section 17.6) are served from a dedicated, session-authenticated admin-only route, /admin/artifacts/:importRunId/:name, that reads from the work directory server-side and streams a re-encoded preview. It is never a redirect, and no import artifact is ever addressable at a public object-storage URL — extraction_candidates. image_ref (Section 6.8) is a work-directory-relative path, not a storage key, precisely so that no code path can accidentally hand one to the public base URL. The overall legal posture for distributing derived, transformative renders (rather than original game assets) is Section 20.8's concern; this section only guarantees the technical boundary that makes that posture true in practice.

7.12 Edge Cases #

Case Detection Pipeline behaviour
Corrupt index entry (garbage lookup/length pointing past end of file) lookup + length > fileSize bounds check before read import_run_items row status = 'failed', detail.reason = 'out_of_bounds'; D4 continues to the next entry.
Zero-length sprite (length = 0 with a non-sentinel lookup, or a decoded RGBA buffer that is fully transparent after decode) Explicit length === 0 check pre-decode; post-decode alpha-sum check Recorded status = 'failed', detail.reason = 'zero_length'; never reaches D5.
Duplicate content hash (two different gump ids decode to byte-identical RGBA) asset_variants.sha256 unique-per-row check at D6 (not a DB constraint, since legitimate duplicates like symmetric left/right variants can share art; a warning, not a rejection) Both proceed to separate assets rows; extraction_candidates.metadata.duplicateOf cross-references the other gump id for staff visibility in the review queue.
Oversize sprite (width or height exceeds 512px, far beyond any real paperdoll layer) Bounds check against a constant MAX_SPRITE_DIMENSION = 512 at D4 Rejected before decode with detail.reason = 'oversize', since it almost always indicates a misread extra field (Section 7.3.1) rather than real art; routed to needs_operator_input for a human to confirm.
Missing female variant (a slot with gender_scope = 'both' has only a male-assignable candidate after D5) Post-D5 completeness check per asset_key group Not an error: the single variant is assigned to both m and f per Section 7.6's shared-across-genders rule, unless staff explicitly marks the asset male-only in the review queue, which sets a per-asset override consulted by D6.
Palette anomaly (a hue's colors array decodes to fewer than 32 distinguishable colours, e.g. many entries identical) Post-decode distinct-colour count on the 32-entry array Recorded but not rejected — some legitimate hues (e.g. pure greyscale ramps) can legitimately repeat colours; extraction_candidates.metadata.paletteWarning = true surfaces it for staff awareness only.
Gap in an index file (a run of consecutive sentinel 0xFFFFFFFF entries, e.g. reserved-but-unused gump id ranges) Sentinel check per Section 7.3.1 Silently skipped, not logged as failed — this is the documented, expected meaning of the sentinel, not an anomaly.
Truncated .mul file (declared length for the last entry extends past physical end of file) Bounds check identical to the corrupt-index case Recorded status = 'failed', detail.reason = 'out_of_bounds'; the rest of the file (entries before the truncation point) still processes normally.
UOP entry whose pathHash matches no candidate in the dictionary (Section 7.3.4, Hash algorithm) Hash lookup miss during D4 Recorded as an extraction_candidates row of candidate_key prefix uop-unresolved:, status = 'needs_operator_input', with the raw pathHash and block/entry position in metadata so staff can extend the dictionary pattern and re-run import extract --only uop.
hues.mul file size not an exact multiple of 708 bytes Modulo check before decoding The trailing partial block (fewer than 708 bytes remaining) is not decoded and is logged as a warning; every complete block before it decodes normally — a truncated tail never blocks the other 99%+ of hues.
Malformed gump record: lookup table shorter than 4 * height data.byteLength < 4 * height check before the row loop (Section 7.3.2) GumpDecodeError('lookup_table_truncated') caught at the stage boundary; import_run_items row status = 'failed', detail.reason = 'lookup_table_truncated'; D4 continues with the next record.
Malformed gump record: a row offset pointing outside the record Range check on every row offset before the first read (Section 7.3.2) detail.reason = 'row_offset_out_of_bounds', same handling.
Malformed gump record: run data ends mid-run cursor + 4 > data.byteLength check inside the run loop (Section 7.3.2) detail.reason = 'run_data_truncated', same handling.
Malformed gump record: a run declaring runLength = 0 Explicit check after reading the run header (Section 7.3.2) detail.reason = 'zero_length_run', same handling. This is the shape that would otherwise spin the row loop forever, since a zero-length run advances the cursor but never advances x.
UOP block chain that cycles, moves backwards, or runs past the file Visited-offset set plus strict-increase and file-length checks (Section 7.3.4) UopDecodeError('block_chain_cycle'); import_run_items row status = 'failed', detail.reason = 'block_chain_cycle'; the whole UOP is abandoned, other source files are unaffected.
UOP entry declaring a decompressedLength above 16 MiB, or a payload extending past the end of the file Checked before decompression is attempted (Section 7.3.4) detail.reason = 'entry_bounds'; the entry is skipped without being decompressed, so a decompression bomb is rejected rather than expanded.
.idx file whose size is not an exact multiple of 12 Floored entry count in readIdxEntries (Section 7.3.1) The trailing partial entry is not read; a warning row records detail.reason = 'idx_partial_entry' and every complete entry processes normally.

8. Rendering Engine — Hue Math, Compositing & Encoding #

This section defines the rendering engine that turns a design (Section 13) into pixels. It is the single source of truth for canvas geometry, sprite representation, the hue algorithm, compositing order, scaling, encoding, the render URL scheme, cache keys, the runtime pipeline, pre-warming, client/server parity, performance budgets, and rendering error handling. Every implementation detail here binds both the server renderer (the core/hue/, core/composite/ and core/codec/ modules of the repository tree in Section 5.1, used by the Deno web process) and the browser renderer (those same modules compiled for the browser, per Section 8.12). There is exactly one algorithm; it must produce bit-identical output in both environments.

8.1 Canvas geometry #

The composite canvas is 260×330 px at scale 1. This is the classic UO paperdoll art area and is fixed for the lifetime of the product.

  • Origin: top-left corner, (0, 0). X increases right, Y increases down. This matches HTML Canvas 2D and typed-array row-major layout, so no coordinate flip is needed anywhere in the pipeline.
  • Coordinate system: integer pixel coordinates only. No sub-pixel placement, no rotation, no perspective. A sprite is placed by adding its recorded offset (offsetX, offsetY, both signed integers, origin at the sprite's own top-left) to the canvas origin.
  • Sprite placement: each SpriteLayer (Section 8.2) carries the (offsetX, offsetY) recorded at extraction time (Section 7, Stage D6). The renderer computes the destination rectangle as [offsetX, offsetX + width) × [offsetY, offsetY + height) and blits into that rectangle.
  • Overflow clipping: a sprite whose destination rectangle extends outside [0, 260) × [0, 330) is clipped to the canvas bounds. Source pixels that map to out-of-bounds destination coordinates are skipped (never wrapped, never written). Clipping is silent — it is not an error — because some Outlands cosmetic layers (long cloaks, oversized hats) intentionally bleed past the paperdoll frame in the original client art, and the classic client itself clips them the same way. Partial overflow is therefore silent and expected. A destination rectangle that lies entirely outside [0, 260) × [0, 330) is different: it means the recorded offset is corrupt, nothing would be drawn, and the render aborts with SF-5006 (500, "composite canvas geometry mismatch", Section 25.2) rather than silently producing a layer-less image. This is the only geometry condition that is an error.
  • Why the canvas never changes size: the render URL scheme (Section 8.8) and the cache key (Section 8.9) do not carry width/height, only scale. A fixed base resolution means:
    1. Every asset's recorded offset is meaningful without a per-render coordinate transform.
    2. The scale parameter is a pure integer multiplier applied after compositing (Section 8.6), so compositing math and hue math run once at scale 1 and scaling is a separate, later step.
    3. OG image generation (Section 14) and the designer UI (Section 11) can embed the composite at a known aspect ratio (260:330) without querying render metadata first.
  • Background: the composite canvas starts fully transparent (RGBA(0,0,0,0) at every texel) before any layer is drawn. The body slot (Section 3.3) is drawn first and is expected to fill the humanoid silhouette; areas the body sprite does not cover (the full canvas margin) remain transparent in the output. Presentation backgrounds (checkerboard, parchment, dark) are a UI concern, not a render concern — see Section 12.2. PNG and WebP outputs always carry a true alpha channel.

8.2 Sprite representation in storage and in memory #

8.2.1 Storage representation #

Each asset_images row (Section 6.12) has an associated binary artifact in object storage, written by extraction (Section 7, Stage D6) under the catalog/ prefix defined in Section 8.9, at the content-addressed key catalog/:assetKey/:body/:hash12/:scale.:format (Section 8.9):

  • The renderer's input is the scale-1 PNG, catalog/:assetKey/:body/:hash12/1.png, decoded to RGBA with @jsquash/png (Section 8.7) and held in the in-process sprite cache (Section 8.13). PNG is lossless, so the decoded buffer is byte-identical to what extraction wrote (Section 7.8), which is what makes the browser-parity contract in Section 8.12 hold.
  • catalog/:assetKey/:body/:hash12/:scale.webp and catalog/:assetKey/:body/:hash12/:scale.png at scales 1, 2 and 3 — six files per variant, the complete set Stage D6 writes (Section 7.8). These are the encodings the catalog and the browser fetch directly (Section 8.12, Section 12.5); only the scale-1 .png among them doubles as the renderer's input above, and none of the six is produced by the compositor itself — the compositor always starts from the decoded pixels of that scale-1 PNG.

asset_images.format is webp or png and admits no other value — the CHECK constraint Section 6.12 owns. There is no raw-pixel storage artifact anywhere in the system.

There is no per-sprite sidecar of any kind. A sprite is exactly its RGBA pixels. Everything the hue algorithm needs is derivable from those pixels, by the rule in Section 8.3.3 — one artifact per variant per scale, nothing to keep in sync, and nothing the extraction pipeline has to invent. This matters because the browser (Section 8.12) receives only a decoded PNG and must run the identical algorithm; any input the browser cannot see would make byte-for-byte parity impossible by construction.

8.2.2 In-memory representation #

// core/composite/types.ts

/** A single decoded sprite: one asset's art at one body/variant, before hue is applied. */
export interface Sprite {
  /** Natural key of the owning asset, e.g. "hair.long-wavy". */
  readonly assetKey: string;
  /** Body this sprite was extracted for. */
  readonly body: "m" | "f";
  /** The build this variant belongs to (Section 6.11, `asset_variants.build_id`). */
  readonly buildId: string;
  /** Pixel width and height. Both > 0. */
  readonly width: number;
  readonly height: number;
  /** Placement offset relative to canvas origin, at scale 1. */
  readonly offsetX: number;
  readonly offsetY: number;
  /** width * height * 4 bytes, RGBA8888, row-major, straight (non-premultiplied) alpha. */
  readonly pixels: Uint8ClampedArray;
  /** sha256 of `pixels` at extraction time, for provenance and cache-busting. */
  readonly contentHash: string;
}

/** One layer in a composite request: a sprite plus the hue to apply to it. */
export interface SpriteLayer {
  readonly slotKey: string;
  readonly sprite: Sprite;
  /** 0 = as-drawn (no hue applied). Any other value is a hue index into HueTable. */
  readonly hue: number;
  /** True when the owning asset is partial-hue (Section 8.3.3). */
  readonly partial: boolean;
  /** z-order from the slot registry in Section 3.3. Layers are composited ascending by z. */
  readonly z: number;
}

/** A resolved 32-entry ARGB1555-derived color ramp for one hue index, within one build. */
export interface HueEntry {
  readonly hueIndex: number;
  readonly buildId: string;
  readonly name: string;
  readonly source: "base" | "outlands" | "derived";
  /** Exactly 32 entries, each pre-unpacked to 8-bit RGB. Index 0 = darkest, 31 = lightest. */
  readonly colors: ReadonlyArray<readonly [r: number, g: number, b: number]>;
}

/** In-memory lookup structure for all hues of one build, keyed by hue index. */
export interface HueTable {
  readonly buildId: string;
  get(hueIndex: number): HueEntry | undefined;
}

/** Full input to a composite operation. */
export interface CompositeRequest {
  readonly body: "m" | "f";
  /** The design's pinned build. Every sprite and every hue is resolved within it (Section 8.9). */
  readonly buildId: string;
  readonly skinHue: number;
  /** Already sorted ascending by z; see Section 8.4. */
  readonly layers: ReadonlyArray<SpriteLayer>;
  readonly scale: 1 | 2 | 3;
}

/** Output of a composite + encode operation. */
export interface RenderResult {
  readonly format: "webp" | "png";
  readonly scale: 1 | 2 | 3;
  readonly width: number;
  readonly height: number;
  readonly bytes: Uint8Array;
  /** sha256 of `bytes`, stored as `design_renders.content_hash` (Section 8.9). */
  readonly contentHash: string;
}

Sprite.pixels uses Uint8ClampedArray because it is written to directly by ImageData-compatible code paths in the browser (Section 8.12) and clamps out-of-range arithmetic during alpha blending (Section 8.4) without extra bounds checks.

Sprite carries no geometry that the render URL does not: width, height, offsetX and offsetY come from the asset_variants row and are delivered to the browser by the catalog API (Section 8.12), not encoded in the image.

8.3 The hue algorithm #

Hue is UO's palette-remapping color system. SkinForge implements it exactly as the classic client does, because Section 8.12 requires the browser and server to produce pixel-identical output, and because a design's stored hue value (Section 13) must always render the same way regardless of which engine renders it.

This subsection is the sole definition of the algorithm. Section 3.6 introduces the concept in prose and defers here; Appendix B (Section 28.2) is a lookup card that reproduces the numbers below and adds none of its own. Where any other text appears to describe the algorithm differently, this subsection is correct.

8.3.1 ARGB1555 unpacking #

Source hue tables (hues.mul, Section 7.3.3, Section 28.1.3) store colors as 16-bit ARGB1555: 1 alpha bit, 5 bits red, 5 bits green, 5 bits blue. Unpacking to 8-bit-per-channel RGB uses bit replication (repeating the top 3 bits into the bottom 3) rather than a naive left-shift, so that full-scale 5-bit white (0x1F) unpacks to full-scale 8-bit white (0xFF), not 0xF8.

Alpha does not come from bit 15. The classic art convention is that the packed value 0x0000 is the transparency sentinel and every other packed value is opaque; bit 15 is not consulted anywhere in SkinForge. This matches the run-length color == 0 sentinel described in Section 28.1.2.

// core/hue/unpack.ts

export interface Rgba {
  readonly r: number;
  readonly g: number;
  readonly b: number;
  readonly a: number;
}

/**
 * Unpack one ARGB1555 value to 8-bit-per-channel RGBA.
 * Transparency comes from the packed value being exactly 0x0000; bit 15 is ignored.
 */
export function unpackArgb1555(c: number): Rgba {
  const r5 = (c >> 10) & 0x1f;
  const g5 = (c >> 5) & 0x1f;
  const b5 = c & 0x1f;
  return {
    r: (r5 << 3) | (r5 >> 2),
    g: (g5 << 3) | (g5 >> 2),
    b: (b5 << 3) | (b5 >> 2),
    a: c === 0x0000 ? 0 : 255,
  };
}

This is the only unpackArgb1555 in the codebase and the only signature it has. Every other section that shows unpacking reproduces this function verbatim.

8.3.2 Building the 32-entry table #

Each hue record in hues.mul (Section 28.1.3) stores exactly 32 ARGB1555 color values, ramped from darkest (index 0) to lightest (index 31), plus a start/end byte pair (legacy client hint, not used by SkinForge) and a 20-byte name. Import (Section 9) unpacks all 32 values once, at import time, into a HueEntry (Section 8.2.2) and persists the unpacked RGB triples in the hues table (Section 6.13) so the renderer never re-parses ARGB1555 at request time. Because hues is keyed (hue_index, build_id) (Section 6.13), each build carries its own table and a design pinned to an old build keeps the colors it was created with.

// core/hue/unpack.ts (continued)

import type { HueEntry } from "../composite/types.ts";

export function buildHueEntry(
  hueIndex: number,
  buildId: string,
  name: string,
  source: "base" | "outlands" | "derived",
  raw32: Uint16Array, // exactly 32 ARGB1555 values, darkest to lightest
): HueEntry {
  if (raw32.length !== 32) {
    throw new RangeError(`hue ${hueIndex}: expected 32 color entries, got ${raw32.length}`);
  }
  const colors = Array.from(raw32, (c): readonly [number, number, number] => {
    const { r, g, b } = unpackArgb1555(c);
    return [r, g, b] as const;
  });
  return { hueIndex, buildId, name, source, colors };
}

8.3.3 Full hue vs. partial hue vs. hue 0 #

The table index is read from the source pixel itself. For any pixel considered for remapping, the 32-entry table index is the pixel's own red channel reduced to 5 bits:

index = r8 >> 3        // range 0-31, exactly the 32 ramp positions

That is the whole index rule. There is no sidecar, no stored index, no luminance conversion and no per-pixel metadata. It is correct because UO paperdoll art is authored greyscale wherever huing is meant to apply — r8 === g8 === b8 on every huable pixel — so the red channel is the ramp position the original artist chose, at exactly the 5-bit resolution the source palette had.

Every SpriteLayer.hue value selects one of three behaviors:

  1. Hue 0 ("as-drawn") — pass the sprite's own RGBA pixels through unchanged. No table lookup, no index computation, no grey test. This is the default for every slot until the user picks a color, and is always valid regardless of asset.
  2. Full hue — every opaque source pixel is replaced by hue.colors[r8 >> 3]. The grey test is not applied; a coloured pixel is remapped by its red channel just like a grey one.
  3. Partial hue — a pixel is remapped only when its channels are exactly equal (r8 === g8 && g8 === b8), which is the definition of a true grey, black and white included. There is no tolerance window: the comparison is exact equality on the 8-bit values. Non-grey pixels (the pink of exposed skin, the leather-brown of a strap) pass through unchanged. Body, hair, beard and tattoo art is partial-hue by convention (Section 3.6): the sprite is drawn greyscale where huing applies and in full color where it must not.

A tolerance window was considered and rejected. Bit replication (Section 8.3.1) is exact, extraction performs no resampling (Section 7.8), and the source art is genuinely greyscale in its huable regions — so a tolerance would only capture pixels the artist deliberately drew off-grey, which is precisely the signal partial hue exists to respect. Exact equality also removes the last free parameter from the algorithm, which is what makes the zero-tolerance parity contract in Section 8.12 checkable.

Which of the two hue modes an asset uses is a property of the asset, not of the request: asset_variants.hue_mode (Section 6.11) holds full or partial. It is written by classification (Section 7.6) from the source layer's known convention — bodies, hair, beards and tattoos partial; cloth and leather goods full — and can be corrected per asset by a curator on the assets admin screen (Section 17.7), which is the authoritative override. The renderer reads hue_mode off the Sprite's owning asset variant and never guesses.

8.3.4 Alpha handling and the transparency convention #

  • Every sprite pixel has a real alpha channel (Section 8.2.1: RGBA8888). A pixel with alpha === 0 is never hued and never blended — it is skipped entirely during compositing (Section 8.4).
  • The packed-zero transparency convention is resolved at extraction time (Section 7, Stage D4/D6), not at render time: the extractor writes alpha = 0 for any source texel whose packed ARGB1555 value is 0x0000, exactly as unpackArgb1555 (Section 8.3.1) does, and alpha = 255 otherwise. By the time a Sprite reaches the renderer, alpha is already correct and the renderer performs no transparency special-casing of its own.
  • A consequence worth stating plainly: because 0x0000 is the sentinel, absolute black (r5 = g5 = b5 = 0) is not an expressible opaque color in the source format. The darkest opaque value the art can carry is r5 = g5 = b5 = 1, which unpacks to RGB (8, 8, 8). Extraction never needs to disambiguate the two, and the renderer never encounters an opaque (0, 0, 0) source pixel. Table index 0 (Section 8.3.3) is therefore reached only by an already-near-black pixel, never by a transparent one.
  • Anti-aliased edge pixels, where present in normalized art, keep their exact alpha value through hue and compositing; hue changes RGB only, never alpha.

8.3.5 Reference implementation #

Complete and runnable as written. No branch below is unreachable and no helper is left undefined.

// core/hue/apply.ts

import type { HueEntry, Sprite } from "../composite/types.ts";
import { RenderError } from "../errors.ts";

/**
 * Apply a hue to one sprite, producing a new RGBA buffer. Does not mutate the input.
 * `partial` is the owning asset variant's `hue_mode === "partial"` (Section 6.11).
 * `hue` must be the HueEntry for `hueIndex` within the request's build (Section 8.9).
 */
export function applyHue(
  sprite: Sprite,
  hue: HueEntry | undefined,
  hueIndex: number,
  partial: boolean,
): Uint8ClampedArray {
  const out = new Uint8ClampedArray(sprite.pixels.length);
  out.set(sprite.pixels); // hue 0 returns this identity copy unchanged

  if (hueIndex === 0) return out;
  if (!hue) {
    // The hue index passed URL validation but has no row in this build's hue table.
    throw new RenderError("SF-5005", `hue ${hueIndex} missing from build ${sprite.buildId}`);
  }

  const px = sprite.pixels;
  const n = sprite.width * sprite.height;

  for (let i = 0; i < n; i++) {
    const o = i * 4;
    if (px[o + 3] === 0) continue; // transparent: never hued, Section 8.3.4

    const r = px[o];
    const g = px[o + 1];
    const b = px[o + 2];

    if (partial && !(r === g && g === b)) continue; // non-grey under partial hue: pass through

    const [hr, hg, hb] = hue.colors[r >> 3]; // Section 8.3.3: index = r8 >> 3, always 0-31
    out[o] = hr;
    out[o + 1] = hg;
    out[o + 2] = hb;
    // out[o + 3] already carries the source alpha from the initial out.set(px).
  }

  return out;
}

hue.colors always has exactly 32 entries (Section 8.3.2 throws at import time otherwise) and r >> 3 on a Uint8ClampedArray read is always 0..31, so the lookup can never be out of range and needs no guard.

8.3.6 Worked example #

All four cases below are computed with the rules above and nothing else. Appendix B (Section 28.2) reproduces these same numbers; if the two ever disagree, this subsection is correct.

Example 1 — partial hue on a grey pixel.

Field Value
Source RGBA (96, 96, 96, 255) — a mid-grey, opaque
Asset hue_mode partial
Requested hue 1102, source = outlands
Grey test 96 === 96 && 96 === 96 → passes
Table index 96 >> 3 = 12
Hue 1102, table index 12 (ARGB1555) 0x4852

Unpack 0x4852 with Section 8.3.1: r5 = (0x4852 >> 10) & 0x1F = 18(18 << 3) | (18 >> 2) = 144 | 4 = 148. g5 = (0x4852 >> 5) & 0x1F = 2(2 << 3) | (2 >> 2) = 16 | 0 = 16. b5 = 0x4852 & 0x1F = 18148. a = 255 (the packed value is not 0x0000).

Result: RGB (148, 16, 148), a magenta-purple consistent with a dyed-hair Outlands hue. Output pixel: (148, 16, 148, 255).

Example 2 — full hue on a non-grey pixel.

Source pixel (180, 92, 40, 255), a leather-brown on a cloth asset whose hue_mode is full. Under full hue the grey test is skipped entirely, so this pixel is remapped like any other: table index = 180 >> 3 = 22. Suppose the requested hue's table index 22 is 0x6A4F: r5 = (0x6A4F >> 10) & 0x1F = 26(26 << 3) | (26 >> 2) = 208 | 6 = 214. g5 = (0x6A4F >> 5) & 0x1F = 18148. b5 = 0x6A4F & 0x1F = 15(15 << 3) | (15 >> 2) = 120 | 3 = 123. Output pixel: (214, 148, 123, 255). Note that only the red channel selected the entry — the source green and blue are discarded, which is why full hue flattens a multi-colored garment onto one ramp.

Example 3 — partial hue on the same non-grey pixel (pass-through).

Source pixel (180, 92, 40, 255), asset hue_mode = partial. The grey test 180 === 92 fails immediately, so the pixel is skipped before any index is computed. Output pixel: (180, 92, 40, 255) — byte-identical to the input.

Example 4 — hue 0.

Any pixel, any hue_mode: applyHue returns at the hueIndex === 0 guard and the output buffer is a byte-for-byte copy of the input. This is the identity path exercised by every render where the user has not chosen a color for a slot, and by every sprite the browser fetches (Section 8.12).

8.4 Composite order #

Layers composite bottom-to-top in the z-order given by the slot registry in Section 3.3 (column z, 10 through 190). CompositeRequest.layers (Section 8.2.2) must already be sorted ascending by z before reaching the compositor; the compositor itself does not sort, so callers (Section 8.10, Section 8.12) are responsible for ordering. body (z=10) is always present and always drawn first; backpack (z=190) is always present and always drawn last — it is a designable slot like any other cosmetic slot, and when the visitor has not chosen one it resolves to the default asset key backpack.default (Section 3.3, Section 13.2.1). Slots with no selection (hue 0 aside — "no selection" means the slot is entirely absent from CompositeRequest.layers, distinct from "hue 0 on a chosen asset") contribute no layer at all.

All 19 slots in the registry are therefore representable in one composite: one required body plus 18 choosable cosmetic slots, backpack included.

Alpha-over (source-over) compositing, per pixel, top layer over the accumulated result so far:

// core/composite/canvas.ts

export function compositeOver(
  dst: Uint8ClampedArray, // canvas accumulator, RGBA8888, straight (non-premultiplied) alpha
  dstW: number,
  src: Uint8ClampedArray, // already hued sprite pixels, RGBA8888, straight alpha
  srcW: number,
  srcH: number,
  offsetX: number,
  offsetY: number,
  canvasW: number,
  canvasH: number,
): void {
  for (let sy = 0; sy < srcH; sy++) {
    const dy = offsetY + sy;
    if (dy < 0 || dy >= canvasH) continue; // Section 8.1 overflow clipping
    for (let sx = 0; sx < srcW; sx++) {
      const dx = offsetX + sx;
      if (dx < 0 || dx >= canvasW) continue;

      const so = (sy * srcW + sx) * 4;
      const sa = src[so + 3] / 255;
      if (sa === 0) continue;

      const doff = (dy * dstW + dx) * 4;
      const da = dst[doff + 3] / 255;
      const outA = sa + da * (1 - sa);
      if (outA === 0) {
        dst[doff] = dst[doff + 1] = dst[doff + 2] = dst[doff + 3] = 0;
        continue;
      }
      dst[doff]     = (src[so]     * sa + dst[doff]     * da * (1 - sa)) / outA;
      dst[doff + 1] = (src[so + 1] * sa + dst[doff + 1] * da * (1 - sa)) / outA;
      dst[doff + 2] = (src[so + 2] * sa + dst[doff + 2] * da * (1 - sa)) / outA;
      dst[doff + 3] = outA * 255;
    }
  }
}

compositeOver handles partial clipping only. The entirely-outside check that raises SF-5006 (Section 8.1) lives one level up, in renderDesign (core/composite/render-design.ts), which asserts offsetX + width > 0 && offsetX < canvasW && offsetY + height > 0 && offsetY < canvasH per layer before calling the compositor.

  • Premultiplication policy: buffers are stored and exchanged as straight (non-premultiplied) alpha everywhere in the pipeline — in Sprite.pixels, in the composite accumulator, and in encoder input. The compositor above does the premultiply/un-premultiply arithmetic internally, per pixel, so no buffer at rest is ever premultiplied. This avoids an entire class of double- premultiplication bugs when the same buffer is reused across the WASM encoder boundary (Section 8.7) and the browser ImageData boundary (Section 8.12), both of which expect straight alpha.
  • Determinism: the algorithm above uses only integer/float arithmetic with no random component, no platform-dependent rounding modes, and Uint8ClampedArray writes (which round-to-nearest and clamp to 0..255 per the Web IDL specification for Uint8ClampedArray, identical in Deno's V8 and browser V8/SpiderMonkey/JavaScriptCore engines). Given the same CompositeRequest, the output is byte-identical across runs, processes, and the server/browser boundary. This determinism is what makes content-addressed caching (Section 8.9) and the golden-image parity test (Section 8.12, Section 22.3) valid.

8.5 Gender variant resolution #

  • CompositeRequest.body is always exactly m or f (Section 3.2). asset_variants.body (Section 6.11) takes exactly those same two values — there is no both value and no body-agnostic variant. A sprite shared between bodies is stored as two rows, one per body, by extraction (Section 7.6).
  • Resolution rule: for a chosen asset_key, the request's body and the request's buildId, the renderer looks up the single asset_variants row matching (asset_id, body, build_id). There is no fallback lookup, because there is nothing to fall back to.
SELECT v.*
  FROM asset_variants v
  JOIN assets a ON a.id = v.asset_id
 WHERE a.asset_key = $1
   AND v.body      = $2
   AND v.build_id  = $3;

The build_id predicate is not optional. Dropping it is the exact failure Sections 9.8 and 13.7 forbid: an old permalink would silently re-render with art from a later patch. The same predicate applies to the hue lookup (Section 8.9).

  • A missing variant is not a render failure. If that query returns no row, the layer is omitted from the composite, the omission is recorded as a render.layer_skipped event (Section 21.2) with the design code, slot key and asset key, and the render completes normally with the remaining layers. Failing the whole image because one cosmetic slot has no art for the selected body would make the live body toggle destructive and would turn a data gap into a broken permalink. This also matches the non-destructive body-switch behaviour Section 11.13 specifies for the designer: an incompatible slot is retained in the editor's state, contributes no layer while incompatible, and is dropped from the canonical document at Share time (Section 13.3).
  • A direct single-asset render request (/render/a/..., Section 8.8) naming an asset/body pair with no variant is a different case: nothing else in that request would render, so it is answered 400 SF-1017 ("That item isn't available for the selected body.") rather than an empty image.
  • facial_hair male-only rule: facial_hair asset variants only ever exist with body = m; no f-body facial_hair variant is ever imported (Section 9 rejects one if the classification pipeline proposes it, Section 7, Stage D5). A design document carrying a facial_hair entry with body = "f" fails validation at creation time (Section 13.2.5, SF-1017) and so never reaches the compositor; a live editor state in that shape simply drops the layer per the rule above.
  • Body-switch handling in the live editor (choosing an asset, then switching body, when that asset has no variant for the new body) is a UI behaviour owned by Section 11.13; this section guarantees only that the render layer resolves the combination deterministically and observably, so the UI has a reliable signal to react to.

8.6 Scaling #

Scales 1, 2, 3 are produced by integer nearest-neighbour upscaling applied once, after the full composite is finished at scale 1 (260×330). Compositing and hue math never run at any scale other than 1.

  • Why smoothing is forbidden: all source art is hand-drawn pixel art at native UO resolution. Bilinear or bicubic interpolation blurs hard pixel edges and introduces new intermediate colors not present in any hue table, which breaks the visual identity of the tool and would make per-pixel parity testing (Section 8.12) meaningless (interpolation is not perfectly reproducible across codecs/engines the way nearest-neighbour integer replication is).
  • Exact algorithm: for scale factor k (2 or 3), output pixel (x, y) for 0 <= x < 260k, 0 <= y < 330k equals input pixel (floor(x / k), floor(y / k)). Implemented as direct row/column replication, not a general resampling filter:
// core/composite/scale.ts

export function scaleNearest(
  src: Uint8ClampedArray, srcW: number, srcH: number, k: 1 | 2 | 3,
): { pixels: Uint8ClampedArray; width: number; height: number } {
  if (k === 1) return { pixels: src, width: srcW, height: srcH };
  const dstW = srcW * k;
  const dstH = srcH * k;
  const dst = new Uint8ClampedArray(dstW * dstH * 4);
  for (let y = 0; y < dstH; y++) {
    const sy = (y / k) | 0;
    for (let x = 0; x < dstW; x++) {
      const sx = (x / k) | 0;
      const so = (sy * srcW + sx) * 4;
      const doff = (y * dstW + x) * 4;
      dst[doff] = src[so]; dst[doff + 1] = src[so + 1];
      dst[doff + 2] = src[so + 2]; dst[doff + 3] = src[so + 3];
    }
  }
  return { pixels: dst, width: dstW, height: dstH };
}

8.7 Encoding #

  • WebP lossless is the default output format for all render URLs (Section 8.8) that omit an explicit extension resolution preference and for every internal/preview use. Lossless mode is mandatory (never lossy WebP) because hue-mapped flat color regions and hard pixel edges compress losslessly to a small size with zero artifacting, and any lossy quantization would defeat the golden-image parity guarantee in Section 8.12.
  • PNG is produced for: (a) the /render/* .png extension explicitly requested, (b) the file offered by the download feature (Section 12.7), because PNG has the widest compatibility for users saving an image locally, and (c) every input to OG image generation (Section 14), because satori embeds images as data URLs and PNG avoids a second WebP-decode dependency in that pipeline. PNG uses standard filtering (adaptive, encoder default) with maximum compression effort — render outputs are generated once and cached forever (Section 8.9), so encode time is not latency-sensitive on the cache-hit path.
  • Encoder settings:
Format Encoder Mode Effort/quality
WebP @jsquash/webp (Section 4.1) lossless quality: 100, lossless: 1, method: 6
PNG @jsquash/png (Section 4.1) n/a (PNG is always lossless) encoder default (adaptive filtering, zlib level 9)
  • Expected byte sizes: at scale 1 (260×330, mostly-transparent RGBA), a typical fully-dressed composite encodes to 8–18 KB as lossless WebP and 12–28 KB as PNG. At scale 3 (780×990), 25–55 KB WebP and 40–90 KB PNG. A single-asset variant render (Section 8.8, /render/a/...) is smaller, typically 1–6 KB at scale 1. These figures inform the storage growth estimate in Section 8.9 and Section 14.8.
  • WASM codec lifecycle: @jsquash/webp and @jsquash/png ship WebAssembly modules that must be instantiated before use. In the long-running Deno server process, both modules are instantiated exactly once at process startup (top-level await in core/codec/png.ts and core/codec/webp.ts, both imported by the server entrypoint before it accepts connections) and the resulting encoder/decoder handles are held in module-level singletons for the process lifetime. Encode calls are synchronous CPU-bound WASM calls with no per-call instantiation cost. In the browser (Section 8.12), the same module is lazy-instantiated on first use inside the designer island and cached on globalThis for the page session, since the browser only needs client-side compositing for live preview (which uses Canvas natively, Section 12.3) and rarely needs to encode — the browser path encodes only when a user explicitly triggers a client-side download fallback (Section 12.7) with no network available.
  • Failure mode: if WASM instantiation fails at server startup (corrupt bundle, unsupported runtime), the process exits non-zero at boot with a clear log line — this is a deploy-blocking condition, not a runtime-degraded condition, because every render request depends on the encoder.

8.8 The render URL scheme #

Three render URL shapes, all content-addressed and immutable once resolved:

Shape Example What it renders
Design composite /render/d/:code@:scale.:ext The full paperdoll for one saved design
Single asset variant /render/a/:assetKey/:body/:hue@:scale.:ext One sprite on its own, on the standard canvas
Slot representative preview /render/s/:slotKey/:body@:scale.:ext The slot's representative asset, at hue 0

The slot representative preview exists so that a caller wanting "a picture of this slot" does not have to first discover which asset represents it. It renders exactly one sprite: the slot's representative asset for that body, defined as the non-retired asset with the lowest assets.display_order and, on ties, the lowest asset_key, resolved within the currently published build — drawn at hue 0 on the same 260×330 canvas as every other render, with the same recorded offset. Its consumer is the per-slot card on /catalog (Section 15.2). A slot with no published asset for that body returns a valid, fully transparent 260×330 image with 200 rather than an error, because an empty slot is a legitimate state while a catalog is being populated (Section 25.4). It never composites more than one sprite: there is no multi-asset strip anywhere in the product, and the per-asset tiles of the picker grid come from /render/a/... instead (Section 12.5).

  • :code — 10-character Crockford Base32 design short code (Section 13.4). Case-insensitive on lookup; the canonical form is lowercase, matching the lowercase-URL naming rule in Section 5.3. A request carrying uppercase letters issues a 301 redirect to the lowercase canonical URL.
  • :assetKey — the asset's natural key, e.g. hair.long-wavy. Asset keys match ^[a-z0-9]+(?:[-_.][a-z0-9]+)*$ with a maximum length of 128 characters — the canonical pattern defined in Section 13.2.5 and enforced identically here. That alphabet contains no character requiring percent-encoding, so an asset key always appears literally in the path; a value that does not match the pattern is rejected before any lookup, which is also what keeps the object-key construction in Section 8.9 free of path traversal.
  • :body — exactly m or f.
  • :hue — a non-negative decimal integer, 0 meaning as-drawn.
  • :slotKey — one of the 19 slot keys in the registry in Section 3.3.
  • :scale — exactly 1, 2, or 3.
  • :ext — exactly webp or png.

Parameter validation (evaluated in this order; first failure wins). Every code below is the code the registry in Section 25.2 defines for that condition — Section 25.2 is the sole authority for SF- codes and this table quotes it rather than assigning its own:

Check Failure status Error code (Section 25.2)
:ext not webp/png 400 SF-1013
:scale not 1/2/3 400 SF-1012
:hue not a non-negative integer 400 SF-1004
:hue parses but is outside the allowed hue range 400 SF-1005
:body not m/f 400 SF-1003
:assetKey does not match the asset-key pattern above 400 SF-1006
:slotKey not one of the 19 slot keys in Section 3.3 404 SF-2004
:code fails normalization or has no matching design 404 SF-2000
:assetKey well-formed but no matching asset 404 SF-2001
:assetKey/:body well-formed but no variant in the resolved build (Section 8.5) 400 SF-1017
:hue well-formed but no hues row for that index in the resolved build 404 SF-2002
:code resolves to a design removed by takedown (Section 20.8) 410 SF-2005
Well-formed request for a retired asset or hue that still has a row in the resolved build 200 — (renders normally from the archived row)

Malformed and unknown design codes are deliberately indistinguishable. Both return 404 SF-2000 with an identical body and message ("We couldn't find that design. The link may be mistyped."). A shape check that returned 400 for a bad alphabet and 404 for a valid-but-unknown code would be a free enumeration oracle on the highest-volume endpoint in the system, letting a scraper filter its guess space without ever spending a database lookup. Section 13.4.1 states the same rule for the permalink route, and the two must never drift apart.

A request outside these shapes entirely (an unknown path prefix under /render/) is a plain 404 with SF-2006 (route not found), not one of the specific codes above.

Canonical redirect rules: uppercase design codes redirect (301) to lowercase. No other redirects exist in the render namespace — asset keys, slot keys and hue numbers are compared exactly (case-sensitive for keys, numeric equality for hue) with no normalization beyond what is stated above, because they are always generated in canonical form by the systems that link to them (Section 10, Section 15).

Content negotiation is not offered. The extension in the path is authoritative and the Accept header is never consulted in this namespace. No request here can therefore produce 406.

One query parameter is recognized. /render/d/:code@:scale.png?dl=1 is the download variant owned by Section 12.7: identical pixels, plus PNG tEXt metadata. It bypasses the cache entirely — no design_renders row, no stored object — and is served Cache-Control: no-store. It is valid only on the .png design-composite shape; ?dl=1 on any other shape or extension is ignored. Every other query parameter is ignored on this namespace.

415 behaviour: :ext values outside webp/png are rejected as 400 SF-1013, not 415, because the extension is a routing parameter parsed from the path rather than a negotiated content type — there is no request body and no Content-Type header to be "unsupported", so 415 Unsupported Media Type never applies to this GET-only namespace. This paragraph exists to make that decision explicit rather than leaving 415 an unstated option.

Cross-origin headers. Every /render/* response carries Access-Control-Allow-Origin: <SKINFORGE_PUBLIC_BASE_URL> (Section 24.2) and Cross-Origin-Resource-Policy: cross-origin. This is not for a JSON API — it is what keeps the browser canvas untainted when images are served from a storage or CDN origin that differs from the page origin. Without it the client compositor's getImageData read (Section 12.3) and the print snapshot in Section 12.8 both throw SecurityError, and client-side compositing stops working the moment a CDN is placed in front of the render routes. Every <img> and fetch the browser uses for compositing sets crossorigin="anonymous" to complete the pair.

Method support. GET and HEAD only. Any other method is 405 with an Allow: GET, HEAD response header.

8.9 Cache keys and storage layout #

  • Cache key: sha256(buildHash + canonicalDesignJson + scale + format) for design composites, where buildHash is game_builds.build_hash (Section 6.4) for the design's recorded build_id and canonicalDesignJson is the exact canonical byte string defined in Section 13.3, read verbatim from designs.canonical_json (a TEXT column, Section 6.18, precisely so those bytes survive a round trip). For single-asset and slot-representative renders the same formula applies with a synthetic canonical payload ({"v":1,"assetKey":…,"body":…,"hue":…} or {"v":1,"slotKey":…,"body":…} respectively, under the same canonicalization rules: sorted keys, no whitespace, UTF-8) in place of the design JSON, still salted with buildHash so a patch re-import (Section 9) that changes an asset's art invalidates old single-asset renders too.
  • Everything is resolved inside one build. Both the asset variant (Section 8.5) and the hue (Section 8.3.2) are looked up with a build_id predicate equal to the design's pinned build_idasset_variants is keyed UNIQUE (asset_id, body, build_id) and hues is keyed UNIQUE (hue_index, build_id) (Section 6.11, Section 6.13). Because buildHash is a function of exactly those rows, the cache key and the pixels it addresses cannot disagree. A hue lookup that omits build_id would re-render an old design with new colours, which is the failure Sections 9.8 and 13.7 forbid outright; it is called out here because it is the single easiest predicate in the system to leave off.
  • Object-key prefixes — the canonical, complete set for the whole product:
Prefix Contents Owner Regenerable?
catalog/ Normalized per-variant art from Stage D6 (.webp, .png) Section 7.8 No — needs the operator's original client files
renders/ Content-addressed composite, single-asset and slot renders This section Yes
og/ Generated Open Graph cards Section 14.5 Yes
imports/ Admin-visible import run artifacts and logs Section 17.5.3 Yes

No other prefix is created by any part of the system. Backup and purge runbooks (Section 23.6, Section 23.8) operate on exactly these four names.

  • Render object key: renders/:first2/:next2/:hash.:ext, where :first2 and :next2 are the first four hex characters of the cache key split into two 2-character path segments (standard fan-out to avoid unbounded directory sizes on the fs storage driver, Section 4.7). :hash is the full 64-character lowercase hex sha256 cache key. Because the key is a hash the path can never contain a traversal sequence, and the asset-key validation in Section 8.8 means no user-supplied string reaches an object key at all.
  • The cache row: one design_renders row (Section 6.19) per cached render, carrying design_id (NULL for single-asset and slot renders), render_kind (design, asset or swatch), cache_key (unique), storage_key, format, scale, byte_size, content_hash, created_at and last_served_at. storage_key is the column name — Section 6.19 owns it, and this section uses no other name for it. content_hash is the sha256 of the encoded bytes and is what the corrupt-entry check in Section 8.14 verifies against on read. The row is the database half of the two-part cache lookup in Section 8.10.
  • Cache headers: every successfully resolved render response carries Cache-Control: public, max-age=31536000, immutable (the render row of the matrix in Section 19.3) plus Content-Type: image/webp or image/png and ETag: "<cache_key>". Because the URL is content-addressed, a 304 on If-None-Match is valid and cheap (a string compare, no re-encode).
  • The application sets Cache-Control, not the proxy. Values on this namespace are status-dependent, so the reverse proxy (Section 23.2) must fill in a Cache-Control header only when the application did not set one, and must never overwrite one. A blanket immutable rule on /render/* would keep a taken-down image alive at the edge for a year after the takedown, which is a legal exposure and not merely a caching bug. The status-dependent values are:
Response Cache-Control
200 resolved render public, max-age=31536000, immutable
404 definitive not-found (unknown code/asset/hue/slot) public, max-age=60
410 taken down (Section 20.8) public, max-age=86400
400 validation failure no-store
5xx and 503/504 transient failures no-store
200 ?dl=1 download variant (Section 8.8, Section 12.7) no-store
  • Negative caching: the short max-age=60 on definitive 404s absorbs scraper and bot retry storms at the CDN without re-querying Postgres on every hit, while staying short enough that a design saved seconds ago becomes visible almost immediately.
  • Build publish and old links: publishing a new build (Section 9.5) changes game_builds.build_hash going forward for new designs, but every existing designs row keeps its original build_id (Section 13.7) and therefore its original cache-key namespace. A publish never deletes or invalidates design_renders rows for prior builds — old permalinks keep resolving to the exact bytes they always have, from the same object key. No cache-busting or migration step is needed on publish; content addressing makes old and new renders coexist by construction.
  • Pruning derived artifacts: design_renders rows and their objects are regenerable and MAY be pruned (Section 14.8 does the same for OG cards); regeneration lands at the identical object key, so a pruned render is invisible to callers apart from one cold render. The designs row itself is never pruned — the only removal path for a design is the takedown process in Section 20.8, which yields 410. Foreign keys on derived rows are ON DELETE CASCADE (Section 6.19, Section 6.20) so that a takedown can remove the derived artifacts it must.

8.10 The render pipeline at runtime #

Request lifecycle for any /render/* URL:

  1. Validate — parse and validate the URL per Section 8.8. Invalid requests short-circuit here.
  2. Resolve inputs — look up the design (or asset/slot), body, hue and build; assemble the would-be CompositeRequest and compute its cache key (Section 8.9) without doing any pixel work yet. Every lookup carries the build_id predicate from Section 8.5 and Section 8.9.
  3. Cache lookup — select the design_renders row with this exact cache_key. If found, issue a HEAD (or consult the short-TTL in-process existence cache, Section 8.10.1) against object storage for its storage_key; if the object is present, stream it with the headers from Section 8.9 and update last_served_at. This is the cache-hit path and is the overwhelming majority of production traffic — every previously viewed permalink, asset tile and slot preview.
  4. Cache miss → claim — insert a jobs row (Section 6.21) with job_type = 'render' and dedupe_key = <cache_key>, ON CONFLICT DO NOTHING. The arbiter is the partial unique index jobs_dedupe_key_idx ON jobs (dedupe_key) WHERE dedupe_key IS NOT NULL AND status IN ('queued','running') (Section 6.21), so exactly one in-flight job can exist per cache key while allowing the same key to be rendered again later. If the insert is a no-op (another request already claimed this render), skip to step 6 and poll.
  5. Composite + encode (claimer only) — load sprites (from an in-process LRU sprite cache backed by object storage, Section 8.13) and the build's hue table (loaded into process memory at startup and refreshed on publish, Section 8.13), run Section 8.4's compositor, Section 8.6's scaler and Section 8.7's encoder, write the object to storage, insert the design_renders row, and mark the job succeeded (the status vocabulary is queued | running | succeeded | failed | cancelled, defined once in Section 6.21 — there is no done state).
  6. Serve — the claimer streams its own freshly encoded bytes directly to its own HTTP response, with no extra storage round-trip. A request that lost the claim race in step 4 polls the jobs row (short interval, Section 8.10.1) until it reaches a terminal status, then re-runs step 3's storage read, capped by the render timeout in step 8.
  7. Concurrency limit — the number of simultaneous in-flight composite+encode operations (step 5) across the process is bounded by SKINFORGE_RENDER_MAX_CONCURRENCY (Section 24.2; default 4). Requests beyond the limit queue in-process on a FIFO semaphore rather than being rejected, up to the timeout in step 8. If the in-process queue itself is saturated beyond that bound, the request is shed with 503 SF-5004 ("render queue full") and Retry-After: 2 rather than being held until it times out.
  8. TimeoutSKINFORGE_RENDER_TIMEOUT_MS (Section 24.2; default 8000) bounds the total time a request may spend from step 4 (claim) to a served response, whether it is claiming or polling. On timeout the server responds 504 with SF-5002 ("That image is taking too long to generate. Please try again.") and Retry-After: 2; the underlying job, if still the claimer's, keeps running to completion in the background so the next request — including the client's own retry — hits the now-warm cache. 504 rather than 503 because the failure is an upstream generation timeout, and because that is the status Section 25.2 assigns SF-5002.

8.10.1 Queued-request response and polling #

A request that is polling (having lost the claim race in step 6) never blocks the HTTP event loop: it awaits the jobs row with exponential backoff starting at 50 ms, doubling to a 500 ms ceiling, checked against the SKINFORGE_RENDER_TIMEOUT_MS deadline measured from the moment the original request arrived, not from when polling started. There is no separate "202 Accepted, check back later" response — because render latency at any single scale/format is small (Section 8.13's budget), the design keeps the request open and streams the final image once it is ready, which is what <img> tags and crawlers can actually consume. A 202 on this namespace would be rendered by every browser as a broken image.

When a permalink is shared widely and a burst of first-time requests for the same :code@:scale.:ext arrives before any render exists:

  • All but one request lose the jobs insert race (step 4) and become pollers (Section 8.10.1). Only one composite+encode operation ever runs for that exact cache key, regardless of burst size. Different scale/ext combinations for the same design are different cache keys and therefore different jobs — but Section 8.11's pre-warming generates the common combinations at save and publish time specifically so a viral burst almost always lands on step 3 (cache hit) rather than step 4. Uncommon combinations (say @3.png for a design nobody pre-warmed at that scale) still collapse to a single job under this scheme.
  • The concurrency limit (step 7) protects the process from unrelated renders (other designs, asset tiles) being starved by the burst, since the burst itself only ever occupies one concurrency slot no matter how many HTTP requests are waiting on it.
  • If the single in-flight job fails (Section 8.14), the jobs row is marked failed with an error reason; all pollers waiting on it receive the mapped error response from Section 8.14 rather than hanging until the timeout. Because the unique index in step 4 is partial on status IN ('queued','running'), marking the row failed immediately frees the key for a new claim, so the very next request retries the render from scratch instead of the failure being cached.

8.11 Pre-warming #

This subsection owns when renders are generated. No other section decides it.

Generated eagerly, synchronously as part of the write transaction described in Section 13.5 (design save) and Section 9.5 (build publish):

  • On design save: the design composite at scale=2, format=webp — the exact variant the permalink page embeds (Section 13.8) — and at scale=1, format=png — the exact variant Open Graph generation consumes (Section 14.3). Both are rendered inline before the save response is returned. Warming the variant the page actually requests is the whole point: warming some other combination would leave the first visitor to a freshly shared permalink paying a cold render anyway. Two renders at the budgets in Section 8.13 add well under 200 ms to a save.
  • On build publish (Section 9.5): every (assetKey, body, hue=0, scale=1, format=webp) single-asset render for every published asset — these are exactly the tiles the picker grid and the catalog grid request (Section 12.5, Section 15.3) — plus every (slotKey, body, scale=1, format=webp) slot representative preview (19 slots × 2 bodies = 38 renders) for the catalog overview cards (Section 15.2). This batch is bounded and known-size, and runs inside the same background job runner as import (Section 9) so it never blocks the curator's publish action.

Generated lazily — the first request triggers the pipeline in Section 8.10 — for everything else:

  • All other (scale, format) combinations of an already-warmed design (@1.webp, @3.webp, @2.png, @3.png).
  • Every single-asset render at a hue other than 0, and every single-asset render above scale 1.
  • Any render for a design created before this rule existed, if ever restored from a backup (Section 23.6) that predates it.

This split keeps save-time and publish-time cost bounded while guaranteeing that the two hottest paths — a shared permalink and a catalog browse — never show a cold-render delay to a real visitor.

8.12 Client-side compositing parity #

The designer island (Section 11) composites live, in the browser, on every user interaction (asset pick, hue pick, body toggle) for instant feedback, using the identical core/hue/ and core/composite/ TypeScript modules described in this section, compiled for the browser by the same build step that produces the Fresh island bundle. There is no separate reimplementation and no server round-trip per interaction.

What the browser fetches, exactly. Three things, and nothing else:

  1. Sprite pixels, as /render/a/:assetKey/:body/0@1.png — always hue 0, always scale 1, always PNG — one request per (assetKey, body) pair per page session, loaded with crossorigin="anonymous" (Section 8.8). Decoded via createImageBitmap into an OffscreenCanvas and read back as a Uint8ClampedArray through getImageData.
  2. Sprite geometrywidth, height, offsetX, offsetY and the asset's hue_mode — from GET /api/v1/assets?slot=<slotKey> (Section 16.8.3), fetched with the catalog data the picker already needs. The render URL deliberately carries no geometry, so this is where placement comes from.
  3. The hue table for the current build, once, as GET /api/v1/hues?full=true (Section 16.8.5), held in memory for the page session. Hue changes — the most frequent interaction — are therefore entirely local, with zero network round-trips after mount.

Why the two paths produce identical bytes. This is the contract that makes the whole client/server split safe, and it is buildable only because the algorithm has no hidden input:

  • Same source module. applyHue (Section 8.3.5), compositeOver (Section 8.4) and scaleNearest (Section 8.6) are one implementation, compiled twice.
  • Same inputs, provably. The hue index for a pixel is r8 >> 3, read from the pixel itself (Section 8.3.3). There is no sidecar, no per-pixel metadata and no extraction-time annotation that the browser would have no way to obtain — which is precisely why the index rule is defined that way. A PNG carries the full RGBA the server composites from, so the browser holds the identical bytes the server holds.
  • Lossless transport. PNG is lossless, so createImageBitmap + getImageData in the browser and @jsquash/png decode on the server both recover the exact RGBA values written at extraction time (Section 7.8). The image is fetched at hue 0 so no server-side remapping has been applied to it.
  • Same arithmetic. Both run IEEE-754 double arithmetic with Uint8ClampedArray writes, which round-half-to-even and clamp to 0..255 identically per Web IDL — the same behaviour in Deno's V8 and in every evergreen browser engine.
  • Same order. Layers are sorted ascending by z (Section 3.3) before reaching the compositor in both environments; the compositor never sorts.

Tolerance: zero. Not "visually similar", not "within N/255 per channel". The server-side render used for the permalink's <img>, for downloads and for OG images, and the last client-side frame the user saw before pressing Share, must match exactly, pixel for pixel, so what a user shares is provably what they designed.

Enforcement: Section 22.3 requires a golden-image suite that runs the same fixed set of CompositeRequest values through both the server renderer (in Deno) and a headless-browser instance of the client renderer (via Playwright, Section 22.5), asserting sha256(serverOutput) === sha256(browserOutput) for every fixture on every CI run. Any change to core/hue/ or core/composite/ that breaks parity fails CI before it can merge.

Content Security Policy dependency. Client-side compositing needs 'wasm-unsafe-eval' in script-src for the browser to compile the WebAssembly codec module, and the storage/CDN origin in img-src and connect-src for the sprite fetches to be permitted at all. Section 20.4's public CSP grants exactly that: script-src 'self' 'wasm-unsafe-eval' 'nonce-<per-response>', img-src 'self' data: blob: <public storage origin> and connect-src 'self' <public storage origin>. Without 'wasm-unsafe-eval' the compositor cannot run in Chromium-family browsers at all and the parity test in Section 22.3 cannot pass; this dependency is stated here because it is invisible from the security section's side.

JS-disabled / API-unavailable fallback: if OffscreenCanvas or createImageBitmap is unavailable, or JavaScript is disabled, the designer island does not attempt client-side compositing at all. The live preview area instead shows the server-rendered <img src="/render/d/:code@2.webp"> for the currently-encoded URL state (Section 13.6's transient query-string form still round-trips through a debounced navigation, Section 12.3), updating on each change via a normal page/query update rather than canvas redraws. This is strictly a progressive-enhancement fallback: the tool remains fully usable, just without sub-100 ms live feedback. Section 12.3 states the debounce and request-coalescing behaviour for this path.

8.13 Performance budgets #

Metric Budget
Per-layer hue application, single sprite, scale 1 < 1.5 ms server CPU (typical sprite ≤ 64×64 visible art within the 260×330 frame)
Full composite (19 layers max — one per slot in Section 3.3), scale 1, hue + blend, cache miss < 25 ms server CPU
Full composite, scale 3 (post-composite upscale, Section 8.6) < 10 ms additional (nearest-neighbour replication is O(pixels), no per-pixel math)
Encode, WebP lossless, scale 1 < 15 ms
Encode, WebP lossless, scale 3 < 60 ms
Encode, PNG, scale 1 < 20 ms
Memory ceiling per in-flight render 16 MB (sprite buffers + accumulator + encoder scratch, worst case scale 3 RGBA accumulator = 780×990×4 ≈ 3.1 MB, remainder is codec working memory)
Cold render, end-to-end (cache miss, p50) < 60 ms
Cold render, end-to-end (cache miss, p95) < 150 ms
Warm render, end-to-end (cache hit, storage-backed, p50) < 15 ms
Warm render, end-to-end (cache hit, p95) < 40 ms
Warm render, end-to-end, CDN-cached (edge hit, Section 19) < 10 ms, network-bound, not server-bound

These budgets assume the reference deployment topology (Section 23: single VPS, local or same-DC object storage) and the SKINFORGE_RENDER_MAX_CONCURRENCY default of 4 (Section 24.2). They are verified by the performance test gate in Section 22.7.

8.14 Error handling #

Every code in this table is the code the registry in Section 25.2 assigns to that condition, with the status Section 25.2 assigns it. This table quotes the registry; it does not extend it.

Condition Error code (Section 25.2) HTTP User-visible outcome
Missing sprite — the asset_variants row exists in the resolved build, but its catalog/:assetKey/:body/:hash12/1.png object is absent from storage SF-5000 500 A server-side data fault, never a 404: the asset is real and the art is broken. The response is a generic "We couldn't render that combination." error, the incident is logged at error on render.failed (Section 21.2) for staff follow-up, and a neutral placeholder image is served with Cache-Control: no-store so nothing broken is ever cached at the edge
Unknown hue — the hue index has no hues row for the resolved build SF-2002 404 Per Section 8.8's validation table
Hue table row exists but is unusable (wrong length, unreadable colors) once loaded SF-5005 500 Thrown by applyHue (Section 8.3.5); logged; retryable
Retired asset or hue referenced by an existing, immutable design — (not an error) 200 Renders normally from the archived row. Soft-delete keeps rows renderable forever (Section 9.7), which is what makes permalink immutability true
Asset exists in the catalog but has no asset_variants row in the build the request resolves against SF-2007 404 Not the same as "retired": a retired asset that still has a row in the resolved build renders normally (Section 8.8, Section 16.8.4). This code fires only when the row is genuinely absent from that build
Design layer references an asset/body pair with no variant in the resolved build — (not an error) 200 The layer is omitted, a render.layer_skipped event is logged, and the remaining layers render (Section 8.5). A direct /render/a/... request for the same pair is 400 SF-1017 instead, because nothing would remain to render
Corrupt cache entry — object storage returns bytes that fail verification against design_renders.content_hash and byte_size on read SF-5008 500 The design_renders row and its object are deleted in one transaction and the request re-enters the pipeline at Section 8.10 step 4 as a cache miss. If verification fails twice in a row for the same key, SF-5008 is returned to the client and the incident logged, so a systemically broken storage backend cannot drive an infinite regenerate loop
Object storage read or write error during a render (not a corruption — the backend is unreachable or refuses) SF-5003 502 Retryable; logged; no partial object is left behind
Encoder failure — the WASM encode call throws, e.g. out-of-memory on a pathological input SF-5001 500 The render job is marked failed (Section 8.10.2); no partial object is ever written, because the write happens only after a successful encode
A sprite's destination rectangle lies entirely outside the canvas (Section 8.1) — a corrupt recorded offset SF-5006 500 Logged for staff follow-up; partial overflow is clipped silently and is not this condition
Render queue saturated beyond the in-process bound (Section 8.10 step 7) SF-5004 503 Retry-After: 2; safe to retry
Render timeout exceeded (Section 8.10 step 8) SF-5002 504 Retry-After: 2; safe to retry, and the underlying job continues in the background so the retry is warm
Design removed by takedown (Section 20.8) SF-2005 410 The permanent removal path; 410 specifically so caches drop the URL

Two rules bind every row above:

  • No render failure is ever cached. Every 4xx other than the definitive not-founds in Section 8.9, and every 5xx, is served Cache-Control: no-store. A one-year immutable header on a failure would make a transient outage permanent from the CDN's point of view.
  • A placeholder is a response body, never a cache entry. The placeholder image served alongside SF-5000 is a static, neutral asset; it is not written to renders/, it gets no design_renders row, and it therefore cannot be mistaken for a real render once the underlying data fault is fixed.

9. Asset Ingestion, Versioning & Patch Re-Sync #

9.1 The Build Model #

A game_build (Section 6.4) is an immutable snapshot of one operator-run import: a labeled bundle of every source_files, assets, asset_variants, asset_images, hues and hue_group_members row produced by running Section 7's pipeline once against one copy of the client. "Immutable" means a build's row set never changes once written by D1–D6 (Section 7.2); the only thing that changes on a game_builds row after import is its status column, its build_hash (written exactly once, by the publish transaction in Section 9.5), and the bookkeeping columns that accompany a status transition (published_at, published_by, archived_at).

Lifecycle: draftin_reviewpublishedarchived or rolled_back, strictly forward, never backward as a direct write. A build enters draft the moment skinforge-cli import discover creates its row (Section 7.1) and stays draft through D1–D6. It moves to in_review when D6 (normalize) completes for at least one approved candidate and the operator runs skinforge-cli builds submit-review --build <id>, which is a metadata-only transition (no data changes) that unlocks the admin console's builds screen (Section 17.10). It moves to published only through the publish transaction in Section 9.5, which simultaneously moves the previously published build (if any) to archived. There is no path back from published/archived/rolled_back to draft/in_review: correcting a published build's mistakes always means importing a new build and publishing that instead, or re-publishing an older one (Section 9.6 covers the rollback case, which is itself a forward transition).

archived and rolled_back are both terminal and both mean "not the current build." They differ only in how the build stopped being current: archived is ordinary supersession by a newer publish, rolled_back is explicit withdrawal because the build was found to be broken. Keeping the two distinct is what lets the builds screen and the audit trail show an operator, at a glance, which builds were withdrawn rather than simply superseded.

Every asset_variants row and every hues row carries the build_id it belongs to (Sections 6.11 and 6.13), and every designs row pins its build_id at creation (Section 6.18). This is the entire mechanism by which permalink immutability (Section 13.7) holds: a design's render never depends on "the current published build," only on the specific build it was created against, whose asset and hue rows are never mutated and never deleted (Section 6.33). assets.first_build_id/last_build_id (Section 6.10) are provenance only and are not consulted on the render path.

Status Entered by Catalog visible publicly? Can be diffed against? Can be rolled back to?
draft import discover (Section 7.1) No Yes, as a diff target No
in_review builds submit-review (Section 9.2 step 5) No Yes, as a diff target No
published builds publish (Section 9.5) Yes, exactly one build at a time Yes, as a diff source N/A (already active)
archived Automatically, when a later build is published (Section 9.5) No, but its designs still render (Section 9.1) Yes, as a diff source Yes (Section 9.6)
rolled_back builds rollback (Section 9.6), applied to the build being withdrawn No, but its designs still render (Section 9.6) Yes, as a diff source Yes, though doing so re-publishes a build already judged broken

Only a published or archived build can ever have designs rows pointing at it, since a design can only be created against the currently published build (Section 13.5); draft/in_review builds never accumulate designs references, which is why they are safe to leave abandoned indefinitely if an operator decides not to proceed with a particular import (no cleanup obligation, though Section 6.33's retention policy eventually prunes their pipeline scratch rows after two years).

A build's label (Section 6.4) is operator-chosen free text but is conventionally YYYY-MM-DD-<patch-descriptor> (e.g. 2026-09-01-patch-47), which sorts chronologically alongside created_at and gives staff a human-recognizable anchor when scanning /admin/builds (Section 17.10) without opening any individual row.

9.2 The Re-Import Workflow After a Game Patch #

When UO Outlands ships a client patch, the operator re-runs the full pipeline to pick up new or changed art. Step by step:

  1. Obtain the patched client. Outside this system entirely — the operator updates their own local UO Outlands client installation through Outlands' own patcher, exactly as any player would.
  2. Start a new build. skinforge-cli import discover --client-dir <path> --build-label <label> (Section 7.1). This always creates a brand-new game_builds row; there is no "update an existing build" CLI verb, because builds are immutable snapshots by design (Section 9.1). Steps 2 and 3 together are what skinforge-cli import all does in one command (Section 7.9).
  3. Run the pipeline. skinforge-cli import run --build <id> (Section 7.9) executes D1–D6 in sequence. Because normalization (D6, Section 7.8) skips re-encoding any asset whose extracted bytes hash identically to a prior build's (Section 7.9's resumability, extended across builds by content-hash comparison rather than just within one build's manifest), a patch that changes only a handful of items still runs quickly even though every file is re-scanned.
  4. Resolve needs_operator_input items, if the exit code from step 3 was 1 (Section 7.9): skinforge-cli import probe --build <id> with a hand-written format-note.json (Section 7.4), or accept that some items remain unresolved and will simply not appear in this build's catalog (they can be picked up in a later build once understood, without blocking publish of everything else).
  5. Submit for review. skinforge-cli builds submit-review --build <id> moves the build to in_review.
  6. Diff against the currently published build. In the admin console, open /admin/builds/:id (Section 17.10) — the diff view described in Section 9.3 — or run skinforge-cli builds diff --from <published_build_id> --to <new_build_id> --json for a scriptable summary.
  7. Review changed/added items per-item or in bulk. In /admin/candidates filtered to this build (Section 17.6), staff approve or reject items per Section 9.4's approval gate.
  8. Publish. Once every item staff cares about has been reviewed (Section 9.4 covers what happens to anything left unreviewed), /admin/builds/:id exposes a Publish action, or skinforge-cli builds publish --build <id> on the CLI, executing Section 9.5's transaction.
  9. Verify. skinforge-cli import verify --build <id> (Section 7.9) confirms every asset_images row's bytes are actually present and correctly hashed in object storage, and that the build contains backpack.default. It is a publish precondition (Section 9.5), so in practice it runs before step 8; running it again after publish is a cheap confirmation, not a substitute.
  10. Monitor. The admin dashboard (/admin/dashboard, Section 17.4) shows the newly published build's summary (counts of new/changed/retired assets and hues) for the first 24 hours after publish, and the metrics and alerts in Sections 21.3 and 21.5 track render error rates for that window, since a publish is the single highest-risk operational event in the system.

9.3 Diffing Two Builds #

A diff compares a source build (typically the currently published one) against a target build (typically a new in_review one) and classifies every asset and hue into exactly one bucket. The bucket vocabulary is defined once, here, and is the same set of strings in the human table below, in the --json output, in the admin diff view and in the integration tests (Section 22.9):

bucket value Applies to Definition
added assets, hues asset_key (or hue_index) exists in the target build's rows but not the source build's.
changedMinor assets sha256 differs but the perceptual hash's Hamming distance is below 6 bits — a cosmetic revision.
changedMajor assets sha256 differs and the perceptual hash Hamming distance is 6 bits or more — a materially different image.
changedMetadata assets, hues Pixel or colour content identical, but display_name, slot assignment, hue name, tableStart or tableEnd differ.
changedColors hues The 32-entry colors array differs at one or more entries.
removed assets, hues asset_key/hue_index exists in the source build but not the target build.
unchanged assets, hues Exists in both with identical content.

"Changed" in prose means any of changedMinor, changedMajor, changedMetadata or changedColors; it is a category, not a bucket value, and never appears as a string in output. There is no modified bucket.

Example output of skinforge-cli builds diff --from <published_build_id> --to <new_build_id> --json:

{
  "fromBuildId": "01J8Z3K9QABCDEFGHJKMNPQRST",
  "toBuildId": "01J9A1M2XYZ0123456789ABCDE",
  "assets": {
    "added": 42,
    "changedMinor": 11,
    "changedMajor": 6,
    "changedMetadata": 3,
    "removed": 2,
    "unchanged": 5891
  },
  "hues": {
    "added": 18,
    "changedMetadata": 4,
    "changedColors": 2,
    "removed": 0,
    "unchanged": 2976
  },
  "items": [
    { "kind": "asset", "assetKey": "hair.long-wavy-braided", "bucket": "added", "slotKey": "hair" },
    { "kind": "asset", "assetKey": "robe.plain", "bucket": "changedMinor", "slotKey": "torso_outer" },
    { "kind": "asset", "assetKey": "hat.wide-brim", "bucket": "removed", "slotKey": "head" },
    { "kind": "hue", "hueIndex": 1847, "bucket": "changedColors", "name": "Doubloon Bronze" }
  ]
}

The items array in the full (non-truncated) output enumerates every non-unchanged entry, which is what the admin builds screen (Section 17.10) paginates through; the summary counts at the top let staff gauge patch size before opening the item list.

Change detection for assets: two signals, both computed at D6 (Section 7.8) and D4/D5 (perceptual hash) respectively, and stored on asset_variants.sha256 (exact content hash) and extraction_candidates.metadata.perceptualHash (Section 7.5) carried forward into a diff-time lookup:

  • If the exact sha256 of every asset_variants row for the asset_key matches between builds, the asset is unchanged — this is the common case for a patch that touches unrelated content.
  • If sha256 differs but the perceptual hash's Hamming distance is below a threshold of 6 bits (of 64), the asset is changed — cosmetic revision: visually similar but not byte-identical (common after Outlands re-exports art with a different compressor or minor palette touch-up). Its bucket is changedMinor, which the admin builds screen (Section 17.10) labels "minor" to help staff triage quickly.
  • If sha256 differs and the perceptual hash Hamming distance is 6 or above, the asset is changed — visual revision: a materially different image. Its bucket is changedMajor, labelled "major".
  • Metadata-only differences (an asset's display_name or slot assignment changed between builds with identical pixel content) get bucket = 'changedMetadata', labelled "metadata", detected by comparing the D5 classification output rather than pixel hashes.

What "Changed" does to the database. This is the mechanism permalink immutability rests on, so it is worth stating explicitly rather than leaving to inference. When an asset lands in any changed* bucket, D6 (Section 7.8) inserts a new asset_variants row carrying the new build_id, the new geometry and the new sha256. It does not update the previous build's row, and it cannot: the unique constraint is (asset_id, body, build_id) (Section 6.11), so the old row and the new row coexist. The assets row is shared across builds — it is the identity of the item, not its art — and only its display_name, display_order and last_build_id are ever updated.

The consequence is the whole product promise. A permalink created before the patch pins designs.build_id to the old build, the renderer resolves asset_key + body + that build_id, and it finds the old row with the old art, forever. A permalink created after the patch resolves the new row. Neither one can be disturbed by the other. Had asset_variants been keyed on (asset_id, body) alone, this diff bucket would have had to overwrite a single row, and every historical permalink referencing that asset would have silently re-rendered with art its creator never chose — a change no user could see coming and no cache invalidation could undo.

An unchanged asset still gets a new build-scoped variant row, and its own six asset_images rows. The storage cost is those rows, not the images: catalog objects are keyed on the variant's content hash (Section 7.8), so unchanged art resolves to the same key, D6 skips both the re-encode and the upload (Section 7.9), and the new rows simply point at the object that is already there. This is why asset_images.storage_key is not unique (Section 6.12) — several builds legitimately reference one object.

Change detection for hues: a hue gets bucket = 'changedColors' if its colors array differs at any of the 32 entries, added if its hue_index has no row in the source build, removed if the reverse, and unchanged otherwise. name, tableStart or tableEnd differences alone (colours identical) get changedMetadata, same treatment as assets. Hues were already build-scoped by (hue_index, build_id) (Section 6.13), so a colour-table change has always produced a new row rather than an overwrite; asset_variants now behaves the same way for the same reason.

Presentation for review: the diff view groups by slot (for assets) or hue group (for hues), shows a side-by-side thumbnail (source build's normalized image vs. target build's) for every Changed item, and a single thumbnail with an "Added"/"Removed" badge for the other two buckets. Bulk counts per bucket are shown at the top of /admin/builds/:id (Section 17.10) so staff can gauge the size of a patch before diving into individual items.

9.4 The Approval Gate #

Nothing an import run produces is visible to public visitors until a staff member explicitly publishes the build (Section 9.5). Within that build, individual review is per-item, and it happens in the candidate review queue (Section 17.6):

  • Every extraction_candidates row that D5 did not auto-approve (below the threshold in Section 7.6) requires an explicit staff decision: Approve (moves to status = 'approved') or Reject (moves to status = 'rejected', never reaches the catalog for this build). Approving does not itself write catalog rows: assets, asset_variants and asset_images are created only by D6, which the operator runs afterwards and which processes only approved candidates (Section 7.2.6).
  • Hues are not gated by this queue. D4 decodes every hue in the client unconditionally and D6 writes the hues and hue_group_members rows for the build in the same run that writes the asset catalog (Sections 7.2.6 and 7.7). Hues have no per-item approval step because there is nothing to adjudicate: a hue is a 32-entry colour table read straight out of the client, not a classification guess. Staff curation of hues happens after the fact on the hues admin screen (Section 17.8) — renaming, re-grouping, overriding a swatch — and the diff view (Section 9.3) surfaces every added, renamed and recoloured hue for review before publish.
  • Display names are curated here. Part of reviewing an item is confirming its display_name reads correctly as a catalog label and inside a generated sentence, because the alt-text generator (Section 12.10) inserts the name verbatim with no article added or removed. Long Wavy and Plain Robe are good names; a Plain Robe and robe_plain are not. This is a review criterion, not a validation rule — nothing rejects an awkward name, but the queue is where it gets fixed.
  • Auto-approved candidates (at or above the threshold) are not blocked on staff review to reach the catalog within a draft/in_review build — D6 runs on them automatically — but they remain visible in the diff view (Section 9.3) as added/changed* items staff can still inspect and, if wrong, reject after the fact via /admin/assets/:assetKey (Section 17.7), which re-opens that specific asset for re-review even after D6 has processed it (setting retired_at on the erroneous version and letting the correct extraction, if any, take its place before the build is published).
  • Bulk actions: /admin/candidates (Section 17.6) supports "Approve all filtered" and "Reject all filtered" scoped to the current filter (by slot, by confidence band, by build), so a staff member confident in a whole batch (e.g. all footwear candidates above 0.7 confidence) does not have to click through each one. A bulk action still writes one audit_log row per affected item (Section 9.10), never a single collapsed row, so individual accountability is preserved.
  • What happens to unreviewed items when a build is published: any extraction_candidates row still status = 'pending' at publish time is left pending — publish does not implicitly approve or reject anything. Because D6 never normalizes a pending item, that item's underlying sprite simply does not exist as a catalog assets row in this build and is invisible to the public site, exactly as if it had never been extracted. It remains available for review in a later build (its extraction_candidates row persists, unaffected by the publish) or can be approved retroactively, which then requires a follow-up build to actually publish it, since a published build's row set is immutable per Section 9.1 (approving a candidate after publish does not retroactively add it to the already-published build's catalog).

9.5 Publishing #

Publishing is the atomic transition of one build from in_review to published, and — in the same transaction — the previously published build (if one exists) to archived.

Preconditions, checked by skinforge-cli builds publish --build <id> before opening the transaction. This list is complete and it is the only list — no other section adds a publish precondition of its own; screens that gate a Publish button check exactly these four and display which one is unmet:

  1. game_builds.status = 'in_review' for the target build (publishing a draft build directly is rejected with a clear CLI error instructing the operator to run submit-review first).
  2. skinforge-cli import verify --build <id> has been run successfully at least once since the last D6 run for this build (checked via an import_runs row with stage = 'verify', status = 'succeeded', and created_at after the most recent normalize run's finished_at); if not, the publish command refuses and instructs the operator to run verify first, since publishing unverified storage state is exactly the failure mode Section 6.34's restore validation exists to catch, and it is cheaper to prevent than to recover from.
  3. The build has at least one non-retired assets row. A build with zero approved assets would publish an empty catalog over a working one; the publish is refused with the error code for an empty build in Section 25.2.
  4. Every hues row for this build has a non-empty name. D6 substitutes Hue <index> for a blank decoded name (Section 7.7), so this precondition can only fail if something upstream wrote a blank directly, and it is cheap insurance rather than a routine gate. It is not a "staff must name every hue" rule: the hues admin screen (Section 17.8) flags hues still carrying the generated placeholder as a quality nudge, and that flag never blocks a publish.

Transaction shape (the application opens and commits the transaction; the SQL below is the statement sequence inside it, in order):

-- 1. Compute and pin the build fingerprint. This is the value Section 8.9's render cache key is
--    salted with, and it is written exactly once, here, for the life of the build.
UPDATE game_builds
SET build_hash = encode(
      sha256(convert_to(
        coalesce((SELECT string_agg(v.sha256, '' ORDER BY a.asset_key, v.body)
                    FROM asset_variants v
                    JOIN assets a ON a.id = v.asset_id
                   WHERE v.build_id = $targetBuildId), '')
        || coalesce((SELECT string_agg(h.colors::text, '' ORDER BY h.hue_index)
                       FROM hues h
                      WHERE h.build_id = $targetBuildId), ''),
        'UTF8')),
      'hex'),
    updated_at = now()
WHERE id = $targetBuildId AND build_hash IS NULL;

-- 2. Archive the currently published build, if any.
UPDATE game_builds
SET status = 'archived', archived_at = now(), updated_at = now()
WHERE status = 'published';

-- 3. Publish the target build.
UPDATE game_builds
SET status = 'published', published_at = now(), published_by = $adminUserId, updated_at = now()
WHERE id = $targetBuildId AND status = 'in_review';

-- Guard: statement 3 must have affected exactly one row. Its WHERE clause fails, and the
-- application aborts the transaction with ROLLBACK, if a concurrent publish already moved this
-- build out of 'in_review' between the precondition check and here.

-- 4. Process retirements determined by the diff (Section 9.7): assets present in the outgoing
--    published build and absent from this one are retired, not deleted.
UPDATE assets
SET retired_at = now(), updated_at = now()
WHERE id = ANY($retiredAssetIds) AND retired_at IS NULL;

-- 5. Audit. One row for the publish, plus one row per retired asset (Section 9.10).
INSERT INTO audit_log (id, actor_id, actor_label, action, entity_type, entity_id, before, after, created_at)
VALUES ($ulid(), $adminUserId, $adminEmail, 'build.publish', 'game_builds', $targetBuildId,
        $beforeJson, $afterJson, now());

build_hash is sha256 over every asset_variants.sha256 in this build, ordered by (asset_key, body), concatenated with every hues.colors array in this build ordered by hue_index. The build_hash IS NULL guard in statement 1 makes it write-once: a rollback (Section 9.6) re-runs this same transaction against a build that already has a hash, and must not disturb it, because every render URL ever issued for that build is keyed on it.

The application wraps this in a single postgres.js transaction (sql.begin(...), Section 4.7) and checks the affected-row count of statement 3; a count of 0 triggers ROLLBACK (implicit on throwing inside sql.begin) and a 409 Conflict surfaced to the admin UI, covering the race where two staff members attempt to publish concurrently.

game_builds_published_singleton (Section 6.4's partial unique index) is a second, database-level guarantee against ever having two published rows simultaneously, independent of the application-level transaction correctness above — belt and suspenders.

Cache invalidation: publishing changes which build's assets and hues are "current" for the designer UI's default catalog view (Section 11.6) and the catalog browse pages (Section 15.3), both of which query "assets/hues in the currently published build" rather than a specific build id. Because those queries are dynamic (not cached at the HTTP layer beyond the s-maxage on HTML pages given in the Cache-Control matrix in Section 19.3), a publish's effect on those pages propagates within that window without any explicit purge. Object storage-backed render URLs (Section 8.8) are content-addressed and never need invalidation — a design's render URL for the previously published build's art continues to resolve correctly forever, and a design created after publish gets a new cache key naturally, since its canonicalDesignJson resolves against the new build's asset/hue data. The CDN purge mechanism (SKINFORGE_CDN_PURGE_URL, Section 24.2) is invoked by the publish job only for the small set of dynamic HTML pages most sensitive to staleness — /, /catalog, /catalog/:slotKey — as a courtesy to reduce the window below the natural s-maxage, not because correctness depends on it. The purge endpoint and the invalidation rules it follows are Section 19.5's.

Regeneration policy: publishing does not eagerly regenerate every design_renders or og_images row for existing designs — those remain valid indefinitely because they are keyed off the design's own build_id (Section 6.19's cache key includes that build's build_hash, per Section 8.9), not the currently-published build. Only new renders requested after publish (new designs, or explicit re-renders of old designs at a new scale/format) are affected, and they naturally use whichever build the requesting design is pinned to. No batch regeneration job runs on publish.

9.6 Rollback #

Rollback reverts the currently published build to the immediately prior one, for use when a publish is discovered to be broken shortly after going live (e.g. a batch of assets was approved in error and now renders visibly wrong art on the public site).

Mechanism: skinforge-cli builds rollback --build <id> where <id> is the target build to re-publish (normally the one that was published immediately before the currently broken one, found via its archived_at timestamp being the most recent among status = 'archived' rows). Rollback is the Section 9.5 publish transaction run again with the target being the older build, plus two differences and no others:

  1. The build being withdrawn is moved to status = 'rolled_back' rather than 'archived', so the builds screen and the audit trail distinguish a withdrawal from an ordinary supersession (Section 6.4).
  2. The audit_log row's action is 'build.rollback' instead of 'build.publish' (Section 9.10).

There is no separate rollback code path and no rollback table. The target build already has a build_hash from its original publish, and statement 1 of the publish transaction leaves it untouched (Section 9.5), so every render URL issued during that build's first period as the published build still resolves to the same cached bytes.

What rollback does: makes the older build's catalog (assets, hues) the one served by the dynamic "current build" queries again (Section 9.5's cache invalidation paragraph applies identically). The broken build moves to rolled_back.

What rollback does not undo: any designs row created while the broken build was published remains pinned to that broken build's build_id (Section 6.18) and continues to render using its art forever — rollback is not a data-loss operation and never touches designs, design_renders, or og_images rows. A rolled_back build's asset_variants and hues rows are never deleted either, for the same reason (Section 6.33). If the broken build's art was visually wrong, permalinks created during that window keep showing the wrong art; this is a deliberate consequence of the immutability guarantee (Section 13.7) outweighing convenience, and is called out explicitly in the operator runbook (Section 23.8) so staff understand rollback fixes the site going forward, not retroactively.

Guarantee: existing permalinks (any designs row, from any build) keep rendering after a rollback, with zero exceptions — rollback only ever changes which build answers "what's currently published," never any historical row's build_id reference or any stored render/OG image.

9.7 Asset Retirement #

When a patch's diff (Section 9.3) shows an asset as Removed relative to the currently published build, and that removal is confirmed (not rejected as a false detection) during review, the asset is retired, not deleted, at publish time:

  • The assets row's retired_at is set to the publish transaction's timestamp, by statement 4 of the transaction in Section 9.5.
  • last_build_id on the assets row is left pointing at the last build that did contain it (i.e. it is not bumped forward to the new build, since the new build is precisely the one that lacks it).
  • The asset disappears from the designer's option panel (Section 11.6) and the catalog browse pages (Section 15.3) for any query scoped to "currently published build," because those queries filter WHERE retired_at IS NULL (matching the partial index in Section 6.10).
  • Storage consequence: none of the asset's asset_images rows or underlying object storage files are deleted. They remain exactly as normalized, because any designs row created before retirement that references this asset_key still needs them at render time. The render path resolves a layer with WHERE a.asset_key = $1 AND v.body = $2 AND v.build_id = $3 against assets a JOIN asset_variants v (Sections 6.10 and 6.11) and applies no retired_at filter at all — retirement only affects discovery surfaces, never the render path, and the build scoping is what guarantees the row it finds is the one that existed when the design was created.
  • UI treatment: the designer (Section 11.6) and preview surfaces (Section 12.5) never let a user newly select a retired asset, but if a permalink whose saved design already references a retired asset_key is opened, the composite still renders normally and the remix flow in Section 11.10 displays a small non-blocking notice next to the affected slot ("This item is no longer available in the catalog; your saved design is unaffected") rather than silently reverting to a different asset or blocking the load — the exact copy is Section 11.14's concern, this section only fixes the rule: retirement never changes what a design renders.

If a later build re-adds an asset with the exact same underlying content (same sha256), diffing (Section 9.3) shows it as unchanged relative to two builds back, but since it was removed in the intervening build, the practical re-publish path clears its retired_at back to NULL rather than creating a duplicate assets row, keyed by the stable asset_key/content match. It also writes a fresh asset_variants row for the new build, exactly as any other asset in that build does. This un-retire path is a normal part of D6's row-write logic (Section 7.8), not a special case requiring staff intervention beyond the normal review.

9.8 Hue Re-Sync #

Patches can add new custom hues, rename existing ones, or change colour tables (Section 7.7 covers how D5 detects and classifies these at extraction time; this section covers what happens at publish).

  • New custom hues (bucket = 'added', Section 9.3) become newly selectable in the hue picker (Section 11.6) once the build containing them is published, with no special handling — they are simply new hues rows with no prior history.
  • Renamed hues (bucket = 'changedMetadata': name changed, colours identical) update what text the public hue browser (Section 15.5) and designer tooltip show for that hue_index going forward. Because hues rows are keyed (hue_index, build_id) (Section 6.13's unique constraint), the rename is a new row for the new build, not an in-place update to an old row — old builds' hues rows keep their original name forever, which matters for the next bullet.
  • Changed colour tables (bucket = 'changedColors', Section 9.3) are the case that matters most for rendering correctness: a design pinned to a changed hue renders with the hue table archived in its own build, never the currently published build's version. Mechanically, this falls out of the same build_id pinning described in Sections 9.1 and 9.7 — the render pipeline (Section 8.3) resolves a design's skinHue/per-slot hue values with WHERE hue_index = $1 AND build_id = $2, where $2 is designs.build_id, so a colour-table change in a newer build simply does not affect any existing design's rendered output. The build_id predicate is not optional: hues is unique on (hue_index, build_id) (Section 6.13), so a lookup without it returns one row per build and picking the first would render every historical design with the newest colours — the exact failure this section exists to prevent, and the reason Section 19.7 states the same rule for the hot query. A user opening the designer fresh (Section 11.6) and picking that same hue_index value, by contrast, gets the new colour table, since the designer always operates against the currently published build.

Which queries filter retired_at, and which must not. The render-path query above applies no retired_at filter at all, for exactly the reason Section 9.7 applies none to assets: retirement governs discovery, never rendering. A hue retired by a later build still has a row in the build the design pinned, and that row is what renders — otherwise a single later patch would blank out the skin tone of every design created before it, which is the failure mode permalink immutability exists to prevent. The filter belongs only to surfaces that are offering a choice:

Query retired_at IS NULL? Why
Render path — resolving a pinned design's skinHue and per-slot hues (Sections 8.3 and 19.7) No The design pinned this build; the row it pinned must resolve regardless of later retirement.
Designer hue picker and skin-tone rail (Section 11.6) Yes A visitor must not be able to newly select a hue the current build no longer carries.
Public hue browser and hue detail pages (Section 15.5) Yes Catalog discovery surface, scoped to the currently published build.
/api/v1/hues listing (Section 16.8.5) Yes Same rule as the browse surfaces it backs.
Admin hues screen (Section 17.8) No — retired rows are shown, badged Staff need to see what was retired and when.

The identical split applies to assets (Section 9.7): the same predicate, present on discovery queries and absent from the render path, applied to assets.retired_at instead of hues.retired_at.

  • This means the same numeric hue index can visibly mean two different colours depending on which design you look at, if a colour table changed between the two designs' respective builds. This is the deliberate, documented consequence of prioritizing permalink immutability, called out in the /changelog page (Section 10.3.12) whenever a colour-table-changing build is published, so end users encountering the discrepancy have a place to find the explanation.

9.9 Idempotency and Resumability #

Re-running an import must never duplicate rows, and an interrupted import must be safely resumable from wherever it stopped. This section states the build-level guarantees; Section 7.9 states the stage-level mechanics that implement them.

  • Idempotency: every write in D1–D6 is either an INSERT ... ON CONFLICT DO NOTHING/DO UPDATE keyed on a natural uniqueness constraint — source_files (build_id, relative_path), extraction_candidates (import_run_id, candidate_key), asset_variants (asset_id, body, build_id), asset_images (asset_variant_id, scale, format), hues (hue_index, build_id), hue_group_members (hue_id, hue_group_id), all per Section 6's unique constraints — or a lookup-then-conditional-write guarded by the same key, so running any stage twice against the same build with unchanged inputs produces zero net row changes on the second run beyond updated updated_at timestamps where applicable.
  • What idempotency does not mean: re-importing the same client as a new build is not a no-op and is not supposed to be. Step 2 of Section 9.2 always creates a fresh game_builds row, and both hues and asset_variants are keyed per build, so a second import of identical data legitimately produces a full set of new rows scoped to the new build — and a diff (Section 9.3) that reports every asset and every hue unchanged. Those are the two distinct properties a test suite should assert, and Section 22.9 asserts them separately.
  • Resumability: Section 7.9 already specifies per-stage resumability via the manifest (Section 7.5) and per-row terminal-state checks. At the build level, this composes: skinforge-cli import run --build <id> re-run after an interruption re-enters at whichever stage the import_runs table (Section 6.6) shows as the latest non-succeeded stage for that build, skipping stages already succeeded unless --force-restage <stage> is explicitly passed (used when an operator wants to deliberately redo a stage, e.g. after fixing a format-note.json).
  • Safe-to-retry guarantee: every stage command can be killed (SIGKILL, power loss, out-of-memory abort per Section 7.10) at any point and re-run with identical arguments, and will converge to the same end state as an uninterrupted run. This is validated by an integration test (Section 22.4) that runs D1–D6 against a fixture client directory, kills the process at a randomized point in each stage across repeated runs, restarts it, and asserts the final row counts and content hashes match an uninterrupted control run exactly.

9.10 Audit Trail #

Every ingestion action that changes state is written to audit_log (Section 6.25), with:

action value entity_type Written when
build.create game_builds import discover creates a new build row.
build.submit_review game_builds Build moves draftin_review.
build.publish game_builds Section 9.5's transaction.
build.rollback game_builds Section 9.6's transaction (the publish transaction with the withdrawn build set to rolled_back, and this distinct action label).
candidate.approve extraction_candidates Single or bulk approval (Section 9.4); one row per affected candidate even for bulk actions.
candidate.reject extraction_candidates Single or bulk rejection; reason column populated, required for rejections.
asset.retire assets Section 9.7's retirement, written as part of the publish transaction.
asset.unretire assets An asset's retired_at cleared back to NULL (Section 9.7's re-add case).
hue.import hues The hues and hue_group_members rows D6 inserts for the build (D6 is the only writer of either table, Section 7.2.6), batched into one audit row per import run rather than per hue — 3,000 audit rows per build would bury every other event — summarizing counts by source, by change bucket and by hue group.

before/after columns capture the affected row's relevant column values as JSON snapshots (for game_builds, the status/build_hash/published_at/archived_at columns; for extraction_candidates, the status/reviewed_by columns; for assets, the retired_at column), giving a reconstructable history without needing a separate row-versioning table. actor_id/actor_label record the staff member for console-driven actions, or actor_id = NULL with actor_label = 'system:cli' or 'system:scheduled-job' for unattended CLI/cron-triggered actions (Section 9.11), so the distinction between human and automated actions is always visible in /admin/audit (Section 17.13).

Console-driven rows also carry ip_address and user_agent from the acting staff session, and metadata (Section 6.25) carries action-specific extras — for a bulk approval, the batch id shared by every row the action produced, so the audit viewer can group them back together. CLI and job-runner rows leave ip_address and user_agent null, which is itself the signal that no browser session was involved. Audit rows are never pruned (Section 6.33).

9.11 Scheduled and Manual Triggers, Notification, Runbook Summary #

Manual trigger: the default and expected mode — an operator runs skinforge-cli import all (or import discover then import run) by hand after noticing an Outlands client patch. This system has no automated patch-detection against Outlands' own release channel; patch awareness is an operational, not a technical, responsibility (stated explicitly so no agent building this system mistakenly scopes in a patch-watcher).

Scheduled trigger: SKINFORGE_JOB_RUNNER_ENABLED (Section 24.2) governs whether the in-process job runner (Section 4.7) is active at all; when it is, a jobs row of job_type = 'reimport_reminder' is enqueued on a recurring run_after cadence — configured, not hard-coded, by the reimport_reminder_cadence_days row in app_settings (Sections 6.37 and 17.14) — purely to produce a notification (next bullet) reminding staff to check for patches. It never triggers discover/import automatically, since starting an import against a client directory the job runner cannot itself verify is current would risk importing stale or partial data unattended.

Notification of results: SkinForge sends no outbound email. There is no SMTP dependency in the stack (Section 4.1), no mail-server configuration in Section 24.2, and no third-party notification integration (Slack, Discord, webhooks) in v1. Notifications are in-app only.

Every import_runs row completing with status IN ('succeeded','failed'), and every build.publish/build.rollback audit event, enqueues a jobs row of job_type = 'notify' whose payload names the event and the target build. The notify handler's only job is to make the event visible on /admin/dashboard (Section 17.4), and it does that without a dedicated table: the dashboard's activity feed is rendered directly from audit_log and import_runs (Sections 6.25 and 6.6), which already hold every fact a notification would have carried. The notify job exists so the feed's derived counters are recomputed promptly rather than on next page load.

This keeps the notification surface consistent with the single-VPS posture and with the "configuration is environment variables, plus the six rows in app_settings" rule in Section 24.1: there is nothing to configure, because there is nothing to send.

Operator runbook summary (the authoritative, detailed runbook lives in Section 23.8; this is the ingestion-specific excerpt): after a patch, (1) update the local client, (2) skinforge-cli import all, (3) resolve any needs_operator_input items, (4) review the diff, (5) verify, (6) publish, (7) watch the dashboard for 24 hours. If anything looks wrong post-publish, roll back (Section 9.6) first and investigate second — rollback is cheap, fast, and safe, so it is always the correct first response to a suspected bad publish rather than attempting a live fix.

9.12 Failure and Recovery Matrix #

This matrix is the ingestion-specific complement to the platform-wide error catalog in Section 25.2; codes there (SF-6xxx, the import group) map to the rows below where an API-surfaced error applies.

Failure Detection Automatic behaviour Operator action
Client directory unreadable/missing at discover D1 aborts immediately (Section 7.2.1) CLI exits code 2, no build row side effects beyond the already-created game_builds row (left in draft with zero source_files) Fix the path or permissions, re-run import discover (creates a fresh build; the empty one can be left as-is, since it is harmless and never published).
A pipeline stage crashes with an unhandled exception Process exit with non-0/1/2/3 code, or a stack trace on stderr The in-flight import_runs row is left status = 'running' with no finished_at — no automatic recovery Treated as a pipeline bug (Section 7.4 requires every anticipated failure to be handled, not thrown); operator files it and re-runs the stage after a fix. The runner detects a stale running row (started_at older than a configurable staleness window) and flags it in the admin dashboard as "possibly stuck" rather than silently leaving it.
needs_operator_input count stays high after probe import_run_items/extraction_candidates counts queried after D3 None — this is an expected, non-blocking outcome (Section 7.4) Write/refine format-note.json and re-run import probe/import extract for just the affected files.
Storage upload fails mid-normalize D6's upload call throws; caught per-asset That asset's transaction rolls back (Section 7.8's "single transaction per asset" rule), leaving it as if not yet normalized; other assets in the same run are unaffected Re-run import normalize --build <id>; resumability (Section 9.9) skips already-succeeded assets and retries only the failed one.
verify finds a hash mismatch between asset_images.content_hash and the actual stored object skinforge-cli import verify (Section 7.9) Publish is blocked (Section 9.5's precondition) Re-run import normalize for the affected asset (forces re-upload), then re-run import verify.
Concurrent publish attempts by two staff members The publish transaction's affected-row-count guard (Section 9.5) Second attempt's transaction rolls back; API returns 409 Conflict Retry the publish; it will now either succeed (if the first attempt's target was different) or report the build is already published.
Publish succeeds but post-publish monitoring (Section 9.11) shows a spike in render errors the error-rate metrics and alerts in Sections 21.3 and 21.5 None automatic in v1 (no auto-rollback) Staff-initiated rollback (Section 9.6) as the first response, per the runbook (Section 9.11), before investigating root cause.
A format-note.json fails Zod validation D3 validates on every run (Section 7.4) That file is left needs_operator_input; D3 continues with other files Fix the note's JSON against the schema (Section 7.4) and re-run import probe.
Diff (Section 9.3) misclassifies an unchanged asset as changedMinor due to a re-compression artifact with no real visual change Staff visual review during Section 9.4's approval step None automatic — this is why every Changed item requires a human look, even changedMinor ones Reject the spurious re-approval (leave the old build's asset as the effective one by not approving a change) or approve it if staff judges the difference immaterial; either is a normal review outcome, not an error state.

10. Public Web Application — Information Architecture & Routing #

10.1 Site map #

All routes are served by the single Deno process described in Section 4. Rendering mode is one of: SSR (fully server-rendered HTML, no island), SSR+island (server-rendered shell with one or more hydrated Preact islands), or static (build-time or edge-cached HTML with no per-request data). Cache policy pointers name the response class from Section 19.4.

Route Purpose Rendering mode Cache policy Indexing
/ Designer home, empty or query-string-seeded design SSR+island HTML class (Section 19.4) index
/design Alias of /, 301 redirect, see 10.5 — (redirect) no-store noindex (redirect target inherits)
/d/:code Permalink view of a saved design SSR+island HTML class index
/catalog Catalog overview, slot cards SSR HTML class index
/catalog/:slotKey Asset grid for one slot SSR+island (filters) HTML class index
/catalog/:slotKey/:assetKey Asset detail page SSR HTML class index
/hues Hue browser overview SSR HTML class index
/hues/:hueIndex Single hue detail SSR HTML class index
/about Project background, credits static HTML class, long TTL index
/faq Frequently asked questions static HTML class, long TTL index
/legal Fan-project disclaimer, IP notice, takedown policy static HTML class, long TTL index
/support Donation link and thanks static HTML class, long TTL index
/changelog Human-readable build/import history SSR HTML class index
/sitemap.xml Sitemap document, see 10.6 SSR HTML class, 1 hour TTL n/a
/robots.txt Crawler policy static long TTL n/a
/opensearch.xml Browser search-engine descriptor static long TTL n/a
/render/... Rendered images static (content-addressed) Section 8.9 n/a
/og/d/:code.png Open Graph image SSR (generated once, cached) Section 14 n/a
/api/v1/... Public JSON API SSR (no HTML) Section 16 n/a
/admin/... Staff console SSR+island no-store noindex, and X-Robots-Tag: noindex header

10.2 Fresh 2.x routing implementation #

Fresh 2.x resolves routes from the file tree under web/routes/. File paths for every public route:

web/routes/
  index.tsx                        -> /
  design.tsx                       -> /design (redirect handler only)
  d/[code].tsx                     -> /d/:code
  catalog/index.tsx                -> /catalog
  catalog/[slotKey]/index.tsx      -> /catalog/:slotKey
  catalog/[slotKey]/[assetKey].tsx -> /catalog/:slotKey/:assetKey
  hues/index.tsx                   -> /hues
  hues/[hueIndex].tsx              -> /hues/:hueIndex
  about.tsx                        -> /about
  faq.tsx                          -> /faq
  legal.tsx                        -> /legal
  support.tsx                      -> /support
  changelog.tsx                    -> /changelog
  sitemap.xml.ts                   -> /sitemap.xml
  robots.txt.ts                    -> /robots.txt
  opensearch.xml.ts                -> /opensearch.xml
  render/d/[...path].ts            -> /render/d/:code@:scale.:ext (parsed in handler, Section 8)
  render/a/[...path].ts            -> /render/a/:assetKey/:body/:hue@:scale.:ext
  render/s/[...path].ts            -> /render/s/:slotKey/:body@:scale.:ext
  og/d/[code].png.ts               -> /og/d/:code.png
  api/v1/[...path].ts              -> catch-all dispatcher, Section 16
  admin/index.tsx                  -> /admin (login)
  admin/dashboard.tsx              -> /admin/dashboard
  admin/imports/index.tsx          -> /admin/imports
  admin/imports/[id].tsx           -> /admin/imports/:id
  admin/candidates.tsx             -> /admin/candidates
  admin/assets/index.tsx           -> /admin/assets
  admin/assets/[assetKey].tsx      -> /admin/assets/:assetKey
  admin/hues.tsx                   -> /admin/hues
  admin/slots.tsx                  -> /admin/slots
  admin/tags.tsx                   -> /admin/tags
  admin/builds.tsx                 -> /admin/builds
  admin/designs.tsx                -> /admin/designs
  admin/takedowns.tsx              -> /admin/takedowns
  admin/audit.tsx                  -> /admin/audit
  admin/settings.tsx               -> /admin/settings
  admin/users.tsx                  -> /admin/users
  _404.tsx                         -> not-found fallback
  _500.tsx                         -> error fallback
  _app.tsx                         -> root HTML shell (Section 10.4)
  _middleware.ts                   -> root middleware chain
  admin/_middleware.ts             -> admin auth gate

Dynamic params ([code], [slotKey], [assetKey], [hueIndex], [id]) are typed via each route's define.page/define.handlers generics against the Zod schemas in core/schema/. [...path] catch-all segments are used only for the render endpoints and the API dispatcher, both of which parse and validate the remaining segments manually against the URL grammars owned by Section 8 and Section 16 respectively.

Route groups: web/routes/admin/ is a Fresh route group gated by admin/_middleware.ts, which runs after the root _middleware.ts and enforces the session check from Section 17.2. There is no other route group; public routes share the root middleware only.

Middleware order (root _middleware.ts, applied to every request):

  1. Request-id assignment (ULID, stored on ctx.state.requestId, echoed as header X-Request-Id).
  2. Trusted-proxy-aware client IP resolution (SKINFORGE_TRUSTED_PROXY_HOPS, Section 24).
  3. Maintenance-mode short-circuit (Section 10.9) when SKINFORGE_MAINTENANCE_MODE=true.
  4. Security headers injection (Section 20.4).
  5. Rate-limit check for /api/v1/* and /render/* only (Section 16.7, Section 19.8).
  6. Access log write (Section 21.1).

_404.tsx renders the 404 page (10.9) for any unmatched path. _500.tsx renders the 500 page (10.9) for any uncaught handler error; the error is logged with requestId before rendering.

10.3 Page-by-page specification #

10.3.1 / — Designer home #

Purpose: primary entry point. Loads a blank default design, or a design seeded from the transient query string described in Section 13.6, into the designer island.

Above-the-fold layout (desktop): header (10.4) at top; below it, the three-region designer layout from Section 11.1 fills the viewport with no scroll required to see the preview stage.

Components present: global shell (10.4); Designer root island (Section 11.2 DesignerRoot) which contains the preview stage (Section 12), the slot rail (Section 11.5), and the option panel (Section 11.6); Share button (Section 11.9); Randomize button (Section 11.8).

Server data loaded: full slot registry and the first page (60) of asset summaries for the body slot only (id, key, name, thumbnail ref, gender compatibility) for initial hydration — the option panel fetches each other slot's assets on demand when it becomes active (Section 11.6) — current published game_builds row, and — when the request has a non-empty query string — the design is parsed and validated server-side per Section 13 before being passed as the island's initial state, so first paint already shows the seeded design with no client-side flash.

Empty state: no query string present renders the default design: body=m, skinHue=0, all optional slots empty. Error state: an invalid or unknown query-string value (unknown assetKey, out-of-range hue) is dropped from the parsed state field-by-field (never a full-page error) and a dismissible banner reports which fields were reset, per Section 11.13.

Title: Outlands SkinForge — Design Your UO Outlands Character Skin. Meta description: Design a UO Outlands character skin free, no login. Pick body, hues, hair and cosmetics, preview live, and share a permanent link.

Purpose: canonical, shareable, immutable view of one saved design. Doubles as the editor entry point (11.10 Remix flow).

Above-the-fold layout: same three-region layout as /, pre-populated from the stored design, plus a "Remix" label state (11.10) instead of a blank-canvas state.

Components present: global shell; Designer root island initialized from the server-loaded design; Share button (relabelled contextually, 11.9); a read-only summary strip showing body and slot names above the preview on first paint (server-rendered, before hydration) so no-JS visitors still see the design described in text and image (10.7).

Server data loaded: designs row by short code (case-insensitive lookup per Section 13.4), its resolved slot list joined against assets/asset_variants/hues, and the pre-rendered composite image reference from design_renders (Section 8.9) for the <img> fallback and OG tag.

Empty/error states: unknown code renders the 404 page (10.9). A code resolving to a design that has been taken down (designs.taken_down_at set through the legal process in Section 20.8) renders the 410 page (10.9) instead of design content — designs are never auto-deleted; takedown is the only removal path (Section 20.8), and it never depends on the state of the design's build. A code that resolves but references a since-retired individual asset still renders normally: retired assets remain servable per the immutability rule in Section 6's soft-delete convention; no error state exists for that case.

Title: <Body> skin — <first 2 slot names, if any> · Outlands SkinForge (e.g. Female skin — Hair, Robe · Outlands SkinForge); when no optional slots are filled: <Body> skin · Outlands SkinForge. Meta description: A shared UO Outlands character skin design. View it, remix it, or make your own at Outlands SkinForge.

10.3.3 /catalog — Catalog overview #

Purpose: browsing entry point, independent of the designer.

Above-the-fold layout: page heading, one card per slot (19 cards: body and backpack included) in a responsive grid (10.8), each card showing slot display name, published asset count, and a representative thumbnail; below the grid, a "Hues" entry card linking to /hues.

Components present: global shell; slot card grid (Section 15.2); no islands — this page is fully server-rendered because it carries no interactive filter state.

Server data loaded: per-slot published asset counts and one representative asset_images thumbnail per slot (most-recently-published), and total published hue count for the hues card.

Empty state: a slot with zero published assets still renders its card, with count 0 and a placeholder silhouette thumbnail instead of an asset thumbnail; the card remains a working link to /catalog/:slotKey, which then shows the catalog empty state (15.7).

Title: Browse the Catalog · Outlands SkinForge. Meta description: Browse every hair style, hue, and cosmetic slot available in Outlands SkinForge, extracted from the live UO Outlands client.

10.3.4 /catalog/:slotKey — Slot asset grid #

Purpose: filterable, searchable listing of every published asset in one slot. Full specification of grid, filters and search is owned by Section 15.3 and 15.6; this entry covers only IA and routing.

Above-the-fold layout: page heading with slot display name and count, filter panel (collapsed to a toggle on mobile, 10.8), asset grid below.

Components present: global shell; CatalogFilterBar island (Section 15.3) hydrated for filter/sort/search interactivity; server-rendered asset grid, re-rendered via a fetch to Section 16's catalog endpoint when filters change client-side (no full page reload).

Server data loaded: first page of published assets for the slot (default sort, default filters) for first paint; full filter facet counts (body, hue group, tags, new-in-build).

Empty/error states: unknown slotKey renders the 404 page. Zero results after filtering renders the zero-result state (15.7), not a 404, because the slot itself is valid.

Title: <Slot display name> · Catalog · Outlands SkinForge. Meta description: Browse all <slot display name> options for UO Outlands character skins. Preview hues, compare styles, and use any item directly in the designer.

10.3.5 /catalog/:slotKey/:assetKey — Asset detail #

Purpose: single-asset deep dive. Full content spec owned by Section 15.4.

Above-the-fold layout: large preview with hue chooser, both gender variants side by side where the asset supports both, metadata panel, primary actions.

Components present: global shell; AssetHuePreview island (Section 15.4); "Use in Designer" button (navigates to / with the transient query string set to a single-slot design, Section 13.6); "Copy Asset Key" button (clipboard write, toast per 11.9's toast pattern).

Server data loaded: the assets row, its asset_variants (per body), representative asset_images, related assets (same slot, shared tags, limit 6), and first-seen game_builds label.

Empty/error states: unknown slotKey or assetKey, or an assetKey that exists but belongs to a different slotKey than the URL states, renders the 404 page. A retired asset renders normally with a visible "Retired" badge (15.4) — retirement is not a 404 condition.

Title: <Asset display name> — <Slot display name> · Outlands SkinForge. Meta description: <Asset display name>, a <slot display name> option for UO Outlands character skins. Preview every hue and use it in your own design.

10.3.6 /hues — Hue browser overview #

Purpose: entry point to the hue catalog. Full content spec owned by Section 15.5.

Above-the-fold layout: hue group tabs, swatch grid for the active group.

Components present: global shell; HueGroupBrowser island (Section 15.5).

Server data loaded: all hue_groups with member counts; first group's hues rows (swatch colours, name, index) for first paint.

Empty/error states: none — hue data is always present once a build is published; if no build has ever been published, the page renders the "no build published yet" state (10.3.12 shared empty state, below), which also covers /catalog and /.

Title: Browse Hues · Outlands SkinForge. Meta description: Every hue available in UO Outlands, organized by group, with the full 32-colour ramp for each.

10.3.7 /hues/:hueIndex — Hue detail #

Purpose: single hue deep dive. Full content spec owned by Section 15.5.

Above-the-fold layout: hue name and index, 32-colour ramp, sample application on a reference asset, copy-hue-number action.

Components present: global shell; HueDetailPanel island for the copy action and reference-asset switcher.

Server data loaded: the hues row, its hue_group_members group, and a fixed reference asset per slot family (configured in core/hue-preview-reference.ts, one asset_key per representative slot: hair, torso_inner, body) used to render the sample application.

Empty/error states: unknown or out-of-range hueIndex renders the 404 page.

Title: Hue <hueIndex> — <hue name> · Outlands SkinForge. Meta description: Preview hue <hueIndex> (<hue name>) applied to hair, clothing, and skin in UO Outlands SkinForge.

10.3.8 /about — About #

Purpose: project background, relationship to UO Outlands, credits, fan-project framing (full legal wording owned by Section 20.8 and cross-linked here).

Above-the-fold layout: heading, short mission paragraph, disclaimer banner (10.4), credits list.

Components present: global shell only. No islands.

Server data loaded: none (static content compiled into the route).

Empty/error states: none.

Title: About · Outlands SkinForge. Meta description: Outlands SkinForge is a free, unofficial fan tool for designing UO Outlands character skins. Learn how it works and who built it.

10.3.9 /faq — FAQ #

Purpose: answers to recurring questions (permalinks never expiring, no accounts, asset accuracy after patches, how to report a wrong asset).

Above-the-fold layout: heading, list of expandable question/answer pairs (native <details> elements, no JS dependency).

Components present: global shell only. No islands.

Server data loaded: none.

Empty/error states: none.

Title: FAQ · Outlands SkinForge. Meta description: Answers to common questions about Outlands SkinForge: permalinks, accuracy, updates after game patches, and how to report an issue.

Purpose: single page hosting the full unofficial-fan-project disclaimer, intellectual-property statement, privacy stance (Section 20.7), and takedown request process (Section 20.8).

Above-the-fold layout: heading, table of contents (anchor links), sections below.

Components present: global shell only. No islands.

Server data loaded: none.

Empty/error states: none.

Title: Legal & Fan-Project Disclaimer · Outlands SkinForge. Meta description: Outlands SkinForge's fan-project disclaimer, intellectual property notice, privacy practices, and takedown request process.

10.3.11 /support — Support #

Purpose: hosts the donation link (SKINFORGE_DONATION_URL, Section 24) and a short thank-you note. No payment processing occurs on this site; the link is an outbound link to a third-party donation page.

Above-the-fold layout: heading, short paragraph, prominent donation button (outbound link, rel="noopener noreferrer"), note that donations are optional and support hosting costs only.

Components present: global shell only. No islands. The donation button is server-rendered from SKINFORGE_DONATION_URL; if that variable is empty the button is omitted and replaced with "Donations are not currently open" text (never a broken link).

Server data loaded: SKINFORGE_DONATION_URL value only.

Empty/error states: covered above (missing env var case).

Title: Support the Project · Outlands SkinForge. Meta description: Outlands SkinForge is free with no ads. If you'd like to help cover hosting costs, here's how.

10.3.12 /changelog — Changelog #

Purpose: human-readable history of published game builds and notable asset/hue additions, generated from game_builds and import_runs (Section 6), for transparency after UO Outlands patches.

Above-the-fold layout: heading, reverse-chronological list of build entries, each with build label, publish date, and a short auto-generated summary (counts of new/changed/retired assets).

Components present: global shell only. No islands.

Server data loaded: published game_builds rows ordered by published_at descending, joined to aggregate counts from the import_runs that fed each build.

Empty state (shared with /, /catalog, /hues when no build has ever been published): a full-width banner reading "SkinForge is being set up. Check back soon." replaces the list; this is the only page state reachable before the first admin publish action (Section 17.10).

Title: Changelog · Outlands SkinForge. Meta description: What's new in Outlands SkinForge: game build imports, new assets, hues, and fixes.

10.4 Global shell #

Every public and admin page shares one root layout (web/routes/_app.tsx):

+----------------------------------------------------------+
| Skip-to-content link (visually hidden until focused)     |
+----------------------------------------------------------+
| Header: logo/wordmark | nav: Designer · Catalog · Hues   |
|         · About        | theme is automatic, no toggle   |
+----------------------------------------------------------+
| Disclaimer banner (see below)                             |
+----------------------------------------------------------+
| <main id="main-content">  page content  </main>           |
+----------------------------------------------------------+
| Footer: nav: FAQ · Legal · Support · Changelog             |
|         donation link | "Unofficial fan project" line     |
|         | contact email (SKINFORGE_CONTACT_EMAIL)         |
+----------------------------------------------------------+

Skip-to-content link: first focusable element in the DOM, <a href="#main-content" class="skip-link">Skip to content</a>, visually hidden by default and shown on :focus per Section 18.5's visually-hidden pattern.

Header navigation: five items — wordmark links to /; Designer links to /; Catalog links to /catalog; Hues links to /hues; About links to /about. Active route gets aria-current="page". No theme toggle is present; theming is automatic per Section 18.10.

Disclaimer banner: persistent, rendered on every page directly under the header, not dismissible. Exact wording, fixed site-wide:

"Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."

This wording also appears verbatim in the footer and in the /legal page's opening statement, so the disclaimer is textually identical and always present everywhere it appears, satisfying the "persistent and clear" requirement from the project's scope. Section 18.11 owns the content-style rule that produced this exact wording, quoted verbatim; this section owns its placement.

Donation link placement: footer only, labelled Support this project, linking to /support (not directly to the outbound donation URL, so the destination always passes through the framing text on that page first).

Footer contact: mailto: link built from SKINFORGE_CONTACT_EMAIL (Section 24), labelled Contact.

10.5 URL and state rules #

  • Canonical URLs are lowercase kebab-case with no trailing slash. A request with a trailing slash (e.g. /catalog/) issues a 301 redirect to the slash-free form. This is enforced in root middleware before route matching.
  • /design is a permanent alias of /: any request to /design, with or without a query string, issues a 301 redirect to / with the same query string appended unchanged.
  • The designer's transient query-string form (Section 13.6) is never itself canonicalized or redirected — two different query strings are two different transient states, both valid, neither indexed (10.6).
  • 301 (permanent) is used for: trailing-slash normalization, /design/, and slot/asset key casing normalization (/catalog/Hair/catalog/hair). No 302 is issued anywhere: the post-Share transition to /d/:code is a client-side history.pushState (Section 11.9), not an HTTP redirect.
  • Case-insensitive route segments: :code (Section 13.4), :slotKey, :assetKey. All are lowercased server-side before lookup; the canonical rendered URL and all internal links always use the stored lowercase form.

10.6 SEO #

Title and meta description templates are given per-page in 10.3. General rules:

  • Every page sets a unique <title> and <meta name="description">. No page inherits a generic fallback.
  • Open Graph tags on every public, indexable page: og:type (website on every page, including /d/:code), og:title (same as <title>), og:description (same as meta description), og:url (canonical absolute URL), og:site_name (Outlands SkinForge), og:image, og:image:width (1200), og:image:height (630), and og:image:alt (the generated sentence from Section 12.10 on /d/:code; the site tagline elsewhere). og:image value: for /d/:code it is the absolute URL to /og/d/:code.png (Section 14); for every other page it is a single static site-wide social card at /social-card.png (1200×630, committed at web/static/social-card.png, Section 14.9 owns it; no per-page variants outside /d/:code).
  • Twitter tags: twitter:card = summary_large_image on every page, twitter:title, twitter:description, twitter:image mirroring the Open Graph values above.
  • JSON-LD: / uses WebApplication type (name, description, applicationCategory DesignApplication, operatingSystem Any, offers with price: "0"). /d/:code uses ImageObject type (name derived from the page title, url, image pointing at the OG image, isPartOf referencing the WebApplication at /); no CreativeWork markup is emitted. /catalog/:slotKey/:assetKey uses ImageObject type for the primary preview image. No other route emits JSON-LD.
  • sitemap.xml: generated per-request by web/routes/sitemap.xml.ts, cached at the edge for 1 hour (Section 19.4). Contents: every static/marketing route (/, /catalog, /hues, /about, /faq, /legal, /support, /changelog), every /catalog/:slotKey for published slots, every /catalog/:slotKey/:assetKey for published, non-retired assets, every /hues/:hueIndex for published hues, and every /d/:code for designs created in the last 90 days (older designs are excluded from the sitemap to bound its size, but remain individually indexable and crawlable via internal links — see the indexability decision below). changefreq is weekly for /, daily for /changelog, monthly for catalog and hue pages, and never for /d/:code entries (designs are immutable). lastmod uses updated_at/published_at where available, created_at for designs.
  • robots.txt contents (static):
    User-agent: *
    Allow: /
    Disallow: /admin
    Disallow: /api
    Disallow: /design
    Sitemap: <SKINFORGE_PUBLIC_BASE_URL>/sitemap.xml
  • opensearch.xml: static OpenSearch descriptor, ShortName = SkinForge, Description = Search the Outlands SkinForge catalog, Url template pointing at /catalog?search={searchTerms} (Section 15.6), Image 16×16 favicon reference.
  • Indexability decision for design permalinks: /d/:code pages ARE indexable (index, follow). Justification: each permalink is a unique, immutable, content-rich page (a specific character skin with a real preview image) that provides genuine search value ("UO Outlands hair hue X preview") and drives organic discovery of the tool; the risk of low-value thin pages is mitigated because every permalink carries a real composited image, a text summary of its slots, and canonical URL — there is no near-duplicate content risk since each code maps to exactly one immutable design. Designs older than 90 days are dropped from the sitemap only to bound sitemap size, not de-indexed; they remain index, follow and reachable through the /catalog and /hues "used in" cross-links (Section 15.4's related-assets panel links back to designs is explicitly out of scope — no such reverse index exists — so in practice very old permalinks rely on external backlinks and direct sharing for discovery, which is an accepted tradeoff).

10.7 Progressive enhancement contract #

Works fully with JavaScript disabled:

  • /d/:code permalink view: the server-rendered summary strip and the <img> composite (Section 8.10's server-side render pipeline) show the complete design. The designer island does not hydrate, so no editing controls render; in their place, the no-JS fallback below is shown.
  • /catalog, /catalog/:slotKey, /catalog/:slotKey/:assetKey, /hues, /hues/:hueIndex: fully browsable. Filters on /catalog/:slotKey degrade to a server-rendered <form method="get"> that reloads the page with query parameters (Section 15.3); search likewise degrades to a GET form.
  • /about, /faq, /legal, /support, /changelog: fully static content, no JS dependency at all.
  • All static preview images (/render/...) load as plain <img> tags everywhere; they never depend on client-side canvas.

Requires JavaScript:

  • The live designer at / and the editing capability at /d/:code (slot rail interaction, option panel, hue picker, live canvas preview, undo/redo, randomize, share, URL query-string sync).
  • Client-side catalog filter/search refinement without a full page reload (the no-JS GET-form path above always remains available as the underlying mechanism).

No-JS fallback UI for the designer: when the DesignerRoot island's mount point renders with JavaScript unavailable, the server still renders, inside a <noscript> block styled as the primary content, a static read-only view: the same summary strip and composite image used on /d/:code, plus a short message: "Enable JavaScript to use the interactive designer. You can still browse the catalog without it." with a link to /catalog. On / with no query string and no JS, this fallback shows the default blank design's composite (a bare human_male body, skin hue 0) instead of an empty page.

10.8 Responsive breakpoints #

Named breakpoints (Tailwind 4.x default scale, used verbatim so Section 18.4's token mapping applies without renaming):

Name Min width Typical device
base 0px small phones
sm 640px large phones
md 768px tablets portrait
lg 1024px tablets landscape, small laptops
xl 1280px desktops
2xl 1536px large desktops

Layout at each breakpoint:

  • Designer (/, /d/:code): basesm — single column, preview stage on top, slot rail as a horizontal scroll strip below it, option panel as a bottom sheet (Section 11.12) triggered by selecting a slot. mdlg — two columns: preview stage + slot rail on the left (slot rail becomes a vertical list), option panel as a fixed-height side panel on the right that is always visible. xl+ — three columns as in Section 11.1's desktop wireframe: slot rail, preview stage, option panel, all simultaneously visible with no overlay behavior.
  • Catalog (/catalog/:slotKey): basesm — single column grid (2 cards per row), filter panel collapsed behind a "Filters" toggle button opening a full-screen sheet. md — 3 cards per row, filter panel still collapsed. lg+ — 4 cards per row, filter panel permanently visible as a left sidebar.
  • Permalink (/d/:code): same layout rules as the designer at each breakpoint; the read-only summary strip sits above the preview stage at all breakpoints.

10.9 Error pages #

Page HTTP status Trigger Copy Behaviour
404 404 Unmatched route; unknown :code, :slotKey, :assetKey, :hueIndex Heading "Page not found". Body: "That page doesn't exist, or the link may be broken." Links: "Go to the designer" (/), "Browse the catalog" (/catalog). Global shell renders around it; X-Robots-Tag: noindex header set.
410 410 :code resolves to a design with designs.taken_down_at set (SF-2005; legal process in Section 20.8) Heading "This design is no longer available". Body: "This design has been removed following a rights request." Link: /legal. No image or design data is included in the response body; X-Robots-Tag: noindex.
429 429 Rate limit exceeded on /api/v1/* or /render/* (Section 16.7, 19.8) reached via direct navigation (rare; normally only fetch calls hit this) Heading "Too many requests". Body: "You've hit a rate limit. Please wait a moment and try again." Response includes Retry-After header; page auto-retries the triggering fetch client-side once the header's interval elapses, if the trigger was a fetch rather than navigation.
500 500 Uncaught handler exception Heading "Something went wrong". Body: "An unexpected error occurred. It has been logged. Please try again." Includes the requestId in small text for support reference. Error and requestId logged server-side per Section 21.1 before the page renders; no stack trace ever reaches the response body.
Maintenance 503 SKINFORGE_MAINTENANCE_MODE=true (Section 24) Heading "SkinForge is temporarily down for maintenance". Body: "We'll be back shortly. Thanks for your patience." Root middleware short-circuits every route except /admin/* and static assets, returning this page with Retry-After: 300 and Cache-Control: no-store.

All error pages use the same global shell (10.4) so the disclaimer and navigation remain present.

10.10 Analytics events recorded server-side per page #

Recorded as structured log lines consumed by the aggregate counters in Section 21.2; no client-side analytics script exists (Section 21.6 and Section 20.7 both prohibit one).

Page/route Event Notable fields
/ page_view_designer hasSeedQuery: boolean
/d/:code page_view_design code, isRemixEntry: boolean (always true for this route)
/catalog/:slotKey page_view_catalog_slot slotKey, filterCount
/catalog/:slotKey/:assetKey page_view_asset_detail slotKey, assetKey
/hues/:hueIndex page_view_hue_detail hueIndex
any route http_error status, requestId, path

Every event additionally carries the fields common to all server log lines defined in Section 21.1 (timestamp, requestId, route, method, status, durationMs). No IP address, user agent string, or any other client-identifying field is included in analytics events; only the access log (Section 21.1) retains a truncated, hashed IP for abuse detection, per Section 20.7.

11. Designer UI — Components, State & Interaction #

11.1 Screen anatomy #

Three regions: slot rail (choose which slot is being edited), preview stage (live composite and per-slot static previews, Section 12), option panel (assets and hues available for the selected slot).

Region proportions by breakpoint (breakpoint names per Section 10.8):

Breakpoint Layout Slot rail Preview stage Option panel
basesm stacked horizontal scroll strip, 72px tall, under preview full width, fixed 1:1 aspect area at top bottom sheet overlay (Section 11.12), hidden until a slot is tapped
mdlg two column vertical list, fixed 88px wide, left column remaining left-column height, right of rail right column, 360px fixed width, always visible
xl+ three column vertical list, 96px wide, column 1 column 2, flexible width, min 420px column 3, 380px fixed width, always visible

ASCII wireframe, desktop (xl+):

+--------+-----------------------------------+------------------------+
| Slot   |                                   |  Option Panel          |
| Rail   |         Preview Stage             |  [Search within slot]  |
| [body] |   +---------------------------+   |  [Sort: Name v]        |
| [tatt] |   |                           |   |  +----+ +----+ +----+ |
| [foot] |   |   live composite canvas   |   |  |thmb| |thmb| |thmb| |
| [legI] |   |    260x330 upscaled       |   |  +----+ +----+ +----+ |
| [torI] |   |                           |   |  +----+ +----+ +----+ |
| [torM] |   +---------------------------+   |  |thmb| |thmb| |thmb| |
| [arms] |   [Static per-slot preview row]   |  +----+ +----+ +----+ |
|  ...   |   [Undo][Redo][Reset]             |  === Hue picker(pinned)|
|        |   [Randomize]      [Share]        |                       |
+--------+-----------------------------------+------------------------+

The hue picker (bottom row of the option panel above) is pinned to the panel's bottom edge and stays visible while the asset grid above it scrolls independently (11.6).

ASCII wireframe, tablet (mdlg):

+--------+--------------------------+------------------+
| Rail   |    Preview Stage         |  Option Panel     |
| (icons |    live composite        |  (always visible, |
|  only) |    static preview row    |   360px column,   |
|        | [Undo][Redo][Reset]      |   hue picker      |
|        |    [Randomize] [Share]   |   pinned bottom)  |
+--------+--------------------------+------------------+

ASCII wireframe, mobile (basesm):

+---------------------------------------------+
|            Preview Stage                     |
|         live composite canvas                |
|   [Undo][Redo][Reset]                         |
|         [Randomize]    [Share]                |
+---------------------------------------------+
| < [body][tatt][foot][legI][torI] ... >  <- scroll
+---------------------------------------------+
   (tapping a slot opens the bottom sheet)
+---------------------------------------------+
|  ============ bottom sheet ================  |
|  [Search]  [Sort v]                           |
|  [thmb][thmb][thmb]                           |
|  === Hue picker (pinned to sheet bottom) ===  |
+---------------------------------------------+

11.2 Component/island tree #

All designer components live under web/islands/designer/ (islands) and web/components/designer/ (server-only presentational components). Only DesignerRoot is mounted as a Fresh island; everything under it is a plain Preact component tree hydrated within that one island boundary — Fresh does not nest separate islands inside it, to avoid duplicate hydration boundaries and state-sharing friction.

Component Kind Props (TypeScript) Children State location
DesignerRoot island { initialDesign: Design; slots: SlotSummary[]; assetIndex: AssetSummary[]; mode: "create" | "view-code"; code?: string } SlotRail, PreviewStage, OptionPanel, ShareButton, RandomizeButton, UndoRedoControls, ResetButton owns the root DesignState signal (11.3)
SlotRail plain { slots: SlotSummary[]; activeSlot: SlotKey; filledSlots: Set<SlotKey>; bodyCode: BodyCode; onSelect: (slot: SlotKey) => void } SlotRailItem × 19 reads root signal, no local state
SlotRailItem plain { slot: SlotSummary; isActive: boolean; isFilled: boolean; isDisabled: boolean; onSelect: () => void } icon, label, badge none
BodyToggle plain { value: BodyCode; onChange: (b: BodyCode) => void } two role="radio" segments (Male/Female) reads root signal, child of PreviewStage
PreviewStage plain { design: DesignState; scale: 1 | 2 | 3 } LiveCompositeCanvas, StaticPreviewRow (Section 12 owns both), BodyToggle reads root signal
OptionPanel plain { activeSlot: SlotKey; design: DesignState; assetIndex: AssetSummary[] } AssetSearchBar, AssetSortControl, AssetGrid, HuePicker local signal for search text, sort order, pagination cursor
AssetSearchBar plain { value: string; onChange: (v: string) => void } text input none, controlled
AssetSortControl plain { value: SortOrder; onChange: (v: SortOrder) => void } segmented control (18.5) none, controlled
AssetGrid plain { items: AssetSummary[]; selectedAssetKey: string | null; onSelect: (assetKey: string | null) => void; hasMore: boolean; onLoadMore: () => void } AssetGridItem ×N, "None" tile, load-more control none, controlled
AssetGridItem plain { asset: AssetSummary; isSelected: boolean; onSelect: () => void } thumbnail, label none
HuePicker plain { value: number; onChange: (hue: number) => void; recentHues: number[] } group tabs, swatch grid, search input local signal for active group tab, search text
ShareButton plain { design: DesignState; existingCode?: string } button, toast trigger local signal for request status (idle|pending|done|copy-failed|error)
RandomizeButton plain { onRandomize: (seed: string) => void } button none
UndoRedoControls plain { canUndo: boolean; canRedo: boolean; onUndo: () => void; onRedo: () => void } two icon buttons none
ResetButton plain { onReset: () => void } button (secondary variant, 18.5) none
DescribeDesignButton plain { onActivate: () => void } icon button, aria-label="Describe this design" none; moves focus to the sr-only summary and triggers a polite announcement (Section 18.9)

11.3 State model with Preact Signals #

Root type, owned by DesignerRoot as a single Signal<DesignState>:

// core/schema/design-state.ts
export interface DesignState {
  body: "m" | "f";
  skinHue: number;                 // 0 = as-drawn
  slots: Partial<Record<SlotKey, { assetKey: string; hue: number }>>;
  // slots omits keys entirely for empty slots, mirroring the canonical JSON in Section 13.2
}

export interface DesignerUiState {
  activeSlot: SlotKey;
  recentHues: number[];            // max 8, most-recent-first, session-scoped only
  history: { past: DesignState[]; future: DesignState[] };
}

Signals, all created inside DesignerRoot with @preact/signals:

  • designSig: Signal<DesignState> — the authoritative design.
  • uiSig: Signal<DesignerUiState> — transient UI state, never persisted, never part of the design hash.
  • Derived (via computed()): filledSlotsSig (the Set<SlotKey> of non-empty slots, for the rail badge and option panel "None" highlighting), compositeLayersSig (the z-ordered array of { slot, assetKey, hue } consumed by the live canvas, Section 12.2), canShareSig (true once body is set — always true, since body has a default — kept as a computed for future validation hooks), canUndoSig / canRedoSig (from uiSig.value.history).

Update actions, all pure functions (state: DesignState) => DesignState applied via designSig.value = action(designSig.value, ...), each pushing the pre-action state onto uiSig.value.history.past (subject to the coalescing rules in 11.7) and clearing history.future:

Action Signature Rule
selectBody (state, body: "m" | "f") => DesignState Sets body. No slot is ever cleared, facial_hair included: a slot whose selection is incompatible with the new body is retained and flagged per Section 11.13, contributes no layer to the composite (Section 8.5), and is restored automatically on switching back. A still-incompatible entry is dropped from the canonical document at Share time (Section 13.3), never from editor state.
setSkinHue (state, hue: number) => DesignState Sets skinHue. No range validation here; validation happens at the hue picker input boundary (11.6), which only ever offers valid hues.
selectAsset (state, slot: SlotKey, assetKey: string | null) => DesignState assetKey !== null sets { assetKey, hue: state.slots[slot]?.hue ?? 0 } (preserves a previously chosen hue for the slot if one exists in the current session, otherwise defaults to 0). assetKey === null is equivalent to clearSlot.
setSlotHue (state, slot: SlotKey, hue: number) => DesignState No-op if the slot is empty (there is nothing to hue). Otherwise updates slots[slot].hue.
clearSlot (state, slot: SlotKey) => DesignState Deletes the slot key from slots entirely.
randomize (state, seed: string) => DesignState Full algorithm in 11.8. Replaces body, skinHue, and all slots in one action (one history step).
undo (uiState) => { state, uiState } Pops history.past, pushes current designSig.value onto history.future, sets designSig.value to the popped state. No-op if past is empty.
redo (uiState) => { state, uiState } Symmetric: pops history.future, pushes current onto history.past. No-op if future is empty.
reset (state) => DesignState Returns the hard-coded default design: { body: "m", skinHue: 0, slots: {} }. Counts as one history step.

undo/redo are implemented outside the plain-reducer table above because they mutate uiSig as well as designSig; they are exposed as two additional functions in web/islands/designer/actions.ts — not core/, since Preact Signal types are a framework import and Section 4.5's dependency rule keeps core/ free of them — with signature (designSig, uiSig) => void that perform both writes atomically inside a batch() call from @preact/signals, so subscribers see one combined update.

Supporting types referenced throughout the props table in 11.2 and the state model above:

// core/domain/slots.ts — the slot registry, z-order and SlotKey type live here; also exports the
// Zod slot-key validator. core/schema/design.ts imports this rather than redeclaring it (Section 13).
export type SlotKey =
  | "body" | "tattoo_body" | "footwear" | "legs_inner" | "torso_inner" | "torso_middle"
  | "arms" | "gloves" | "waist" | "legs_outer" | "torso_outer" | "neck" | "hair"
  | "facial_hair" | "face" | "earrings" | "head" | "cloak" | "backpack";
// order and membership fixed by the z-order table in Section 3; never extended without a schema
// migration and a corresponding update to Section 3's canonical table.

export type BodyCode = "m" | "f";
export type SortOrder = "name" | "newest" | "most_used";

// core/schema/slot-summary.ts
export interface SlotSummary {
  key: SlotKey;
  displayName: string;
  zOrder: number;               // matches Section 3's z column
  isHueable: boolean;           // always true per Section 3, kept explicit for forward compatibility
  genderScope: "both" | "male" | "female";
  publishedAssetCount: number;
}

// core/schema/asset-summary.ts
export interface AssetSummary {
  assetKey: string;
  slot: SlotKey;
  displayName: string;
  bodies: BodyCode[];           // which bodies have a published variant
  thumbnailUrl: string;         // resolves to a Section 8 render URL at hue 0
  isRetired: boolean;
  firstSeenBuildLabel: string;
  useCount: number;             // backs the "Most Used" sort, Section 21.2
}

SlotSummary.genderScope is the single source the rail (11.5) and the body-switch validation (11.13) both read to decide whether a slot is disabled or merely flagged; it is computed server-side once from the slot registry in Section 3.3 and shipped as part of the initial slots prop, so no client-side logic hard-codes the facial_hair exception outside this field.

11.4 URL synchronization #

designSig is mirrored to the query string in the transient form owned by Section 13.6 (?b=m&s=1002&hair=hair.long-wavy:1102&…). Sync is one-directional on load (URL → state, parsed once at DesignerRoot mount from initialDesign, which the server already parsed per Section 10.3.1) and one-directional on change thereafter (state → URL), to avoid feedback loops.

  • A useEffect inside DesignerRoot subscribes to designSig and calls history.replaceState(null, "", url) with the freshly serialized query string.
  • Debounce: 250ms, reset on every designSig write, using a single setTimeout held in a ref (not a signal, since it is not rendered). Hue-drag interactions (11.7) can fire many writes per second; the debounce collapses them to one URL write per pause.
  • History entries: replaceState is used for every routine edit (body change, asset pick, hue change) so the browser back button does not step through every micro-edit. pushState is used exactly once per designer session: on a successful Share (11.9), navigating to /d/:code, so the back button from a shared permalink returns to the pre-share editing state rather than exiting the site.
  • Back-button behaviour: because routine edits use replaceState, pressing back from / mid-edit leaves the designer (browser history has no intermediate designer states to step through) — this is intentional; the transient query string is not meant to build a linear undo history in the URL, only in uiSig.history (11.3), which persists across a replaceState since it does not reload the page.

11.5 Slot rail #

Ordered list: exactly the 19-row z-order table from Section 3 (body through backpack), rail order top-to-bottom matches that z-order regardless of visual composite order elsewhere.

Icon/label treatment: each SlotRailItem shows a 24×24 line icon (Section 18.6) plus a text label — inline text label at xl+ (the icon and label sit side by side, matching Section 11.1's xl wireframe); at baselg the label is not rendered inline (keeping the rail narrow) and the icon instead carries an aria-label plus a tooltip on hover/focus, per Section 18.5's Tooltip rules.

States:

  • Selected: activeSlot === slot.key. Accent-coloured left border (4px) and background tint (Section 18.2 accent token at 12% opacity).
  • Filled: slot has a non-empty entry in designSig.value.slots (or is body, which is always "filled"). Shows a small filled-dot badge in the icon's bottom-right corner.
  • Empty: default state, no badge, muted icon colour (text-muted token).
  • Disabled: only facial_hair on body === "f". Rendered at reduced opacity (Section 18.2 disabled treatment), aria-disabled="true", tabindex="-1", not reachable by arrow-key navigation (skipped in the traversal order below), with a tooltip on hover/focus reading "Not available for female bodies."

Badge counts: the badge is a presence dot, not a count (there is at most one asset per slot); no numeric badges appear on the rail.

Keyboard navigation: the rail has role="listbox" with aria-orientation matching its current axis ("vertical" at md+, "horizontal" at basesm); each SlotRailItem has role="option" and carries aria-selected reflecting activeSlot. Focus is managed with roving tabindex: the active item is tabindex="0", every other item tabindex="-1", so the rail is a single Tab stop. ArrowDown/ArrowRight moves focus and selection to the next non-disabled item; ArrowUp/ArrowLeft to the previous; Home jumps to body (first item); End jumps to backpack (last item). Selection follows focus (single-select listbox pattern) — moving focus immediately calls onSelect, matching the visible highlight to the keyboard position with no extra confirm step, since selecting a slot has no destructive effect.

11.6 Option panel #

Asset grid: thumbnail source is the slot swatch/asset static preview described in Section 12.5 (/render/a/:assetKey/:body/0@1.webp at the current body, always hue 0, with /render/a/:assetKey/:body/0@2.webp as the high-DPI srcset entry — the same contract Section 12.5 owns, and exactly the variant Section 8.11 pre-warms at publish — grid thumbnails always show the as-drawn hue regardless of the currently selected hue, so browsing hues does not have to re-fetch every thumbnail).

Search-within-slot: AssetSearchBar issues a debounced (250ms) request to GET /api/v1/assets?slot= <slotKey>&q=<text>&limit=60 (Section 16) and replaces the grid with the result; there is no client-side index, so search behaves identically whether the slot has ten assets or several hundred. This reuses the same query shape as the standalone catalog's search (Section 15.6) with the results scoped to one slot and rendered at the option panel's smaller tile size.

Sort order: AssetSortControl offers Name (A-Z) (default), Newest, Most Used, mirroring Section 15.3's sort vocabulary for consistency; "Most Used" uses the same stat_counters aggregate Section 21.2 owns.

"None" option: always the first tile in the grid, a dashed-border placeholder tile labelled "None", selected when the slot is empty; selecting it calls clearSlot. Not shown for the body slot (body is mandatory and has no "None").

Pagination: infinite scroll, not a page-numbered control, because the option panel is a compact secondary surface where "load more" friction should be minimal. Page size 60 assets per fetch (larger than the standalone catalog's 24, since grid tiles here are smaller). Triggered by an IntersectionObserver on a sentinel element 400px before the grid's end; while a fetch is in flight a skeleton row (18.5) of 6 placeholder tiles appears at the bottom.

Hue picker: rendered in a sticky region pinned to the bottom of the option panel (and of the bottom sheet at basesm), always visible regardless of asset-grid scroll position, with the asset grid scrolling independently above it — shown whenever the active slot is non-empty (or always for the implicit skinHue control when activeSlot === "body", which shows only the hue picker; the body itself is chosen via the preview-stage body toggle, Section 12.2). Structure:

  • Grouped tabs across the top: one tab per hue_group that contains at least one hue applicable to the active slot (the skin group only appears when activeSlot === "body"), plus an always-present "As-drawn" pseudo-tab that is not a group but a single always-first swatch representing hue 0.
  • Swatch grid below the active tab: one swatch per hue in that group, 24×24px, filled with the hue's resolved swatch colour — swatch_override_argb when non-null, otherwise swatch_color, which import computes from table index 16, the visual midpoint of the ramp (Sections 6.13 and 7.7). The API ships the resolved value on every hue resource (Section 16.8.5), so the swatch is still a pure CSS background-color with zero extra requests. Selected swatch gets a 2px accent ring.
  • Search by hue number and name: a text input above the swatch grid; digits match hueIndex prefix, text matches hue name substring (case-insensitive); matches from any group, and a match jumps the active tab to that hue's group.
  • Recently used: a "Recent" row above the tabs, populated from uiSig.value.recentHues (max 8, most recent first, session-only — never persisted to the URL or storage), shown only when non-empty. Selecting any hue anywhere unshifts it into this list (de-duplicated).
  • "As-drawn": hue 0, always the first swatch, labelled "As-drawn" instead of a hue number.

11.7 Undo/redo #

Stack depth: 50 steps (uiSig.value.history.past capped at 50 entries; oldest dropped on overflow). Applies uniformly to past and future.

What counts as one step: one call to any action in the 11.3 table, with one exception (coalescing, below). randomize and reset are each one step despite changing multiple fields, because they are issued as single atomic actions.

Coalescing rules for rapid hue changes: setSkinHue and setSlotHue calls are coalesced into the in-progress history step when they occur within 400ms of the previous history push AND target the same field (same slot for setSlotHue, or both setSkinHue). This makes dragging a hue slider or rapidly clicking through swatches produce one undo step per "session" of interaction rather than one per intermediate value. Coalescing is implemented by comparing a lastPushMeta: { field: string; at: number } | null kept alongside the history stacks (not itself a signal, a plain ref): if the incoming action matches lastPushMeta.field and Date.now() - lastPushMeta.at < 400, the top of past is replaced instead of a new entry pushed; otherwise a new entry is pushed and lastPushMeta is updated. Any other action type resets lastPushMeta to null, ending the coalescing window.

11.8 Randomize #

Triggered by RandomizeButton. Algorithm, deterministic given a seed so results are reproducible for support/debugging:

  1. Generate a seed: an 8-character Crockford-Base32 string from 40 random bits (crypto.getRandomValues), unless a seed is supplied programmatically (used only in golden-image tests, Section 22.3).
  2. Seed a deterministic PRNG (xorshift32, implemented in core/util/rng.ts) with the seed string's hash.
  3. Body: uniform choice between "m" and "f" — 50/50.
  4. Skin hue: uniform choice among the skin-tagged hue group's members (Section 3's skin hue subset), plus a 10% chance of 0 (as-drawn) — i.e. weight 0.9 spread uniformly across skin hues, weight 0.1 on as-drawn.
  5. Every optional slot except facial_hair participates with independent 70% probability of being filled (30% chance of staying empty), drawn from the PRNG per slot in z-order.
  6. facial_hair participates only if body resolved to "m" in step 3, with the same 70% fill probability.
  7. For each participating slot, the asset is a uniform choice among that slot's published, non-retired, body-compatible assets. If a slot has no such asset, it resolves to empty (or, for backpack, to the default leather backpack, backpack.default, at hue 0).
  8. For each participating slot's chosen asset, the hue is drawn with weights: 40% as-drawn (0), 60% uniform among all hues belonging to any hue group applicable to that slot (not restricted to the skin group, since only body uses the skin group).
  9. The resulting DesignState replaces the current design in a single randomize history step (11.7); uiSig.value.activeSlot is left unchanged (randomize does not change which slot's panel is open).

Reproducibility: the client logs the generated seed to the browser console (development builds only, gated by SKINFORGE_ENV !== "production") and the seed is never sent to the server or stored, since randomize results are not required to be reproducible across sessions — only within one page load's support/debug workflow.

11.9 Share flow #

ShareButton posts the current designSig.value to the design-creation endpoint owned by Section 16 (POST /api/v1/designs). Request body is the canonical design JSON derived from designSig.value per Section 13.3.1's canonicalization rule (slots sorted by z-order, empty slots omitted, hue 0 kept explicit). The button's local status signal drives its label: idle → "Share", pending → "Sharing…" (spinner, disabled), done → briefly "Copied!" before reverting to "Share" after 2 seconds, copy-failed → "Copy manually" (the fallback input is focused and pre-selected), error → "Try Again".

On success, the server returns { data: { code: string, url: string, isNew: boolean } }. The client:

  1. Writes url (an absolute URL) to the clipboard via navigator.clipboard.writeText.
  2. Fallback when the Clipboard API is unavailable or throws (older browsers, insecure context, permission denied): sets status to copy-failed, and renders a temporary read-only text input pre-filled and pre-selected with the URL, inside the toast itself, so the user can manually copy with the browser's native copy command.
  3. Shows a toast (18.5): "Link copied to clipboard" on success, or "Copy this link" (with the fallback input) when the Clipboard API path failed (copy-failed status).
  4. Issues history.pushState(null, "", url) — the one pushState case from 11.4 — so the address bar now shows /d/:code without a full page navigation/reload.

On failure, the request itself can reject two ways: a network/server error sets status error (button label "Try Again", toast "Couldn't create your link. Check your connection and try again."), or a 429 rate-limit response (the design-create bucket, Section 16.7) sets the same error status with toast "You've created a lot of links just now. Try again in a few minutes." (Section 25 owns the SF-3002 mapping this response carries).

Idempotency: when the posted canonical JSON already matches a stored design exactly, the server returns the existing row's code with isNew: false (Section 13.5's collision rule: identical canonical JSON never creates a duplicate row). The client treats isNew: false identically to isNew: true in the UI — same toast, same clipboard write — the flag exists for analytics only (Section 21.2), not for differing UI copy, so re-sharing an unchanged design is silent and friction-free.

11.10 Remix flow #

Opening /d/:code loads that design into the same DesignerRoot used at /, with mode: "view-code" and code set to the loaded value. Any edit action (11.3) immediately and silently transitions the session to an unsaved-changes state: the ShareButton's existingCode prop is cleared internally the moment designSig diverges from the design that was loaded, so pressing Share after an edit always performs a fresh POST /api/v1/designs (never a PATCH — designs have no update endpoint, Section 16, because they are immutable) and therefore always yields either the same code (if the edit round-trips back to an identical canonical JSON, which is treated as the idempotent case in 11.9) or a brand-new code. The original design row is never mutated by this flow; there is no server-side concept of "editing" a stored design, only creating new ones.

UI copy: immediately below the summary strip on /d/:code (10.3.2), a small text line reads: "Editing this design creates a new link when you share — the original stays unchanged." This line is removed from view (or its layout slot repurposed for the standard designer copy) as soon as the design diverges from the loaded one and a fresh Share has not yet happened, replaced by "You're remixing this design. Share to get your own link." — reinforcing that the currently visible state is no longer the saved one.

11.11 Keyboard shortcuts, focus management, focus trapping, Escape #

Global shortcuts (active whenever no text input has focus):

Key Action
Alt+R (Option+R on macOS) Randomize
Ctrl+Z / Cmd+Z Undo
Ctrl+Shift+Z / Cmd+Shift+Z, and Ctrl+Y Redo
Alt+S (Option+S on macOS) Share
Escape Close any open overlay (hue picker search focus, mobile bottom sheet); if none is open, do nothing (focus is never moved by Escape outside a dismissible container)

No single-character shortcut exists anywhere in the designer (satisfying WCAG 2.2's 2.1.4 Character Key Shortcuts without a remap UI): every shortcut above either requires a modifier or is a standard multi-key combination. The numeric rail-jump shortcuts (19, 0) from earlier drafts are removed; rail navigation is arrow-key/Tab-only (11.5).

Focus management: after any slot rail selection, focus moves to the option panel's first focusable element (the search bar) only when the selection was made by pointer input — distinguished by the originating event's PointerEvent.pointerType ('mouse'/'touch'/'pen' moves focus; an empty string or a KeyboardEvent origin does not); keyboard-driven rail navigation (11.5) keeps focus on the rail itself so repeated arrow-key browsing of slots stays fluid, and the option panel updates its content without stealing focus. After Share succeeds, focus moves to the toast's dismiss control (or the fallback copy input, 11.9) so screen reader users immediately land on the actionable element.

Focus trapping in dialogs: the mobile bottom sheet (11.12) and any modal confirmation (none exist in the designer today, but the mechanism is shared with the admin console's modals per Section 18.5) trap focus using a standard cycle: Tab from the last focusable element inside the container moves to the first, Shift+Tab from the first moves to the last, and focus is restored to the element that opened the container when it closes.

Escape behaviour: closes the mobile bottom sheet or clears hue-search focus (returns focus to the search input's parent tab) without discarding any state — Escape never reverts a selection, it only dismisses UI chrome. When no overlay is open, Escape does nothing; it never blurs or moves focus away from whatever control the visitor was using.

11.12 Touch interactions #

Tap targets: every interactive control (rail items, grid tiles, swatches, buttons) has a minimum 44×44 CSS px hit area, enforced via padding even where the visible icon/thumbnail is smaller (e.g. a 24×24 swatch sits inside a 44×44 tappable wrapper).

Swipe between slots: on the preview stage at basesm, a horizontal swipe gesture (detected via pointermove delta, threshold 60px, velocity-independent) moves activeSlot to the next/previous rail item in z-order, mirroring the ArrowLeft/ArrowRight keyboard behaviour in 11.5. Swipes on the option panel or bottom sheet do not trigger this (swipe is scoped to the preview stage element only, to avoid conflicting with the bottom sheet's own drag-to-dismiss gesture below).

Pinch-zoom on the preview: two-finger pinch over the live composite canvas scales its CSS transform between 1x and 3x (matching the scale values from Section 8.6) with no network re-fetch — this is a pure CSS transform: scale() on the already-rendered canvas, not a re-render at a different source resolution, since the canvas element itself always draws at the highest available scale (3x source sprites) already. Pinch is bounded to the 1x–3x range and snaps back to 1x on release if released below 1.1x.

Mobile bottom-sheet option panel: at basesm, selecting a slot opens the option panel as a bottom sheet sliding up from the viewport bottom, starting at 50% of viewport height, with a resize handle at the top. The handle is a real <button> (aria-label="Resize sheet"): Enter/Space cycles the sheet between 50% and 85% height, so resizing is always available without a drag; dragging the handle is a pointer-only enhancement over the same two heights, never the only path (satisfying WCAG 2.2's 2.5.7 Dragging Movements). Dismissal: tap the scrim above the sheet, drag the sheet down past a 30% threshold and release, or press Escape (11.11). The sheet does not auto-close when an asset or hue is selected — the user can keep browsing/adjusting within the same sheet session and closes it explicitly.

11.13 Validation and impossible states #

Situation Detection Recovery User-visible copy
Gender-incompatible selection on body switch After selectBody, any filled slot whose assetKey has no asset_variants row for the new body The slot is NOT auto-cleared (so switching back restores it); instead the SlotRailItem shows a warning badge (amber, 18.2 warning token) instead of the filled dot, and the slot contributes no layer to the composite (there is no body-agnostic variant to fall back to, Section 8.5); the stored selection itself is untouched, so switching back restores it Tooltip on the warning badge: "Not available for this body. Pick a new option or switch back."
Retired asset in a loaded design (via /d/:code or a hand-edited URL) assetKey resolves to an assets row with retired_at set The slot still renders (retired assets remain servable, per Section 6's soft-delete rule) but the SlotRailItem shows an info badge (18.2 text-muted icon variant) and the option panel, if that slot is opened, shows the retired asset pinned at the top of the grid labelled "Currently selected (retired)" even though it is excluded from the normal browsing list below it Banner on first load only, dismissible: "Some items in this design have been retired from the catalog but are shown as originally selected."
Unknown asset key in a URL (transient query string, 13.4) assetKey does not exist in assets at all (not even retired) The field is dropped during server-side parse (10.3.1) before the client ever sees it — the slot is simply absent from initialDesign Banner: "One or more items in the link couldn't be found and were skipped." (same banner mechanism as the hue case below; both conditions combine into one banner listing affected slot names)
Hue index that no longer exists hue value not present in hues for the resolved slot's applicable groups Same server-side drop as above; the slot falls back to hue 0 (as-drawn) rather than being cleared entirely, since the asset selection itself is still valid Included in the same combined banner: "…and some hues were reset to as-drawn."

The combined banner from the last two rows is one dismissible component rendered once per page load when the server-side parse in Section 10.3.1 dropped or altered any field; it lists the affected slot names by display name, joined with commas, and is never shown again for that page load once dismissed.

11.14 Microcopy inventory #

Context Copy
Share button, idle "Share"
Share button, pending "Sharing…"
Share button, done "Copied!"
Share button, error "Try Again"
Share button, copy failed "Copy manually"
Share toast, success "Link copied to clipboard"
Share toast, fallback "Copy this link"
Share toast, error "Couldn't create your link. Check your connection and try again."
Share toast, rate limited "You've created a lot of links just now. Try again in a few minutes."
Randomize button "Randomize"
Body toggle (12.2) "Male", "Female"
"Describe this design" button (18.9) "Describe this design"
Undo button tooltip "Undo"
Redo button tooltip "Redo"
Reset button "Reset"
Reset confirm (none needed — reset is itself undoable, 11.3) n/a
"None" grid tile "None"
Asset search placeholder "Search this slot…"
Hue search placeholder "Search hue name or number…"
Sort control options "Name (A–Z)", "Newest", "Most Used"
Hue picker "as-drawn" swatch label "As-drawn"
Hue picker recent row label "Recently used"
Facial hair disabled tooltip "Not available for female bodies."
Gender-incompatible warning tooltip "Not available for this body. Pick a new option or switch back."
Retired asset banner "Some items in this design have been retired from the catalog but are shown as originally selected."
Dropped-field banner "One or more items in the link couldn't be found and were skipped."
Dropped-field + hue banner "One or more items in the link couldn't be found and were skipped, and some hues were reset to as-drawn."
Remix hint (unedited) "Editing this design creates a new link when you share — the original stays unchanged."
Remix hint (edited) "You're remixing this design. Share to get your own link."
Empty option panel (slot has zero published assets) "No options available for this slot yet. Check back after the next update."
Load-more sentinel, loading "Loading more…" (visually hidden text for the skeleton state, announced to screen readers)
Bottom sheet resize handle, accessible label "Resize sheet" (aria-description: "Press Enter to toggle sheet height, Escape to close")
Skip-to-content link "Skip to content" (Section 10.4)

11.15 Performance #

Interaction-to-paint budget: 100ms from any pointer/keyboard input that changes designSig to the live composite canvas reflecting the change, measured on a mid-tier mobile device profile (Section 19.1 defines the reference device). This is achievable because the live composite is pure client-side canvas compositing over already-fetched sprite bitmaps and the hue table (Section 8.3) — no network round-trip sits on the critical path for any edit once the design's referenced assets are cached.

Image prefetch strategy: when a slot becomes activeSlot, the option panel's first page of asset thumbnails (60 items) is fetched immediately; additionally, DesignerRoot prefetches (low-priority fetch with priority: "low") the full-resolution sprite bitmap for every asset currently present in designSig.value.slots on mount, so switching hues or re-selecting an already-chosen asset never blocks on a network request. Hovering an AssetGridItem for more than 150ms triggers a prefetch of that asset's full-resolution sprite ahead of an actual click. For keyboard focus the delay is 400ms instead of 150ms, and any pending prefetch timer is cancelled when focus moves off the item before it fires — so continuous arrow-key traversal of the grid issues no requests, keeping prefetch volume under 12.9's 6-concurrent cap regardless of input method.

Virtualization: AssetGrid virtualizes rows once more than 120 items have been loaded (i.e. after the third infinite-scroll page), using a fixed-row-height windowing approach (web/islands/designer/virtual-list.ts — a UI windowing helper, not pure domain logic, so it lives under web/ rather than core/, per Section 4.5) that keeps at most 3 screens' worth of rows mounted; below 120 items no virtualization overhead is introduced. The hue picker's swatch grid uses the same windowing approach once a group's member count exceeds 120 (some hue groups, per Section 7.7's curation, run into the thousands); groups below that size render unvirtualized.

12. Preview Surfaces — Live Paperdoll & Per-Slot Static Previews #

This section specifies both required preview modes end to end: the live composited paperdoll and the per-slot static previews. It owns preview layout, browser rendering path, loading/error states, download, print/accessibility preference handling, preview performance limits, accessibility of the visual surface, and the empty/random-design affordance. It consumes the rendering engine defined in Section 8 and the design state model owned by Section 11; it does not redefine either.

12.1 The two modes side by side #

Live paperdoll preview Per-slot static previews
What it shows The full composited character at the current design state, updating on every change Every available asset in one slot, each pre-rendered as-drawn (hue 0)
Who it serves A user actively designing, and any visitor to a permalink page A user choosing which asset to put in a slot, before it is part of the composite
Where it appears Center stage on / (Section 10; /design permanently redirects here, Section 10.5) above the fold; the sole image on /d/:code; embedded (read-only, no controls) on /catalog/:slotKey/:assetKey at a neutral default hue The asset picker panel for the currently open slot in the designer UI (Section 11); the slot grid pages under /catalog/:slotKey (Section 15)
Update trigger Any slot/hue/body change in the current session (Section 11's state model) Opening a slot's picker, or switching body (which changes the variant each tile resolves to)
Backing render Client-side canvas composite (Section 12.3) with server render as source-of-truth fallback (Section 8.12) Server-side per-asset renders (/render/a/..., Section 8.8), never client-composited — each thumbnail is a single sprite at hue 0, pre-warmed at publish (Section 8.11)

Both modes are required simultaneously in the designer UI: the live paperdoll shows the assembled result, the slot strip shows the alternatives. Neither replaces the other.

12.2 Live paperdoll preview #

  • Layout: a single square-ish stage, aspect ratio locked to the canvas's 260:330 (Section 8.1), centered in its container, with a fixed-position toolbar (zoom controls, background switcher, body control, download button) docked below the stage on narrow viewports and to the stage's right on wide viewports.
  • Size at each breakpoint (Tailwind CSS breakpoints, Section 4.1, Section 18.4 owns the token scale this references):
Breakpoint Stage width Stage height Toolbar position
< 640px (mobile) min(88vw, 320px) proportional (width * 330/260) below stage, horizontal scroll row
640–1024px (tablet) 360px 457px below stage, wrapped row
≥ 1024px (desktop) 480px 609px right of stage, vertical column
  • Zoom controls: four states — , , (mapped directly to Section 8.6's scale values) and Fit (scales the rendered 260×330 bitmap to fill the current stage container via CSS object-fit: contain, using whichever of 1×/2×/3× was last rendered — Fit is a display-only transform, never a distinct render request). Default on load: Fit. Zoom state is session-only (not part of the shareable design, Section 13.2) and resets to Fit on a fresh page load. Section 12.2 owns the zoom model outright: there are four discrete states and no continuous or percentage zoom anywhere in the product. Section 11.12's pinch gesture snaps to the nearest of , , on release and never produces an intermediate scale.
  • Background options: transparent (checkerboard, 8px squares at , alternating the two neutral tokens defined in Section 18.2), parchment (a flat warm off-white token, evoking the UO paperdoll frame without depicting copyrighted art), dark (a flat near-black token, for users previewing against a dark game UI). Default: transparent. Background is a CSS treatment behind the canvas element only — it is never baked into any rendered or downloaded image, since Section 8.1 fixes the render output as transparent-background RGBA.
  • Body control: a two-option role="radiogroup" labelled "Body", with role="radio" options Male and Female, reflecting CompositeRequest.body (Section 8.2.2). It is a radiogroup and not an aria-pressed toggle because choosing between two named alternatives is a selection, not an on/off state, and it renders as the segmented control in Section 18.5. This is the only affordance in the product for changing body; Section 11.6's option panel shows the skin-hue picker when the body slot is active and defers the body choice here. Changing it re-resolves every currently selected slot per Section 8.5 and triggers a recomposite; a slot whose selection has no variant for the new body is retained but contributes no layer, per Section 11.13, and the preview reflects that state.
  • DOM/canvas structure:
<figure class="sf-preview" data-testid="live-preview">
  <div class="sf-preview__stage" style="aspect-ratio: 260 / 330;">
    <canvas class="sf-preview__canvas" width="260" height="330"
            aria-hidden="true"></canvas>
    <img class="sf-preview__fallback" alt="" hidden />
  </div>
  <p class="sf-preview__alt-text sr-only" data-testid="preview-alt-text"><!-- Section 12.10 --></p>
  <div class="sf-preview__toolbar" role="group" aria-label="Preview controls">
    <div class="sf-preview__zoom" role="radiogroup" aria-label="Zoom level"><!-- 1x/2x/3x/Fit buttons --></div>
    <div class="sf-preview__background" role="radiogroup" aria-label="Preview background"><!-- 3 buttons --></div>
    <div class="sf-preview__body" role="radiogroup" aria-label="Body"><!-- Male/Female radios --></div>
    <button class="sf-preview__download"><!-- Section 12.7 --></button>
  </div>
</figure>

The <canvas> carries aria-hidden="true" because it is a bitmap with no accessible semantics of its own; the adjacent sf-preview__alt-text element (visually hidden, screen-reader-visible) is the accessible description, per Section 12.10. The <img> fallback element is shown, and the <canvas> hidden, whenever Section 8.12's JS-disabled/API-unavailable path is active; both never render simultaneously.

12.3 Rendering path in the browser #

  • Sprite fetch strategy: for each layer in the current design, the island requests /render/a/:assetKey/:body/0@1.png (hue 0, so the browser applies hue itself per Section 8.12) the first time that exact (assetKey, body) pair is needed in the session, and never again — fetched sprites are kept in an in-memory Map<string, Sprite> keyed by `${assetKey}:${body}` for the page lifetime (cleared only on full page reload). Every variant is scoped to exactly one body (Section 8.5: asset_variants.body is m or f, never both), so switching body refetches one sprite per populated slot the first time that body is used in the session, and hits the map on every switch after that.
  • Sprite geometry: width, height, offsetX, offsetY and the asset's hue_mode come from GET /api/v1/assets?slot=<slotKey> (Section 16.8.3), which the picker already fetches. The render URL carries no geometry (Section 8.12), so placement is never inferred from the bitmap.
  • Cross-origin loading: every sprite <img> and fetch used for compositing sets crossorigin="anonymous", and the render routes return Access-Control-Allow-Origin: <SKINFORGE_PUBLIC_BASE_URL> (Section 8.8). Without both halves the canvas becomes tainted the moment a CDN fronts /render/*, and every getImageData read below — plus the print snapshot in Section 12.8 — throws SecurityError.
  • Hue table fetch and caching: GET /api/v1/hues?full=true (Section 16.8.5) is fetched once, on designer mount, before the first composite; the full HueTable (Section 8.2.2) is held in memory for the page lifetime. This single request (not per-hue lookups) keeps hue changes — the most frequent interaction — entirely local with zero network round-trips after mount.
  • Off-screen canvas: compositing runs against an OffscreenCanvas (or, when unavailable but createImageBitmap/getImageData are present, an in-memory Uint8ClampedArray accumulator with no canvas at all until the final blit) sized 260×330 at scale 1, using Section 8.4's compositor and Section 8.6's scaler; only the final scaled bitmap is written to the visible <canvas> via putImageData, so intermediate layer composition never causes visible flicker.
  • Request coalescing: rapid changes (e.g. dragging a hue slider) do not trigger one fetch or one recomposite per intermediate value. Sprite fetches are deduplicated by the in-memory map above (identical URLs in flight share one fetch promise). Recomposite work is debounced (below); at most one composite is in flight at a time, and if a new change arrives mid-composite, the in-flight result is discarded on completion and a fresh composite is scheduled immediately rather than queued.
  • Debounce values: hue slider/continuous input changes debounce at 80 ms before triggering a recomposite (chosen to feel instantaneous — under the ~100ms threshold for perceived immediacy — while collapsing the 10-20 intermediate events a typical slider drag produces). Discrete changes (asset pick, body change, hue-swatch click) recomposite with no debounce (0 ms), since they are already discrete, deliberate clicks with no rapid-fire sequence to collapse. The debounced navigation that persists transient design state to the URL query string (Section 13.6) uses a separate, longer debounce of 400 ms, decoupled from the recomposite debounce, so URL history does not accumulate an entry per keystroke-equivalent interaction.
  • Fallback to server renders: per Section 8.12, if OffscreenCanvas/createImageBitmap are unavailable or JS is disabled, the live preview area shows /render/d/:code@2.webp-equivalent server-rendered <img> output driven by the query-string design state, updating via the 400 ms debounced navigation above rather than canvas redraws. This is the only condition under which the 400 ms navigation debounce also gates the visible preview update (in the canvas path, the preview updates immediately at 80 ms/0 ms and the 400 ms debounce only affects when the URL — not the picture — catches up).
  • Reference debounce/coalescing implementation:
// web/islands/designer/preview-controller.ts

const RECOMPOSITE_DEBOUNCE_CONTINUOUS_MS = 80;
const RECOMPOSITE_DEBOUNCE_DISCRETE_MS = 0;
const URL_SYNC_DEBOUNCE_MS = 400;

let recompositeTimer: number | undefined;
let inFlight: Promise<void> | null = null;
let pendingRerun = false;

function scheduleRecomposite(kind: "continuous" | "discrete") {
  const delay = kind === "continuous"
    ? RECOMPOSITE_DEBOUNCE_CONTINUOUS_MS
    : RECOMPOSITE_DEBOUNCE_DISCRETE_MS;
  clearTimeout(recompositeTimer);
  recompositeTimer = setTimeout(runComposite, delay);
}

async function runComposite() {
  if (inFlight) {
    pendingRerun = true; // Section 12.3: discard-and-restart, never queue
    return;
  }
  inFlight = doComposite().finally(() => {
    inFlight = null;
    if (pendingRerun) {
      pendingRerun = false;
      runComposite();
    }
  });
  await inFlight;
}

doComposite() fetches any not-yet-cached sprites (Section 12.3's in-memory map, deduplicated by URL), then calls Section 8.4's compositeOver and Section 8.6's scaleNearest against the current design state snapshot taken at call time — not at schedule time — so a rapid sequence of changes always composites the latest state even if several intermediate states were skipped.

12.3.1 Off-thread composition #

Composition and hue application run on the main thread by default, since Section 8.13's budgets keep a single composite under 25 ms of CPU — well within a frame budget and not a source of jank even on mid-range mobile hardware. On devices where navigator.hardwareConcurrency >= 4 and Worker is available, the designer island optionally offloads compositing to a dedicated Web Worker running the same core/hue/ and core/composite/ modules (Section 8), transferring sprite buffers via Transferable ArrayBuffers to avoid structured-clone copy cost; this is a pure performance optimization with no behavioral difference, and its absence (worker unavailable, low core count) never disables live preview — it only means composite work runs on the main thread instead.

12.4 Loading, skeleton, partial and error states #

State Trigger Visual Copy
Initial skeleton Designer mounts, hue table and/or first sprites not yet fetched Pulsing neutral-token placeholder shaped to the 260:330 stage "Loading preview…" (visually hidden aria-live="polite" announcement; no visible text over the skeleton itself)
Per-slot loading A newly selected asset's sprite is still in flight The stage keeps the last valid composite fully visible; no spinner overlay, no dimming — avoids visual flicker on fast networks none (silent; if the fetch exceeds 600 ms, a small non-blocking corner spinner appears with aria-label="Updating preview")
Partial state One or more layers failed to load but at least one layer (typically body) succeeded Composite renders with the failing layer(s) simply omitted; a dismissible inline notice appears below the stage "Some parts of this design could not be shown. [Retry]"
Retired-asset notice A design references a slot asset whose retired_at is set (still renders per Section 9.7 and Section 8.14, but is no longer choosable going forward) A small "Archived" badge on the affected slot's picker entry point only — the preview image itself is unaffected, since retired assets still render normally "This item has been retired and can no longer be added to new designs, but existing designs that use it still display it."
Missing-variant notice User switches body and the current selection has no variant for the new body (Section 8.5, Section 11.13) The affected slot contributes no layer on the next composite, but its selection is retained so switching back restores it; a toast/inline notice names the slot " is hidden because it isn't available for <Male/Female>."
Hard error Hue table fetch fails, or every sprite fetch fails Full-stage error illustration-free placeholder (a neutral icon token, no image dependency) with retry "The preview couldn't load. [Try again]" — the rest of the designer (asset pickers, URL state) remains usable; only the visual stage is affected

All transient text-only states use aria-live="polite" on a single shared live region so screen reader users hear updates without duplicate announcements; the hard-error state additionally moves focus to the retry button only if the error occurs during initial mount (never steals focus for an error that occurs mid-session while the user is actively interacting elsewhere).

12.5 Per-slot static previews #

  • Layout: a responsive grid (CSS grid, auto-fill, minmax(96px, 1fr)) of thumbnail tiles, one per asset variant available for the slot and current body, opened from the slot's entry in the designer's slot list (Section 11.5) or browsed directly at /catalog/:slotKey (Section 15.3, which owns the page chrome; this section owns the tile/grid rendering contract itself).
  • Source: each tile's image is /render/a/:assetKey/:body/0@1.webp — the asset as drawn, at hue 0, always. Tiles never carry the slot's currently selected hue. This is a deliberate cost decision as much as a UX one: hueing the grid would multiply the render cache by the number of selectable hues, defeat the publish-time pre-warm in Section 8.11 (which warms exactly the hue-0 scale-1 variant of every published asset), and make every hue change re-request the whole grid. Section 11.6 states the same rule for the option panel, and the two must not drift apart. The user sees the chosen hue applied on the live stage (Section 12.2), which updates instantly and needs no network request at all.
  • Thumbnail dimensions: 96×96 CSS px tile, image itself is 260×330 scale-1 art centered and object-fit: contain within a 96×122 inner frame (preserving the 260:330 aspect ratio, not cropping), so garment silhouettes are comparable across tiles regardless of a slot's typical sprite footprint. Retina/high-DPI displays receive the scale-2 render (/render/a/:assetKey/:body/0@2.webp) via srcset, never a CSS-upscaled scale-1 bitmap. That scale-2 variant is generated lazily on first request (Section 8.11) rather than pre-warmed, since only high-DPI visitors ask for it.
  • Hover/focus behavior: on hover (pointer) or focus (keyboard), a tile scales to 1.05× (CSS transform, GPU-composited, no layout shift), gains a visible focus/hover ring (Section 18.2 token), and shows the asset's display name in a tooltip/label that is always present but visually de-emphasized at rest (never hover-only text, for touch and accessibility parity). Activating a tile (click, Enter, Space) selects that asset for the slot and closes the picker, returning focus to the slot's entry point in the designer list.
  • Lazy loading: tiles use native loading="lazy" plus an IntersectionObserver-driven fetch-priority hint; only tiles within roughly one viewport height of the visible grid are requested eagerly (fetchpriority="high" for the first row, loading="lazy" for the rest), keeping the initial picker open cost proportional to visible tiles, not total catalog size, for slots with large option counts (e.g. hair).
  • Clearing a slot: every optional slot's grid includes one leading tile labelled "None", which removes the slot from the design entirely (distinct from setting its hue to 0 — see Section 12.6's "As drawn" swatch, which is a hue choice, not an asset choice). The body slot's grid has no "None" tile, because body is required (Section 3.3); backpack does, and clearing it falls back to the default asset backpack.default rather than removing the layer, since the backpack is always visible.

12.6 Hue swatch previews #

  • The swatch form is a flat colour chip. Each hue in the picker renders as a solid square filled with the hue's resolved swatch colour — swatch_override_argb when non-null, otherwise swatch_color, which import computes from table index 16, the visual midpoint of the 32-entry dark-to-light ramp (Sections 6.13 and 7.7) — labelled with the hue's name from hues.name. The API ships the resolved value as swatchArgb on every hue resource (Section 16.8.5), already present in the hue table fetched at mount (Section 12.3), so the chip is still a pure CSS background-color computed client-side and opening the hue picker issues zero additional network requests.
  • Index 16 and not index 0: index 0 is the darkest entry of the ramp, so a swatch drawn from it would render every hue as a near-identical near-black square and the picker would be unusable. swatch_color is computed from index 16 at import time (Section 6.13, Section 7.7), and a curator may override it per hue via swatch_override_argb on the hues admin screen (Section 17.8), which always wins when set. Sections 11.6 and 15.5 use the same resolved value, so all three surfaces show the same chip for the same hue, override included.
  • Why not render each swatch on the selected asset. Doing so would need one server render per (asset, body, hue) combination — thousands of cache entries per asset, none of them pre-warmable (Section 8.11), all of them re-requested the moment the user changes asset. The chip is instant and cache-free; the actual asset-with-hue preview is the live stage, which is one canvas redraw away.
  • Focused hue preview on the stage. While a hue picker is open, the live stage (Section 12.2) crops and zooms to the slot's most hue-visible region so the effect of the highlighted hue is legible without hunting for it. The crop comes from slots.hue_swatch_crop (Section 6.9), a per-slot hint, and falls back to a centred 60%-scale crop when the column is null. This exists because a sparse sprite such as earrings occupies a small corner of the 260×330 frame and its hue change is otherwise invisible at stage size. The crop is a display transform on the already-composited canvas — it is never a distinct render request, never affects the shared design, and is released when the picker closes.
  • Swatches share the same grid, hover/focus, keyboard and lazy-loading rules as Section 12.5, minus lazy image loading, which a CSS-filled chip does not need.
  • The "As drawn" swatch: every hue grid leads with one swatch labelled "As drawn", which sets the slot's hue to 0, meaning the asset renders in its original authored colours. It is rendered as a chip with the checkerboard treatment from Section 12.2 rather than a solid colour, so it reads as "no hue" rather than as one more colour. For a slot whose hue is currently 0, this swatch is the selected one.

12.6.1 Hue grid layout #

Hue swatches are grouped by hue_groups (Section 6.14). The seed data (Section 6.31) creates exactly five groups, and these are their display names: "Skin Hues", "Hair Hues", "Tattoo Hues", "Clothing Hues" and "Event Hues". They are presented as labelled sections within the same slot picker surface used in Section 12.5 — each section a horizontally scrollable row on mobile and a wrapped grid on desktop, using the identical 96×96 tile size.

The "Skin Hues" group is shown first, and only when the currently open picker is the skin-hue control (Section 11.6's body/skin panel, not a cosmetic slot). Cosmetic slot pickers order groups by hue_groups.sort_order (Section 6.14) with "Event Hues" last, since event hues are the least game-authentic and most novel option — appropriate as a discovery-oriented final group rather than a default-facing first one.

12.7 Download #

  • What: a PNG (never WebP — Section 8.7's rationale: maximum local-viewer compatibility) of the current live design, at a scale the user chooses from the same 1×/2×/3× control used for zoom (Section 12.2) — the download always uses the currently selected zoom scale, except Fit, which downloads at (a sensible fixed default when the user has not committed to a specific pixel scale).
  • Source: if the current design has already been saved (has a /d/:code), the download button requests /render/d/:code@:scale.png directly (Section 8.8) — a plain navigation-triggered download (<a download> with the render URL as href), no client compositing involved. If the design is unsaved (still in transient query-string form, Section 13.6), the button first performs an implicit save (same persistence call Section 13.5's Share action uses) and then downloads from the resulting permalink's render URL — a design is never downloadable without also becoming a permalink, so every downloaded image remains traceable to a shareable, immutable source.
  • Filename convention: skinforge-<code>-<scale>x.png, e.g. skinforge-4kx9m2p7qa-2x.png (the example is a valid 10-character Crockford Base32 code: the alphabet excludes i, l, o and u, Section 13.4).
  • Attribution note in metadata: the downloaded PNG's tEXt chunk carries Software: Outlands SkinForge and Description: Unofficial fan-made design preview — <public base URL>/d/:code (base URL from SKINFORGE_PUBLIC_BASE_URL, Section 24.2), written by the same PNG encoder pass described in Section 8.7 as an additional metadata-only step applied to download-purposed encodes (design composites requested with .png through the ordinary render pipeline are not required to carry this chunk — Section 8.9's cache key does not include a metadata variant, so the download flow requests through a distinct, non-cached /render/d/:code@:scale.png?dl=1 variant whose only difference from the cached path is this metadata injection, generated on demand and not itself cached, since it is requested rarely relative to the plain cached render).
  • Rate limiting: the dl=1 metadata-injection path is rate-limited on the same render bucket as every other render request — SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE, default 60 per minute per IP (Section 16.7.1, Section 24.2). It does not get a separate budget, since it reuses the same encoder capacity described in Section 8.13. Because the response is deliberately uncached, it is also the one render path where a client can force real CPU work per request, which is exactly why it sits inside that bucket rather than beside it.

12.8 Print/high-contrast/reduced-motion behaviour #

  • Print: a dedicated print stylesheet (@media print) hides the toolbar, background switcher and all chrome, and renders only the preview snapshot (captured as a static <img> via canvas.toDataURL at the moment print is invoked, since a <canvas> element does not print reliably across browsers) at scale on a plain white background, with the design's short code and the unofficial-fan-project disclaimer printed as a caption beneath the image. The disclaimer is quoted verbatim from Section 10.4, which owns its single wording:

    "Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."

    canvas.toDataURL throws SecurityError on a tainted canvas, which is exactly what a CDN-fronted /render/* would produce without the CORS pair described in Section 12.3 — so the print path depends on that pair being in place, not merely the compositing path. If the snapshot nevertheless fails, printing falls back to the server-rendered /render/d/:code@2.png image, which is same-content and always printable.

  • High-contrast (prefers-contrast: more): the checkerboard transparency background (Section 12.2) switches to a higher-contrast two-tone pair from Section 18's high-contrast token set; all toolbar controls gain visible borders (rather than relying on background-color differences alone) to meet WCAG 2.2 AA non-text contrast requirements per Section 18.

  • Reduced motion (prefers-reduced-motion: reduce): the hover/focus 1.05× tile scale transform (Section 12.5) is disabled (swapped for a static ring/border change only); the corner "updating" spinner (Section 12.4) is replaced by a static pulsing-free "Updating…" text label; no other motion exists in the preview surface to disable, since compositing itself is instantaneous, not animated.

12.9 Performance rules for the preview #

  • Maximum simultaneous image requests: the sprite fetch layer (Section 12.3) caps concurrent in-flight sprite fetches at 6 (a fetch-wrapping semaphore in the same module), queuing the rest FIFO. The bound exists to cap concurrent decode work and peak decoded-pixel memory, not to respect a connection limit — the origin is served over HTTP/2 (Section 23.1), where the classic six-per-origin cap does not apply. The slot grid (Section 12.5) has its own independent 6-slot cap, since it is a logically separate consumer with a different eviction policy.
  • Sprite cache size in the browser: the in-memory sprite map (Section 12.3) is unbounded by count but capped at 24 MB of estimated decoded pixel bytes (width * height * 4 summed across cached entries); on exceeding the cap, entries are evicted least-recently-used, excluding any sprite currently part of the live composite (the active design's own layers are never evicted while displayed).
  • Memory ceiling on mobile: the same 24 MB cap applies on all devices; on devices reporting navigator.deviceMemory <= 2 (where supported; treated as "low-memory" when the API is absent and the viewport matches the mobile breakpoint, Section 12.2) the cap is reduced to 8 MB and the slot-grid thumbnail concurrency (Section 12.5) drops from 6 to 3 simultaneous requests.
  • What is dropped first under pressure: eviction order is (1) sprite cache entries for assets not in the current design or currently open picker grid, oldest-used first; (2) slot-grid thumbnails scrolled out of view (their <img> src is cleared and lazily restored on re-entering the viewport, same IntersectionObserver from Section 12.5); (3) as a last resort, on a WebGLContextLost-style canvas failure signal or repeated OffscreenCanvas allocation failure, the designer falls back to the server-rendered <img> path (Section 12.3's fallback), which has near-zero client memory cost. The live composite's own current-frame buffers (accumulator, final scaled bitmap) are never dropped while visible — they are the last thing eviction touches.

12.10 Accessibility of an inherently visual surface #

  • Alt text generation rule: the live preview's accessible description (the visually-hidden sf-preview__alt-text element from Section 12.2's DOM structure, referenced by aria-describedby on the <canvas>/<img>) is a generated sentence naming the body, the skin hue, and each chosen asset with its hue, in the z-order given by the slot registry in Section 3.3, omitting empty slots and omitting a hue mention when a slot's hue is 0:

    "Male character with Sun-Kissed skin, wearing Long Wavy hair in Raven Black, Plain Robe, and Leather Backpack."

    Generation algorithm: "<Body display name> character with <skin hue name> skin" — or, when skinHue === 0, the fixed opening clause "<Body display name> character with default skin" — then, for each populated slot in z-order excluding body, ", wearing <asset display name>" optionally followed by " in <hue name>" when hue !== 0, joined with commas and a final "and" before the last item, terminated with a period. Asset and hue display names are inserted verbatim, with no article inserted or removed by the algorithm — the catalog's display_name values are curated to read correctly in this position with no algorithmic help (Section 9's import review). The backpack slot is included like any other populated slot — it is a designable slot (Section 3.3), it always has a value, and a description that silently omitted the one item the character is always wearing would be wrong. Slot and asset display names come from slots.display_name and assets.display_name, hue names from hues.name (Section 6) — never the internal slot_key/asset_key strings. This same sentence is reused verbatim as the design's alt attribute wherever the permalink's static image appears outside the designer (Section 10.3.2's /d/:code page, and the og:image:alt value Section 10.6 emits for the card generated in Section 14).

  • Text-mode listing: a <dl> (description list) immediately following the preview, inside a <details> element labelled "View design as text" (collapsed by default, always present in the DOM for screen readers and search engines regardless of collapsed state), listing every populated slot's display name and hue name as term/definition pairs — the structured equivalent of the alt sentence above, for users who want to scan rather than read prose. backpack appears in this listing on the same terms as every other populated slot, for the same reason.

  • Keyboard operation: the zoom radiogroup, background radiogroup and body radiogroup (Section 12.2) each follow the WAI-ARIA radiogroup pattern (arrow keys move selection; Tab enters and exits the group once); the download button is a standard button. No preview control requires a pointer, and every control in the toolbar is reachable in a single static Tab sequence with no roving focus traps.

  • Announcements are on demand, not per change. The design description is not re-announced on every edit — during hue browsing that would produce an announcement every few hundred milliseconds and drown out everything else. Section 18.9 owns the on-demand model: a "Describe this design" control reads the current sentence into the live region when the user asks for it. The full sentence is also available at all times through aria-describedby and the text-mode listing. The shared aria-live="polite" region described in Section 12.4 is reserved for state transitions (loading, partial, error), not for design content.

  • Success criteria this surface must satisfy, beyond the automated audit in Section 22.6 — none of these three is detectable by an automated checker, so each is a manual gate in Sections 26 and 27:

    • 2.1.4 Character Key Shortcuts — the preview surface defines no single-character shortcut. Every keyboard affordance here is either a standard control interaction (Tab, arrows, Enter, Space) or, in Section 11.11's shortcut table, modifier-qualified. A bare letter key typed while the preview has focus does nothing, so a speech-input user can never trigger it by accident.
    • 2.5.7 Dragging Movements — no preview interaction requires a drag. Zoom is a radiogroup, not a slider; the mobile bottom sheet that hosts the picker (Section 11.12) opens, closes and resizes from buttons as well as from the drag handle; hue selection is a grid of activatable tiles, never a drag-along ramp. Every drag in this surface has a single-pointer, non-path alternative.
    • 2.4.11 Focus Not Obscured — the sticky toolbar and the mobile bottom sheet must never cover the focused element. The stage scrolls focused picker tiles into a viewport region inset by the sheet's own height, and the toolbar is position: sticky with a scroll-margin equal to its height on every focusable descendant, so a keyboard user is never left with the focus ring hidden behind chrome.
  • Edge cases in generation: an entirely empty design (Section 12.11) produces "Male character with default skin, wearing Leather Backpack." — the backpack clause is present because the backpack is present; a design with exactly one populated cosmetic slot omits the Oxford-comma "and" construction, e.g. "Female character with Porcelain skin, wearing Plain Robe."; hue names that contain the word "Hue" as a literal prefix (hues whose decoded name was blank and received the generated Hue <index> placeholder, Section 7.7) are used verbatim from hues.name with no text massaging, because that column is curated specifically to read naturally in this sentence position (Section 9's import review step includes a "reads naturally in a sentence" check for exactly this reason).

12.10.1 Preview surface test hooks #

Every interactive or state-bearing element in this section carries a stable data-testid so Section 22's component and end-to-end tests can target them without depending on CSS class names or visible text (which change with copy edits):

Element data-testid
Live preview stage live-preview
Zoom control group preview-zoom
Background control group preview-background
Body radiogroup preview-body
Download button preview-download
Alt-text description element (not a live region) preview-alt-text
Shared status live region (Section 12.4) preview-status
Text-mode design listing preview-text-listing
Per-slot picker grid slot-grid-<slotKey>
Per-slot picker tile slot-tile-<assetKey>
Hue picker grid hue-grid-<slotKey>
Hue picker tile hue-tile-<hueIndex>
Randomize button (empty-state) preview-randomize
Empty-state banner preview-empty-state
Retired-asset badge slot-badge-retired-<slotKey>

These identifiers are stable across releases; renaming one is a breaking change to the test suite and must be coordinated with Section 22's fixture updates in the same change.

12.11 Empty state and the "random design" affordance #

  • Empty state: on first visit to / (or /design, which redirects here, Section 10.5) with no query-string design state (Section 13.6), the preview shows the body = m default with every optional slot empty (only the always-present body and the default backpack.default asset populate, per Section 3.3) and skin hue 0. The alt text (Section 12.10) for this state reads "Male character with default skin, wearing Leather Backpack." A single prominent affordance sits beneath the stage: a "Randomize" button.
  • Random design affordance: the empty state surfaces the same Randomize control specified in Section 11.8 — same label ("Randomize"), same seeded algorithm, same single history step — rendered more prominently while the design is empty. Section 11.8 owns the probabilities; this section adds no variant of its own. Activating it recomposites immediately (0 ms debounce, Section 12.3, since it is a discrete action) and updates the transient URL state per Section 13.6. It never auto-saves a permalink — a random result is exploratory until the user explicitly presses Share (Section 13.5).

13.1 Requirements #

A SkinForge design is a specific, named combination of a body, a skin hue, and zero or more cosmetic slot choices (asset + hue per slot, per the slot registry in Section 3). The permalink system exists to satisfy five hard requirements:

  1. Permanence. A link created today must resolve to the same visual result indefinitely. There is no expiry, no login-gated deletion, and no mechanism for a design to silently disappear except the legal takedown path in Section 20.
  2. No login. Anyone can create a permalink. Creation requires no account, no session, no cookie.
  3. Determinism. The same design, submitted twice, always yields the same short code. Submitting an identical design is not an error and does not create a duplicate row.
  4. Meaning never changes. A link's rendered picture is fixed at creation time. Later changes to the underlying game art (new builds, retired assets, re-hued items) never alter what an existing link shows. Section 13.7 specifies the pinning mechanism.
  5. Patch survival. UO Outlands ships patches that add, remove, or change art. A permalink created before a patch must keep working and keep rendering correctly after the patch, even if the original asset is later retired from the live catalog.

These requirements drive three linked mechanisms: a canonical JSON document (13.2), a canonicalization algorithm that turns that document into a fixed byte string (13.3), and a short-code algorithm that turns that byte string into a URL-safe identifier (13.4).

13.2 The canonical design document #

The canonical design document is the single source of truth for what a design looks like. It is stored, hashed, and used to re-render the design at any point in the future.

{
  "v": 1,
  "body": "m",
  "skinHue": 1002,
  "slots": [
    { "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 },
    { "slot": "torso_outer", "asset": "robe.plain", "hue": 0 }
  ]
}

13.2.1 Field definitions #

Field Type Required Allowed values Notes
v integer yes 1 (current) Document format version. Bumped only if the shape changes incompatibly. A design row always records the v it was created with; old versions are read-migrated at render time, never rewritten in place.
body string yes "m", "f" Matches the body code strings from Section 3.
skinHue integer yes 0 or a hue index tagged into the skin hue group (Section 3, Section 6) 0 means the body renders with the art's own unhued skin tone.
slots array yes (may be empty []) see 13.2.2 One entry per occupied cosmetic slot, out of the 18 choosable slots in the registry (Section 3), backpack included. Never contains an entry for body itself — body and skinHue are top-level fields, not slot entries. May contain an entry for backpack, exactly like any other choosable slot; when a backpack entry is absent, rendering (Section 8) applies the default asset key backpack.default at hue 0, since a paperdoll always shows a backpack layer — backpack is the one slot whose omission has a non-empty rendering fallback rather than "nothing rendered."

13.2.2 Slot entry shape #

Each element of slots is:

Field Type Required Allowed values Notes
slot string yes one of the 18 choosable slot_key values in Section 3 (the 17 optional cosmetic slots plus backpack) Duplicate slot values in the same array are rejected.
asset string yes a published asset_key whose slot_key matches this entry's slot, and whose gender compatibility matches body (Section 3's gender notes; facial_hair is male-only, checked here) Must resolve at write time; an unknown key is rejected with SF-2001, a key that exists but belongs to a different slot with SF-1019, and a gender-incompatible key with SF-1017 (Section 25).
hue integer yes 0 or a published hue index 0 means "as-drawn" — the asset's native colours from its source art, no hue table applied.

13.2.3 Ordering rule #

slots is always stored and served sorted by ascending z-order, using the z values from the slot registry in Section 3 (tattoo_body=20 first, ... cloak=180, backpack=190 last among choosable slots). Ordering is not client-supplied; the server re-sorts on every write. This makes the canonical form independent of the order slots were chosen in the UI and is required for the hashing in 13.3 to be deterministic.

13.2.4 Omission rule #

A slot with no selection is omitted entirely from slots — there is no null-asset placeholder entry. An empty slots: [] array is valid and represents a bare body with only the skin hue applied.

13.2.5 Full Zod 4.x schema #

Lives at core/schema/design.ts, imported by the API handler in Section 16.8, the designer island in Section 11, and the CLI. This is the single validation source; no other module re-implements these rules.

// core/schema/design.ts
import { z } from "zod";
import { SLOT_KEYS, CHOOSABLE_SLOT_KEYS } from "../domain/slots.ts";

export const BodyCodeSchema = z.enum(["m", "f"]);

export const HueIndexSchema = z.number().int().min(0).max(65535);

export const AssetKeySchema = z
  .string()
  .min(1)
  .max(128)
  .regex(/^[a-z0-9]+(?:[-_.][a-z0-9]+)*$/, "must be a lowercase dotted/kebab asset key");

export const DesignSlotEntrySchema = z.object({
  slot: z.enum(CHOOSABLE_SLOT_KEYS as [string, ...string[]]),
  asset: AssetKeySchema,
  hue: HueIndexSchema,
}).strict();

export const DesignDocumentSchema = z.object({
  v: z.literal(1),
  body: BodyCodeSchema,
  skinHue: HueIndexSchema,
  slots: z.array(DesignSlotEntrySchema).max(CHOOSABLE_SLOT_KEYS.length),
}).strict()
  .superRefine((doc, ctx) => {
    const seen = new Set<string>();
    for (const [i, entry] of doc.slots.entries()) {
      if (seen.has(entry.slot)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: `duplicate slot "${entry.slot}"`,
          path: ["slots", i, "slot"],
        });
      }
      seen.add(entry.slot);
    }
  });

export type DesignDocument = z.infer<typeof DesignDocumentSchema>;

.strict() rejects unknown top-level and per-slot keys, satisfying the "unknown keys rejected" rule. Cross-field checks that require catalog lookups (asset exists, asset's slot_key matches, gender compatibility, hue exists and is published) run in the API handler after this shape-level parse, because they need database access that a pure schema module does not have. Those checks report the code matching the specific failure — SF-2001 (asset key does not exist), SF-1019 (asset exists but does not belong to the declared slot), SF-1017 (gender-restricted asset on an incompatible body), or SF-2002 (hue index does not exist) — each with the offending field path (Section 16.4).

13.3 Canonicalization algorithm #

Canonicalization produces one exact byte string for a given DesignDocument value, so that hashing is deterministic regardless of how the object was constructed or serialized upstream.

13.3.1 Rules #

  1. Start from the validated, server-normalized document: slots already sorted by z-order (13.2.3), no omitted-slot placeholders, no unknown keys (guaranteed by .strict() in 13.2.5).
  2. Serialize as JSON with keys in a fixed order, not alphabetical: v, body, skinHue, slots; within each slot entry: slot, asset, hue. This fixed order matches the example in 13.2 and is chosen for human readability of the canonical form; it is fixed by code, not by a generic "sort object keys" step, because object key order is not semantically meaningful in JSON and a naive alphabetical sort would reorder slots/slot collisions inconsistently across engines.
  3. No whitespace: no spaces after : or ,, no newlines, no trailing newline.
  4. Numbers: integers only in this document (no floats appear in the schema). Serialize with no leading zeros, no leading +, no exponential notation, matching JSON.stringify behaviour for safe integers, which every field in this schema is (HueIndexSchema caps at 65535).
  5. Strings: standard JSON string escaping. asset and slot values are already constrained to [a-z0-9._-] by 13.2.5, so no escaping ever triggers in practice, but the encoder does not assume this and always applies standard JSON.stringify string escaping.
  6. Encoding: the canonical string is encoded as UTF-8 bytes before hashing. Since the schema only permits ASCII in every field, canonical bytes are always plain ASCII in practice, but the algorithm is defined as UTF-8 for correctness in principle.
  7. slots: [] serializes as "slots":[] (empty array, not omitted, not null).

13.3.2 Reference implementation #

// core/design/canonicalize.ts
import type { DesignDocument } from "../schema/design.ts";

export function canonicalize(doc: DesignDocument): string {
  const slots = doc.slots
    .map((s) => `{"slot":${JSON.stringify(s.slot)},"asset":${JSON.stringify(s.asset)},"hue":${s.hue}}`)
    .join(",");
  return `{"v":${doc.v},"body":${JSON.stringify(doc.body)},"skinHue":${doc.skinHue},"slots":[${slots}]}`;
}

export function canonicalBytes(doc: DesignDocument): Uint8Array {
  return new TextEncoder().encode(canonicalize(doc));
}

This hand-written serializer is deliberate: it guarantees byte-for-byte stability across Deno versions and V8 upgrades, whereas relying on JSON.stringify over a plain object depends on insertion order, which is an implementation detail this system must not depend on for a value that is cryptographically hashed.

13.3.3 Worked example #

Input document (already normalized: sorted, no omissions):

{
  "v": 1,
  "body": "m",
  "skinHue": 1002,
  "slots": [
    { "slot": "torso_outer", "asset": "robe.plain", "hue": 0 },
    { "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 }
  ]
}

Note torso_outer (z=110) sorts before hair (z=130), matching Section 3's z-order table (the input above is shown pre-sort to illustrate that input order is irrelevant).

Canonical byte string (exact, no line breaks — shown wrapped only for legibility here):

{"v":1,"body":"m","skinHue":1002,"slots":[{"slot":"torso_outer","asset":"robe.plain","hue":0},{"slot":"hair","asset":"hair.long-wavy","hue":1102}]}

That string is 147 bytes of ASCII, UTF-8-encoded, and is the exact input to SHA-256 in 13.4.

13.4 Short-code algorithm #

13.4.1 Steps #

  1. Compute digest = SHA-256(canonicalBytes(doc)) — 32 bytes.

  2. Take the leading 50 bits of digest: read the first 7 bytes (56 bits) as a big-endian integer and discard the low 6 bits of the 7th byte, leaving the top 50 bits.

  3. Encode those 50 bits as Crockford Base32, using a 32-symbol alphabet, producing exactly 10 characters (50 bits / 5 bits-per-symbol = 10 symbols).

  4. The alphabet excludes i, l, o, u to eliminate visual ambiguity with 1, 0, v/w and to avoid accidentally spelling profanity fragments. SkinForge's alphabet (lowercase canonical form):

    0123456789abcdefghjkmnpqrstvwxyz

    (32 symbols: digits 09, letters a b c d e f g h j k m n p q r s t v w x y z — note the gaps at i, l, o, u.)

  5. The code is always lowercase in URLs (/d/:code). Lookup is case-insensitive: incoming codes are uppercased-then-lowercased through the same normalization function before the database query — concretely, the API/route layer runs the code through normalizeShortCode() (13.4.4) before any lookup, rejecting characters outside the alphabet (case-folded), wrong length, or a well-formed code with no matching row uniformly with SF-2000 (404, not found), never a generic 400, so that malformed, well-formed-but-unknown, and merely-differently-cased codes are all indistinguishable to a client probing for valid codes.

13.4.2 Collision procedure #

Short codes are 50-bit values; collisions across unrelated designs are possible (13.4.3 gives the probability). The designs table has a unique index on short_code. On insert:

  1. Compute the candidate code from canonicalBytes(doc) as above.
  2. Attempt INSERT ... ON CONFLICT (short_code) DO NOTHING RETURNING *.
  3. If the insert succeeded, done — return the new code with HTTP 201 (Section 13.5, Section 16.8).
  4. If it conflicted, fetch the existing row by short_code and compare its stored canonical JSON (column canonical_json, Section 6) byte-for-byte against canonicalize(doc):
    • Identical: this is the idempotent-create case (13.5). Return the existing code with HTTP 200.
    • Different: this is a true hash collision. Recompute the code using a salted digest: SHA-256(canonicalBytes(doc) || saltByte) where saltByte is a single appended byte starting at 0x01 and incrementing. Repeat steps 2–4 with the new candidate code, persisting the winning saltByte in the designs.salt column (SMALLINT NULL, Section 6). NULL means "unsalted" — the common case, and the only value for which re-deriving a code from canonical_json alone (with no salt byte appended) reproduces the stored short_code. 1255 record which salt byte was appended for a collision retry; 0 is never written, so NULL and a salt value are always unambiguous. Salt search is capped at 255 attempts (values 1255); exhausting it returns SF-9007 (Section 25) — a distinct, detected-and-handled condition, never the generic unhandled-exception code, logged as design.salt_exhausted and treated as an operational alert (since it implies either a bug or an astronomically unlikely event per 13.4.3).

This procedure is race-safe under concurrent requests for the same design because the uniqueness constraint is enforced by Postgres, not by an application-level check-then-insert.

13.4.3 Probability analysis #

50 bits gives a code space of 2^50 ≈ 1.126 × 10^15. Using the standard birthday-bound approximation p ≈ n² / (2 × N) for n stored designs and N = code space:

Designs stored (n) Approx. collision probability
1e5 (100,000) ≈ 4.4 × 10^-6
1e6 (1,000,000) ≈ 4.4 × 10^-4
1e7 (10,000,000) ≈ 0.044 (about 4.4%)

At 1e7 stored designs the probability that at least one pair of the stored codes collides is non-trivial (this is the birthday bound across all pairs, not the chance any single new insert collides — the chance a single new design collides with the existing 10 million is only ≈ 10^7 / 2^50 ≈ 8.9 × 10^-9). The salted-retry procedure in 13.4.2 handles every case: collisions are detected deterministically via the unique index and resolved without user-visible impact except a few extra milliseconds of write latency. No monitoring threshold changes behaviour; the salt column exists precisely so the system scales past 1e7 designs without a migration.

13.4.4 Reference TypeScript #

// core/design/short-code.ts
const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; // Crockford, no i/l/o/u
const ALPHABET_INDEX = new Map([...ALPHABET].map((c, i) => [c, i]));

export async function computeShortCode(
  canonicalBytesValue: Uint8Array,
  salt?: number, // undefined or 0 means "unsalted"; 1-255 is an appended collision-retry byte
): Promise<string> {
  const input = (salt === undefined || salt === 0)
    ? canonicalBytesValue
    : concat(canonicalBytesValue, Uint8Array.of(salt));
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
  return encode50Bits(digest);
}

function encode50Bits(digest: Uint8Array): string {
  // Leading 50 bits = first 7 bytes (56 bits) as a big-endian integer, top 50 bits kept
  // (the low 6 bits of the 7th byte are discarded).
  let full = 0n;
  for (let i = 0; i < 7; i++) full = (full << 8n) | BigInt(digest[i]);
  let v = full >> 6n;
  let out = "";
  for (let i = 0; i < 10; i++) {
    out = ALPHABET[Number(v & 0x1fn)] + out; // 5 bits per symbol
    v >>= 5n;
  }
  return out;
}

export function normalizeShortCode(input: string): string | null {
  const lowered = input.trim().toLowerCase();
  if (!/^[0-9a-z]{10}$/.test(lowered)) return null;
  for (const ch of lowered) {
    if (!ALPHABET_INDEX.has(ch)) return null; // rejects i, l, o, u explicitly
  }
  return lowered;
}

function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
  const out = new Uint8Array(a.length + b.length);
  out.set(a, 0);
  out.set(b, a.length);
  return out;
}

normalizeShortCode is the single normalization entry point used by the route handler (/d/:code, Section 10) and the API handler (GET /api/v1/designs/{code}, Section 16.8) — both reject a code that fails normalization with SF-2000 (404, not found), never a 400, per 13.4.1.

13.5 Creation semantics #

POST /api/v1/designs is idempotent by construction, not by an idempotency-key header. The full endpoint contract — request schema, response schema, status codes, error cases — is owned by Section 16.8. This section states only the semantics that the encoding scheme guarantees:

  • Submitting the same DesignDocument (after server-side normalization: sorting, omission of empty slots) twice, from the same client or different clients, at any time apart, always returns the same short_code.
  • The first successful creation returns HTTP 201 with the new code.
  • Every subsequent identical submission returns HTTP 200 with the same code — never a second 201, never an error. This is what makes retries and double-submits (for example, a user double-clicking Share, or a client retrying after a timeout that actually succeeded server-side) safe by default, with no separate deduplication mechanism required.
  • A submission that is valid but differs in any field (different asset, different hue, different body) is a different design and receives its own code.

13.6 The transient query-string form #

Before a design is saved, the in-progress editor state lives entirely in the URL query string of the editor route (/ or /design, Section 10) — never in server state, never in a cookie, never in localStorage. This makes the editor URL itself shareable-but-unstable (13.6.4 explains why that instability is intentional) and lets a user's browser back/forward buttons step through edit history for free.

13.6.1 Parameter grammar #

Param Meaning Format Example
b body m | f b=m
s skin hue integer s=1002
<slotKey> one query param per occupied slot, named exactly as the slot_key <assetKey>:<hue> hair=hair.long-wavy:1102

Full example: /?b=m&s=1002&hair=hair.long-wavy:1102&torso_outer=robe.plain:0

Rules:

  • b and s are the only two reserved, non-slot parameter names. Every other recognized query key must exactly match one of the 18 choosable slot_key values from Section 3 (including backpack, which follows the same <assetKey>:<hue> grammar as any other slot param; an absent backpack param behaves like an absent entry in the permanent form — 13.2.1's backpack.default fallback applies at render time).
  • A slot parameter's value is <assetKey>:<hue> — a single colon separates the two parts. assetKey matches AssetKeySchema (13.2.5); hue is a non-negative integer.
  • Omitting a slot parameter means that slot is empty, mirroring the omission rule in 13.2.4.
  • No percent-encoding is required for assetKey values because the schema restricts them to [a-z0-9._-], none of which are reserved in a query string; the colon separator is also unreserved in a query value per RFC 3986 and needs no escaping.

13.6.2 Parsing rules #

  1. Parse b: must be m or f. Missing or invalid → default to m and continue (this is an editor default, not an error state — the editor always renders something).
  2. Parse s: must be an integer in range and a known hue index at render time (existence is checked against the loaded hue catalog client-side; the server-side render endpoint re-validates per Section 8). Missing or invalid → default to 0.
  3. For every other query key: if it matches a slot_key, parse <assetKey>:<hue>. A value missing the colon, or with a non-integer hue part, is dropped silently (the slot is treated as empty) — the editor never hard-errors on a malformed share link; it degrades to "as much of the design as parsed successfully."
  4. Unknown parameter handling: any query key that is neither b, s, nor a valid slot_key is ignored — not an error, not preserved on re-serialization. This keeps the form forward-compatible (old links with since-removed experimental params still load) and prevents the query string from accumulating cruft as the editor state changes.
  5. Length limits: the full query string is capped at 2000 characters total, matching the practical URL-length ceiling respected by all major browsers, proxies and messaging apps' link unfurlers. The editor enforces this client-side by refusing to add a further slot selection (with an inline notice) once the serialized query string would exceed the cap; in the extremely unlikely case a hand-crafted URL exceeds it, the server-rendered editor route truncates at the last fully-formed &-delimited parameter before the 2000-character mark and parses only what remains.

13.6.3 Reference parser #

// core/design/query-form.ts
import { CHOOSABLE_SLOT_KEYS } from "../domain/slots.ts";

export interface TransientDesign {
  body: "m" | "f";
  skinHue: number;
  slots: Partial<Record<string, { asset: string; hue: number }>>;
}

const SLOT_KEY_SET = new Set(CHOOSABLE_SLOT_KEYS);

export function parseTransientDesign(search: URLSearchParams): TransientDesign {
  const body = search.get("b") === "f" ? "f" : "m";
  const sRaw = search.get("s");
  const skinHue = sRaw !== null && /^\d+$/.test(sRaw) ? Number(sRaw) : 0;

  const slots: TransientDesign["slots"] = {};
  for (const [key, value] of search.entries()) {
    if (!SLOT_KEY_SET.has(key)) continue; // unknown param, ignored
    const idx = value.indexOf(":");
    if (idx < 0) continue; // malformed, slot treated as empty
    const asset = value.slice(0, idx);
    const hueRaw = value.slice(idx + 1);
    if (!/^\d+$/.test(hueRaw)) continue;
    slots[key] = { asset, hue: Number(hueRaw) };
  }
  return { body, skinHue, slots };
}

export function serializeTransientDesign(d: TransientDesign): string {
  const params = new URLSearchParams();
  params.set("b", d.body);
  params.set("s", String(d.skinHue));
  for (const [slot, entry] of Object.entries(d.slots)) {
    if (!entry) continue;
    params.set(slot, `${entry.asset}:${entry.hue}`);
  }
  const out = params.toString();
  return out.length > 2000 ? out.slice(0, out.lastIndexOf("&", 2000)) : out;
}

13.6.4 Why this is deliberately not the permanent form #

The query-string form is optimized for the editor's moment-to-moment state changes: it is cheap to mutate (one param per slot), human-scannable while debugging, and requires no server round-trip to update as the user experiments. It is deliberately not treated as a stable identifier because:

  • It has no canonicalization step — key order in a URLSearchParams is insertion order, not the fixed z-order from 13.2.3, so two editors could produce different query strings for the same visual result.
  • It has no collision resistance or fixed length — it grows linearly with slots occupied, unsuitable for a short, brandable share link.
  • It carries no immutability guarantee — nothing prevents a future format change to the parameter grammar itself, whereas the canonical JSON document's v field (13.2.1) exists exactly so the permanent form can evolve without breaking old links.

Pressing Share in the designer (Section 11) takes the current TransientDesign, converts it to a DesignDocument (filling in the omission and sort rules), submits it to POST /api/v1/designs (13.5), and the client issues history.pushState(null, "", url) to /d/:code with no page navigation and no HTTP redirect (Sections 10.5 and 11.9). From that point on, /d/:code — not the query string — is the canonical shareable link.

13.7 Immutability guarantees #

A permalink's rendered meaning is fixed forever at creation time. This is achieved through three mechanisms:

  1. build_id pinning. Every designs row (Section 6) stores build_id, a foreign key to the game_builds row that was the active published build at the moment the design was created (Section 9 owns build publishing). All rendering for that design — live preview regeneration, OG image regeneration, re-renders after a cache purge — resolves assets and hues as they existed in that build, never against "whatever is currently published." Section 9's versioning model keeps every published build's asset/hue rows queryable indefinitely — retirement and rollback are always soft-delete (a flag, never a row deletion) — so build_id pinning is always resolvable.
  2. Retired-asset rendering. If an asset referenced by an old design is later retired (Section 9), it still renders: retirement sets retired_at on the assets row but never deletes the row or its asset_images (Section 6). The design's render pipeline (Section 8) looks up the asset by asset_key within the pinned build_id's asset set, ignoring retired_at entirely for this lookup path. Retirement only affects whether an asset is offered in the live editor and catalog (Section 10, Section 11) — never whether it can still be rendered for an existing design.
  3. Changed-hue behaviour. Hue rows are also soft-delete-only. If a hue's swatch metadata is corrected by staff (Section 17.8) — for example, a mislabelled name — the numeric hue table itself (the actual colour ramp, Section 8) is treated as immutable once published in a build; corrections to non-visual metadata (name, grouping) do not affect rendering. If a genuine colour-table correction is ever required (for example, the original extraction mis-decoded a colour ramp), that correction ships as a new build (Section 9), and existing designs keep pointing at their original build_id's hue table, so their rendered colours do not shift underneath them.

The explicit promise: a shared link renders the same picture in five years as it did the day it was created, even if every asset it references has since been retired from the live catalog, even if the game itself is patched dozens more times, and even if hue metadata is corrected. Designs are never auto-deleted (13.9.3); the only way a permalink's rendered picture can legitimately stop being servable is the legal takedown path in Section 20, which replaces the page with an HTTP 410 notice rather than silently altering or removing the underlying row.

13.8 Design page behaviour #

/d/:code (Section 10 owns the route and layout shell) is a server-rendered page whose content includes:

  • Server-rendered preview: the composite paperdoll image at scale 2, embedded as /render/d/:code@2.webp (Section 8) inside a standard <img> tag with explicit width/height attributes matching the 260×330 canvas at that scale, so the page has no cumulative layout shift. A <noscript>-safe <picture> element also provides a .png source for user agents or link unfurlers that request PNG.
  • Remix affordance: a prominent "Remix this design" control that navigates to the editor route (Section 10) with the query string pre-filled from the design's DesignDocument, using serializeTransientDesign (13.6.3) applied to the loaded document. Remixing never mutates the original design row; it only seeds a new transient editor session that, if shared, produces a new, independent short code.
  • "Designs like this": a strip of up to 6 other designs sharing the same body and at least two identical (slot, asset) pairs, ranked by count of shared (slot, asset) pairs descending, then by page_view_daily-derived popularity (Section 6, Section 21) descending, ties broken by created_at descending. Computed with a single indexed query at request time (no precomputed similarity table in v1); if fewer than 2 candidates are found the strip is omitted from the page entirely, not rendered empty.
  • Structured data: a <script type="application/ld+json"> block with schema.org ImageObject (contentUrl pointing at the scale-2 WebP render, creator omitted since there are no accounts, description a generated sentence such as "A UO Outlands character skin design created with SkinForge — unofficial fan tool."). No Product or CreativeWork markup, since this is not a commercial listing.
  • OG tags pointer: the page's <head> OG/Twitter meta tags point at the design's Open Graph image per Section 14's generation and caching rules; this section does not redefine those tags.

13.9 Abuse and hygiene #

13.9.1 No moderation surface for free text #

A DesignDocument contains no user-supplied free text anywhere — every field is either a constrained enum, an integer bounded against a catalog, or a machine-generated key validated against the catalog (13.2.5). There is therefore no profanity, harassment, spam-link, or PII surface inside a design document itself. The abuse surface that does exist is entirely about volume (mass creation to exhaust storage or the short-code space) and about the rendered image itself potentially depicting a combination someone finds objectionable — the latter is addressed by the takedown path (Section 20), not by pre-moderation, since there is no practical way to pre-moderate a paperdoll composite and doing so would contradict the no-login, no-review, instant-share design goal.

13.9.2 Rate limiting on creation #

POST /api/v1/designs is rate-limited per the SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR bucket (Section 19 owns the mechanism; Section 24 owns the env var's default and type). Rate-limit responses follow the 429 contract in Section 16.7.

13.9.3 Storage growth and pruning policy #

Each design row is small (a canonical_json payload under 1 KB in the overwhelming majority of cases, plus fixed-width columns, per Section 6) but its rendered artifacts (composite images at 3 scales × 2 formats, per Section 8, plus an OG image per Section 14) are the real storage cost — roughly 150–400 KB per fully-rendered design once all cached variants exist.

Decision: Section 8.11 owns when renders are generated; no other section decides it. Two variants — @2.webp (the permalink page's embed, 13.8) and @1.png (the Open Graph card's source, Section 14) — are rendered eagerly, enqueued inside the same transaction that saves the design. Every other (code, scale, format) combination is generated lazily, on first request (Section 8.9 owns cache-key mechanics; Section 8.10 owns the generation-trigger/runtime pipeline). This means a design that is created but never viewed by anyone but its creator consumes the small database row plus exactly those two pre-warmed render objects, not the full 3-scale × 2-format set.

Designs are never auto-deleted. A designs row, once created, is retained forever — there is no view-count threshold, no age threshold, and no nightly pruning job that removes a design row. This matches the permanence promise in 13.1 and 13.7 and the legal posture in Section 20: the only way a design stops being servable is the takedown process (Section 20.8), which sets taken_down_at on the row (410, Section 13.11) rather than deleting it, so the row and its audit trail persist even after takedown.

Rendered artifacts (composite images and the OG image) are a different matter: they are derived, regenerable data (Section 8, Section 14), not the design itself, so they may be pruned to control storage growth without affecting the permanence promise — a pruned render is simply regenerated lazily on the next request. A nightly job (Section 9's job runner, Section 21's job metrics) removes rendered artifacts whose last_served_at (og_images, Section 6) or storage-object last-access timestamp is older than 90 days, leaving the owning designs row untouched; the foreign keys from design_renders/og_images to designs permit this via ON DELETE CASCADE in the other direction only (deleting a render row never deletes its design). A design that has never been rendered simply has no artifacts to prune yet — pruning only ever removes bytes that can be regenerated on demand.

13.10.1 View counting without cookies #

Each GET /d/:code request that is not filtered as a bot (13.10.2) increments a counter. Public visitors are never tracked with cookies, sessions, or persistent identifiers of any kind (Section 20 owns the full privacy posture), so view counting is not deduplicated by visitor identity — there is no session, no cookie, no IP-derived fingerprint stored per view. The counter is a simple monotonic increment per page load, recorded server-side into stat_counters (daily-bucketed) and reflected on the designs row as a denormalized view_count maintained by the same write path, both defined in Section 6. This intentionally means "view count" measures page loads, not unique visitors — stated here as the definition so no other section redefines it.

13.10.2 Bot filtering #

Before incrementing, the request is checked against a bot-detection rule set:

  1. User-Agent allowlist for link unfurlers: known crawler UAs (Slackbot, Discordbot, Twitterbot, facebookexternalhit, TelegramBot, WhatsApp, LinkedInBot) are recognized explicitly by substring match and are excluded from the view counter but are still served the full page (they need the OG tags), because a chat-app link preview is not a human view.
  2. Generic bot heuristic: requests with no Accept-Language header and no Accept header containing text/html are treated as non-browser and excluded.
  3. Everything else increments the counter exactly once per request. There is no further deduplication — a human refreshing the page ten times counts as ten views. This is a stated, final decision: precise unique-visitor analytics would require cookies or fingerprinting, both excluded by this system's no-tracking privacy posture (Section 20).

13.10.3 The counter table #

Owned in full by Section 6; referenced here only for the columns this section's logic depends on: stat_counters(counter_key, day, value) with counter_key = 'design_view:' || short_code, and the denormalized designs.view_count column updated in the same transaction as the daily bucket row, using INSERT ... ON CONFLICT (counter_key, day) DO UPDATE SET value = stat_counters.value + 1.

13.11 Edge cases #

Every row below is authoritative for its scenario; Section 25 owns the full error code registry and repeats these entries there for completeness, but this table is where the behaviour is decided.

Scenario HTTP status Error code User-visible copy
Unknown code (well-formed, no matching row) 404 SF-2000 "We couldn't find that design. The link may be mistyped."
Malformed code (fails normalizeShortCode, 13.4.4) 404 SF-2000 Same code and same copy as unknown code — malformed and unknown are indistinguishable to the visitor by design (13.4.1).
Retired asset referenced by the design 200 No error. Renders normally per 13.7's retired-asset rendering guarantee; the page shows the image exactly as at creation time.
Missing render variant (cache miss for a valid design) 200 No error. The render endpoint (Section 8) generates it synchronously on first request within SKINFORGE_RENDER_TIMEOUT_MS; the page's <img> simply takes slightly longer to load. If generation itself fails (corrupt source asset), see "render failure" below.
Render failure (a referenced sprite is missing or unreadable within the pinned build for an otherwise-valid asset) 200 (page) / 500 (image) SF-5000 This is a server-side data fault, not a client error: the page still renders with a static "preview unavailable" placeholder image in place of the broken <img>; the underlying image URL returns 500 with SF-5000 in its JSON error variant (for API consumers), is never cached, and logs an operational alert (Section 21), since this indicates a pipeline bug, not user error (Section 8.14 states the same rule).
Deleted design (takedown-removed per Section 20.8 — designs are never auto-pruned, 13.9.3) 410 SF-2005 "This design is no longer available." (Section 20 owns the exact takedown copy shown on the page; this row states the status code and family.)
Extremely old build (design's pinned build_id predates the oldest build still queryable) This cannot occur by construction Builds are never hard-deleted (Section 9); every build_id ever assigned to a design remains queryable forever. Stated here as a guarantee, not handled as an error path.
Code valid but references a design created under a future schema version the running server does not understand (v newer than supported) 200, degraded The render pipeline reads the highest v it supports; a document with a higher v than the running server understands cannot exist in practice because v is written by the same codebase that reads it, and deployments are single-version (Section 23). No forward-compatibility path is needed; this scenario is excluded by construction, not handled defensively.

14. Open Graph Image Generation #

This section owns the generation, layout, typography, pipeline, crawler handling, platform-specific requirements, cost controls, and error handling for the social preview image served at every design permalink. It consumes the design and render primitives owned by Sections 8 and 13; it does not redefine the meta tags themselves (Section 10 owns those) or the design JSON/short-code format (Section 13 owns those).

14.1 Purpose and where the URL appears #

Every design permalink needs a rich preview when shared on social platforms, chat apps, and messaging clients, because the permalink itself (/d/:code) is an opaque short code with no inherent visual information. The OG image closes that gap: it shows the actual designed character, not a generic site banner.

  • URL: /og/d/:code.png, where :code is the same 10-character Crockford Base32 design short code used throughout the render namespace (Section 8.8, Section 13.4). Only PNG is offered — no .webp variant — because social platform crawlers (Section 14.7) are inconsistent about WebP support for OG images, while PNG is universally accepted.
  • Where referenced: the og:image and twitter:image meta tags on the /d/:code page point to this URL. Section 10 owns the full set of meta tags and their exact attribute values; this section owns only what is served when that URL is requested.
  • Not used elsewhere: catalog pages, hue pages, and the homepage use a static fallback card (Section 14.9) rather than a generated one, since they have no single design to depict.

14.2 Exact canvas #

  • Dimensions: 1200×630 px, the standard Open Graph image size recommended by Facebook, X/Twitter (summary_large_image), and Discord, chosen specifically because it avoids cropping on any of the three.
  • Safe area: platform crawlers may crop toward a 1.91:1 center on some surfaces; all essential content (the character composite, the wordmark, the fan-project disclaimer) is placed within a 1160×590 px safe area centered in the canvas (a 20px margin on all sides), so nothing essential is lost to platform-side cropping variance.
  • Layout grid: a two-column layout within the safe area:
Region Bounds (within 1200×630 canvas) Content
Left panel x: 60–460, y: 65–565 (400×500) The paperdoll composite (Section 8), placed 1:1 and unscaled at x: 130–390, y: 150–480 — its natural 260×330 pixels, centred in the panel
Divider x: 500, y: 65–565 A 1px vertical rule, low-emphasis token colour (Section 18.2)
Right panel, top x: 560–1140, y: 90–140 Site wordmark ("Outlands SkinForge") in the display type scale (Section 18.3)
Right panel, middle x: 560–1140, y: 170–500 Slot summary list (Section 14.3), up to 8 lines plus an optional 9th overflow line
Right panel, bottom x: 560–1140, y: 510–565 Unofficial fan-project disclaimer (Section 14.3), smaller type, muted colour
  • The composite is never resampled. Section 8.6 permits integer nearest-neighbour scaling only, and forbids smoothing outright, because interpolation introduces colours that exist in no hue table. Of the integer scales available (1, 2, 3) only scale 1 — 260×330 — fits inside the 400×500 left panel at all; scale 2 is 520×660 and is taller than the entire 630 px card. The card therefore embeds the scale-1 PNG at its natural size, 1:1, and satori is given explicit width: 260; height: 330 on the image node so it cannot choose a fit for itself. The panel is larger than the image on purpose: the surrounding margin is deliberate whitespace, not a box the image is expected to fill. The composite's centred box is derived as x = 60 + (400 − 260) / 2 = 130 and y = 65 + (500 − 330) / 2 = 150, giving x: 130–390, y: 150–480 — both offsets are whole pixels, so the image lands on the pixel grid exactly.

  • Colour treatment: background is a flat solid fill using the darkest neutral background token in Section 18.2 (not pure black, for the print/screen contrast reasons established there), giving the transparent-background composite (Section 8.1) consistent contrast regardless of the viewer's own theme — the OG image is always rendered in one fixed (dark) treatment, independent of any future site light/dark mode, because a shared social image must look correct on every downstream platform's own chrome, not adapt to the visitor's device.

  • Fallback layout when the design is empty: an "empty" design (a permalink saved with every optional slot unselected and skin hue 0 — a legal, if unusual, permalink) still renders the left-panel composite exactly as normal (the default body plus backpack.default silhouette, Section 12.11). Its right-panel summary is not empty either: the skin line is always present (Section 14.3), so an empty design shows Skin: As-drawn followed by the single Backpack: Leather Backpack line, since the backpack always resolves (Section 14.3). There is no "no items" state. No special-cased alternate layout exists; the same grid handles the empty case by having less content in the middle region.

14.3 Content of the card #

  • Paperdoll composite: the design's server-side composite (Section 8, the same CompositeRequest the permalink itself resolves to), sourced from /render/d/:code@1.pngscale 1, PNG. Scale 1 because it is the only integer scale that fits the card's left panel without resampling (Section 14.2), and PNG because satori embeds images as data URLs and PNG avoids a second WebP decode dependency in this pipeline (Section 8.7). That exact variant is pre-warmed on design save (Section 8.11), so OG generation normally finds it already in the render cache; if it is not warm — for a design restored from a backup that predates the rule — the ordinary pipeline in Section 8.10 produces it on demand. The PNG bytes are embedded into the satori layout as a base64 data URL image node with explicit width: 260; height: 330.

  • Skin line: the first summary line is always "Skin: <hue name>", or "Skin: As-drawn" when the skin hue is 0. It is always present, including on an otherwise empty design, because skin is the one choice every design makes.

  • Slot summary: up to 8 lines including the skin line, one per populated slot in the z-order given by the slot registry in Section 3.3, excluding body (whose skin hue is reported on the skin line above). backpack is listed like any other populated slot — it is a designable slot (Section 3.3) and it always has a value, defaulting to backpack.default, so it always appears. Each line reads "<Slot display name>: <Asset display name>" with " (<Hue name>)" appended when that slot's hue is non-zero. If more than 8 lines would be produced (the maximum possible is the skin line plus the 18 choosable cosmetic slots), the first 8 are shown and the remainder is summarized on an optional 9th line reading "+<n> more". The middle region in Section 14.2 is sized for 9 lines for exactly this reason.

  • No user text ever reaches the card. Designs carry no user-authored strings — Section 13.2's design JSON has no text fields at all — so every word on the card comes from catalog display_name values (Section 6) and hues.name, which are curated by staff during import and admin review (Section 9, Section 17). Beyond standard XML-escaping for the SVG layout (Section 14.5) there is nothing to sanitize, because there is nothing untrusted.

  • Hue names: taken from hues.name (Section 6.13), never a raw hue index — a hue is never shown to an end user as a bare number anywhere in the product, OG cards included.

  • Site wordmark: the literal text "Outlands SkinForge", set in the display weight of Section 18.3's type scale, colour token text-primary-on-dark (the OG canvas's fixed dark treatment, per Section 14.2, uses the dark-mode token set regardless of site-wide theme).

  • Unofficial fan-project disclaimer: quoted verbatim from Section 10.4, which owns the single wording used everywhere in the product:

    "Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."

    This section fixes only its position (bottom-right, Section 14.2's grid) and typographic treatment (Section 18.3's smallest body size, muted colour token). It is set on two lines at that size within the 580 px column; the wording is never abbreviated or paraphrased to fit, because a shortened disclaimer is a different legal statement.

14.4 Typography #

  • Bundled font: one static font file is bundled in the repository at web/static/fonts/PixelifySans-Regular.ttfPixelify Sans, the display face named in Section 18.3 — committed alongside its OFL.txt. TTF, not WOFF2: satori parses TTF, OTF and WOFF and explicitly does not support WOFF2, so a WOFF2 subset would fail at call time. No monospace face is bundled, because the card renders no monospace text; the design short code is not shown on the card in this layout.
  • Licence: the SIL OFL 1.1 font file is committed alongside its OFL.txt licence text, per the licence's redistribution requirement. This is a repository content decision, not a runtime dependency, and needs no attribution string inside the generated image.
  • No build-time subsetting step. A single-weight Latin TTF is already small enough to hold in memory for the process lifetime, and a subsetting step would add a build tool, a deno task and a Dockerfile stage for no measurable gain — and would silently break the moment a curated display_name introduced a character the subset dropped. The full face ships as-is.
  • Offline resolution by satori: satori (Section 4.1) requires font data supplied as raw bytes at call time — it does not fetch fonts over the network and does not resolve system fonts. The OG generation module reads the bundled TTF once, at process startup (alongside the WASM codec initialization described in Section 8.7), into an in-memory Uint8Array cache keyed by (family, weight), and passes that cache into every satori() call's fonts option. OG generation therefore has zero external network dependency and zero per-request file I/O.

14.5 Generation pipeline #

  1. Trigger (Section 14.5.1) fires with a design :code.
  2. Build layout: construct a JSX-like element tree (satori's supported subset of CSS-in-JS flexbox layout) matching Section 14.2's grid, embedding the scale-1 paperdoll PNG (Section 14.3) as a base64 data URL <img> node with explicit width: 260; height: 330, and the bundled font buffer (Section 14.4).
  3. satori → SVG: satori(element, { width: 1200, height: 630, fonts }) produces an SVG string.
  4. resvg → PNG: @resvg/resvg-wasm (Section 4.1) rasterizes the SVG string to a 1200×630 RGBA bitmap, which is then PNG-encoded via the same @jsquash/png encoder module described in Section 8.7 (shared WASM instance, no duplicate initialization).
  5. Persist: the PNG bytes are written to object storage at og/:first2/:next2/:hash.png — the og/ prefix and the two-segment fan-out are defined in Section 8.9 — where :hash is sha256(designCacheKey + "og" + templateVersion). designCacheKey is the design composite's own cache key from Section 8.9 (so any change that would change the composite also changes the OG image), and templateVersion is an integer bumped whenever the OG layout itself changes, so a layout redesign invalidates every previously generated card without touching the design composite cache.
  6. Record: an og_images row (Section 6.20) is written with design_id, cache_key, storage_key, byte_size, content_hash, created_at and last_served_at (updated on every serve, and read by the pruning rule in Section 14.8). cache_key is the unique key on that table, not design_id — a bumped templateVersion produces a second row for the same design, and the old row remains valid until it is pruned.
  7. Serve: the identical HEAD-then-stream pattern as Section 8.10's cache-lookup step, with the same Cache-Control: public, max-age=31536000, immutable header, since the URL — once the hash-bearing object exists — is equally content-addressed and immutable. As on /render/* (Section 8.9), the application sets Cache-Control per response and the reverse proxy must not override it: the degraded paths in Section 14.9 deliberately carry short or absent caching.

Reference cache-key computation, sharing the canonicalization helper used by Section 8.9:

// core/codec/og.ts

const OG_TEMPLATE_VERSION = 1; // bump on any layout change, Section 14.5.2

export function ogCacheKey(designCacheKey: string): string {
  const input = `${designCacheKey}|og|${OG_TEMPLATE_VERSION}`;
  return sha256Hex(new TextEncoder().encode(input));
}

export function ogStorageKey(hash: string): string {
  return `og/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}.png`;
}

designCacheKey here is the design composite's cache key computed exactly as Section 8.9 defines it, for scale=1, format=png — the same variant Section 14.3 embeds. OG generation does not invent a second design-hashing scheme; it consumes the render layer's own cache key as an opaque input.

14.5.1 Regeneration triggers #

An OG image is generated when /og/d/:code.png is requested and no og_images row exists for the current cache_key. That single condition covers first view, any change to the underlying design composite, and any bump of the template version.

OG generation is always lazy — never eager at design-save time. Section 8.11 pre-warms the source composite the card is built from, but not the card itself, because the majority of saved designs are never shared to a platform that renders link previews, and generating a card for all of them would spend the cost budget tracked in Section 14.8 on images nobody requests.

14.5.2 Synchronous-vs-queued decision #

OG generation reuses the exact claim/poll job pattern from Section 8.10 — a jobs row (Section 6.21) with job_type = 'og_render' and dedupe_key set to the OG cache_key, arbitrated by the same partial unique index — and not a fire-and-forget background task, because the first request for a freshly shared link is very often the platform crawler itself (Section 14.6) making a synchronous fetch with its own short timeout. The response must be a real image or a deliberate placeholder, never a 202.

  • Timeout: SKINFORGE_OG_TIMEOUT_MS (Section 24.2; default 3000) bounds how long a request waits for OG generation. It is a separate variable from SKINFORGE_RENDER_TIMEOUT_MS because the two are answering different questions: the render timeout is sized for a browser that will happily wait, while the OG timeout is sized for a crawler that will not (Section 14.6).
  • Placeholder behaviour on timeout: if generation has not completed within SKINFORGE_OG_TIMEOUT_MS, the request is served the static fallback card (Section 14.9) with a short cache lifetime (Cache-Control: public, max-age=30) rather than the design-specific card. The in-flight generation job continues in the background exactly as Section 8.10.2 describes, so the very next request — a crawler retry, or a second platform fetching the same link — gets the real card once it completes. The job's own deadline is SKINFORGE_RENDER_TIMEOUT_MS, so it is never killed merely because the waiting request gave up first.

14.6 Crawler handling #

  • User-agent-agnostic behaviour: the OG endpoint applies identical logic regardless of requesting user agent — no User-Agent sniffing, no crawler allowlist, no different content for Facebook's crawler versus a plain browser tab versus curl. This is a deliberate anti-cloaking stance (Section 14.6 heading below) and also simpler to reason about and test.
  • No cloaking: the image served at /og/d/:code.png is always exactly what a human visiting that URL directly would see — there is no hidden "crawler-only" code path, satisfying every major platform's anti-cloaking policy without needing platform-specific logic.
  • First-request latency budget: crawlers commonly apply their own 3-5 second fetch timeout and do not retry indefinitely. That is why the OG path has its own variable, SKINFORGE_OG_TIMEOUT_MS (Section 24.2, default 3000), rather than inheriting the render timeout's 8000 ms default: a waiting crawler must be handed something before it gives up. Exceeding it serves the fallback card (Section 14.5.2) while the generation job continues to completion under the render timeout, so the retry is warm. In normal operation the crawler never reaches this path at all — Section 14.5's steps 1-7 complete in well under 200 ms once the source composite is warm, which Section 8.11 arranges at design-save time.
  • The "generating" placeholder image: identical to the static fallback card (Section 14.9), not a distinct "please wait" graphic — this avoids maintaining a third card design and means a platform that caches the placeholder aggressively (against the guidance below) still shows a legitimate, on-brand image rather than a broken-looking interim state.
  • Why the placeholder is never cached long: max-age=30 (Section 14.5.2) is deliberately short so that if a platform's own crawler fetches a link within the first few seconds of it being shared (the highest-traffic moment for any given link) and happens to hit the placeholder, a re-crawl shortly after — which most platforms perform on first real click-through, and which SkinForge cannot force but can make cheap to benefit from — picks up the real card instead of being stuck with a long-lived generic placeholder for the link's entire lifetime.

14.7 Twitter/X card, Discord embed and Facebook specifics #

Platform Required meta tags (names only; Section 10 owns exact tag emission) Image dimensions expected Notes
X (Twitter) twitter:card = summary_large_image, twitter:title, twitter:description, twitter:image 1200×630 (matches summary_large_image recommendation exactly) No Twitter-specific account/site tags are emitted — SkinForge has no X account to reference
Discord Standard Open Graph tags only (og:title, og:description, og:image, og:url); Discord has no proprietary tags 1200×630, embed renders at reduced width in-client Discord respects og:image:width/og:image:height hints (Section 10 emits these as 1200/630) to avoid a layout-shift flash while the embed loads
Facebook og:title, og:description, og:image, og:image:width, og:image:height, og:image:alt, og:type = website 1200×630 og:type is website on every page including /d/:code; object is not a valid Open Graph type. og:image:alt is set by Section 10.6 to the generated alt sentence defined in Section 12.10, for the design being shared
  • Testing procedure: before each release that touches OG generation (Section 14.5) or the meta tags (Section 10), staff manually verify a representative permalink against three validators: Meta's Sharing Debugger (https://developers.facebook.com/tools/debug/), the Discord embed behaviour observed by pasting the link in a private Discord channel (no formal Discord validator exists), and the X Card Validator equivalent check via https://cards-dev.twitter.com/validator where available, or a direct tweet-compose preview otherwise (X has periodically retired its public validator; the compose-box live preview is the durable fallback check). This procedure is recorded as a manual QA step in Section 22.12's release checklist (referenced, not restated) rather than an automated test, since it depends on third-party services outside CI's control.

14.8 Cost controls #

  • Generation rate limit: the OG generation path shares the render bucket — SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE, default 60 per minute per IP (Section 16.7.1, Section 24.2) — with the pixel-render pipeline. It is not a separate budget, since both consume the same CPU-bound WASM encode capacity (Section 8.13's performance budgets apply unchanged; satori and resvg add roughly 30-80 ms on top of the underlying composite fetch, which is normally already cached).
  • Dedupe by design code: the cache-key formula in Section 14.5 step 5 means requesting the same :code repeatedly, or from multiple simultaneous crawlers, only ever generates once per (designCacheKey, templateVersion) pair — identical dedupe guarantee to Section 8.10's job-claim mechanism, reused as-is for the og_render job type.
  • Storage growth estimate: a generated OG PNG is 60-140 KB (1200×630, mostly flat background, Section 8.7's PNG size class scaled up for the larger canvas). At a projected steady state of a few hundred newly shared designs per week, the og/ prefix grows by roughly 5-15 MB per week — materially smaller than the render cache (Section 8.9) and not a meaningful line item in the deployment sizing in Section 23.11.
  • Pruning policy: an og_images row whose last_served_at (Section 14.5 step 6) is older than 180 days is eligible for pruning. A monthly scheduled job (Section 23.8's operational job registry) deletes the object from storage and the row. The condition is last_served_at alone — not a join against page views — because last_served_at is exactly the signal being asked about ("has anything requested this card lately?"), and because view counters deliberately exclude unfurler previews (Section 21.6), which are the very traffic that keeps an OG card alive.
  • Why pruning stays safe: pruning removes only the cached rendered card, never the design. The design row is never deleted; the sole removal path for a design is the takedown process in Section 20.8, which yields 410 (Section 13.7). The next request for a pruned card's /og/d/:code.png simply regenerates it on demand (Section 14.5.1), at the identical content address it had before pruning, because the cache key depends only on the still-present design and a stable template version — not on the pruned row's existence. Pruning is therefore a pure storage optimization whose only user-visible effect is one extra cold generation the next time a dormant link resurfaces. og_images.design_id is ON DELETE CASCADE (Section 6.20) so that a takedown can clear a design's derived cards; that cascade is the only path by which a card disappears for a reason other than pruning.

14.9 Error handling and the static fallback card #

  • Static fallback card: a single pre-built 1200×630 PNG committed to the repository at web/static/social-card.png and served at /social-card.png (Fresh serves web/static/ at the URL root, so a file under web/static/og/ would land inside the /og/d/:code.png route namespace — hence the root-level name). It shows the site wordmark, the site's icon mark, and the disclaimer quoted verbatim from Section 10.4, with no character composite region. It is used for (a) every non-design page's og:image — homepage, catalog, hues, about and the rest, with Section 10.6 owning which pages reference it — and (b) the placeholder path in Section 14.5.2 and Section 14.6. There is exactly one such artifact in the product; Section 10.6 points at this same path.
  • Error handling table. Every code below is the code the registry in Section 25.2 assigns to that condition, used here as the logged code. The HTTP status in the Response column is the status this endpoint returns, which deliberately differs from the registry's status for the render-family codes: /og/d/:code.png never surfaces a 5xx to a crawler (see the note below the table).
Condition Error code (Section 25.2) Response
:code fails normalization or has no matching design SF-2000 404; the response body is the static fallback card with Cache-Control: public, max-age=60, not a JSON error — an image-typed endpoint must return an image so embedding clients degrade gracefully even on a 404 status. Malformed and unknown codes are deliberately indistinguishable here for the same anti-enumeration reason as Section 8.8 and Section 13.4.1
:code resolves to a design removed by takedown (Section 20.8) SF-2005 410; static fallback card with Cache-Control: public, max-age=86400 — a 410 exists so caches and crawlers drop the URL, and a long-lived immutable header would defeat that
Source composite render fails (Section 8.14's table) inherits the underlying code, e.g. SF-5000/SF-5001 200 with the static fallback card and Cache-Control: max-age=30, treated identically to the timeout placeholder path (Section 14.5.2) — OG generation never surfaces a bare 5xx to a social crawler, because a broken-image embed is worse for shareability than a generic-but-valid card. The underlying failure is still logged at its own severity
satori layout throws (a future font or layout regression) SF-5007 Same fallback treatment as the row above; the incident is logged (Section 21.2) for staff follow-up, since this indicates a code defect rather than transient infrastructure
resvg rasterization throws SF-5007 Same as above
Object storage write fails after a successful generation SF-5003 The freshly generated bytes are still served for this one request — generation succeeded, only persistence failed — with Cache-Control: no-store, since the image was never durably cached and must not be treated as permanent by any downstream cache. The failure is logged for staff follow-up
Generation exceeds SKINFORGE_OG_TIMEOUT_MS (Section 14.5.2) — (not an error) 200 with the static fallback card and Cache-Control: public, max-age=30; the job continues in the background

No condition in this table ever returns a non-image response body from /og/d/:code.png. Every row resolves to either the generated card or the static fallback card, which is the defining contract of this endpoint for the crawlers and chat clients that consume it. The Content-Type is always image/png, whatever the status.

14.9.1 Fallback card content and provenance #

web/static/social-card.png is produced by the same satori+resvg pipeline (Section 14.5) as a one-time build-time artifact — a small script at scripts/build-og-fallback.ts in the executing repository, run manually whenever the brand mark or wordmark changes, not on every deploy — and then committed as a binary asset. This keeps the hot request path free of any "is this the fallback case" branching beyond serving a static file, and guarantees the fallback never depends on the live render pipeline that might itself be the thing failing. The card's layout intentionally omits the left-panel composite region (Section 14.2) entirely, replacing it with a centred version of the site's icon mark on a flat background, so it reads as a deliberate brand card rather than a broken character render at any size or platform crop.

14.9.2 Monitoring fallback-rate as a health signal #

Every response served via the fallback-card paths in Section 14.9's table increments the skinforge_og_fallback_total counter (Section 21.3's metrics registry owns the full metric catalog; referenced here only to establish that this endpoint's degraded-path rate is observable), labeled by reason (not_found, taken_down, render_error, timeout, storage_error). A sustained rise in the render_error or timeout label specifically — as opposed to not_found, which simply tracks normal bad-link traffic — is treated as an operational signal warranting the alert thresholds defined in Section 21.5, since it indicates the shared rendering pipeline (Section 8) is degraded in a way that also affects every ordinary pixel-render request, not just OG cards.

A weekly rollup of the same counters (Section 21.4's dashboard, referenced not redefined) gives staff a simple health check: a healthy steady state shows not_found dominating (normal expired/mistyped link traffic) and the four failure-labelled reasons (render_error, timeout, storage_error, taken_down) combined staying under 0.5% of total OG requests; crossing that threshold for more than one rollup period is the trigger condition for the alert in Section 21.5, not a fixed absolute request count, since OG traffic volume itself scales with how many permalinks are actively being shared in a given week.

15. Catalog Browse, Search & Filtering #

15.1 Catalog purpose and relationship to the designer #

The catalog is a standalone browsing surface for players who want to explore available options before or without using the designer (Section 11), for search engines indexing individual assets and hues (Section 10.6), and as the landing surface for shared links to specific items. It is never required to use the designer — every asset and hue is also reachable inside the designer's own option panel (Section 11.6) — but it offers deeper metadata (first-seen build, tags, related items) that the compact in-designer panel omits.

The hand-off from catalog to designer is one direction only: catalog pages never read or write designSig; they are plain navigations. The "Use in Designer" action (15.4) constructs a transient query string (Section 13.6) representing a single-slot design and navigates to /, where the designer parses it exactly as it would any shared link. No client-side state is shared between the two surfaces beyond that URL.

15.2 /catalog overview page #

Specified structurally in Section 10.3.3 (IA/routing owner). This section owns its content model:

  • Slot cards: one per row of the 19-row taxonomy in Section 3 — the required body slot and all 18 choosable cosmetic slots, backpack included (Section 11.6 lets the visitor change its asset and hue like any other slot; it defaults to backpack.default at hue 0 when untouched). Each card shows: slot display name, published asset count (asset_variants rows with assets.retired_at IS NULL and asset_images present), a representative thumbnail (most-recently-published asset in that slot), and a gender-availability icon pair (male/female) reflecting whether the slot has at least one asset for each body.
  • Featured/new assets: a horizontal strip above the slot grid, titled "Recently added", showing up to 12 assets across all slots published in the most recent game_builds row, sorted by asset_variants.published_at descending. Omitted entirely (strip not rendered) when the most recent build introduced zero new assets.
  • Hue groups entry: a single card, visually distinct (accent border) from the slot cards, linking to /hues, showing the total published hue count.

15.3 /catalog/:slotKey — grid, filters, query parameters #

Grid layout: responsive card grid per Section 10.8's breakpoint table (2/3/4 columns at basesm/md/lg+). Each AssetCard:

interface AssetCard {
  assetKey: string;
  displayName: string;
  thumbnailUrl: string;         // /render/a/:assetKey/:body/0@2.webp, body = current filter or "m"
  bodies: ("m" | "f")[];
  isNew: boolean;                // published in the current game_builds row
  isRetired: boolean;
  tags: string[];                // up to 3 shown, rest collapsed behind a "+N" chip
}

Card anatomy: thumbnail (top, a 1:1 card region containing the 260:330 art via object-fit: contain per Section 12.4, transparent-background composited on the surface token per Section 18.2), display name (below, single line, ellipsis-truncated), gender-availability icon pair (small, bottom-left), "New" chip (accent, top-right corner, shown only when isNew), "Retired" chip (muted, top-right corner, shown only when isRetired; mutually exclusive with "New" by construction since a retired asset cannot be new). The whole card is one link to /catalog/:slotKey/:assetKey.

Sorting options: name (default, ascending display name, locale-aware collation), newest (published_at descending), most_used (stat_counters use count descending, Section 21.2). Exposed via a segmented control (Section 18.5) above the grid.

Filter panel fields:

Filter UI control Query param Values
Body segmented control, 3 options body m, f, omitted = both
Tags multi-select chip list, searchable if the slot has more than 20 tags tag repeatable, tag slugs
New in build toggle newOnly true | omitted
Show retired toggle, off by default showRetired true | omitted

There is no hue-group filter on the asset grid: hue application is universal (any huable pixel accepts any hue, Section 8.3), so an asset does not itself belong to a hue group — only individual hues do (hue_group_members). Hue-group browsing lives on /hues and /hues/:hueIndex (15.5) and inside the hue picker's grouped tabs (Section 11.6), not as an asset-list facet.

Full query parameter grammar for /catalog/:slotKey:

?sort=name|newest|most_used
&body=m|f
&tag=<slug>                (repeatable)
&newOnly=true
&showRetired=true
&cursor=<opaque>
&limit=1..100 (default 24)

These map directly onto the GET /api/v1/assets query parameters owned by Section 16; the page's server render performs the same request server-side for first paint, then CatalogFilterBar (Section 11's sibling island, defined here since it is Section 15's component) re-issues it client-side on any control change, replacing the grid in place and updating the URL via history.pushState (unlike the designer's replaceState pattern in Section 11.4 — catalog filter states are meaningful, shareable, back-button-navigable pages, not micro-edits).

Filter facet counts shown next to each tag chip (e.g. "Metal (12)") are computed with one aggregate query, executed alongside the primary list query for the current slot and any already-applied filters from the OTHER dimensions (so picking a body filter narrows the tag counts but the tag dimension never narrows its own counts, letting the visitor see what widening or changing tags would yield):

SELECT t.tag_key, t.display_name, count(DISTINCT a.id) AS count
FROM tags t
JOIN asset_tags at2 ON at2.tag_id = t.id
JOIN assets a ON a.id = at2.asset_id
JOIN asset_variants av ON av.asset_id = a.id
WHERE a.slot_key = :slotKey
  AND a.retired_at IS NULL
  AND (:bodyFilter IS NULL OR av.body = :bodyFilter)
GROUP BY t.tag_key, t.display_name
ORDER BY count DESC;

The facet query runs with a 50ms statement timeout distinct from the main list query's timeout, so a slow facet count degrades to "count unavailable" (chip renders without a number) rather than blocking the page.

15.4 /catalog/:slotKey/:assetKey — asset detail page #

Layout: large preview (left or top, per breakpoint) with a hue chooser identical in behaviour to Section 11.6's HuePicker component (same component, reused, props adapted to a standalone selectedHue local signal instead of designSig); both gender variants shown side by side when bodies.length === 2 (two preview panes, each independently reflecting the same selected hue), or a single pane with a "Not available for the other body" note when only one variant exists.

Metadata panel: slot (linking back to /catalog/:slotKey), first-seen build label and date (from game_builds.published_at of the build referenced by the asset's earliest asset_variants row), tags (each a link to /catalog/:slotKey?tag=<slug>), retirement status if applicable (date retired, plain statement that it remains permanently available in any design that used it, referencing the soft-delete convention in Section 6).

Actions:

  • "Use in Designer": navigates to /?b=<currentBody>&s=0&<slotKey>=<assetKey>:<selectedHue> (or the skinHue/no-slot-param form when slotKey === "body", since body has no assetKey, only a hue), i.e. a single-slot transient design (Section 13.6). All other slots are left at their default empty state — this action does not attempt to merge with any prior designer session.
  • "Copy Asset Key": copies the raw assetKey string to the clipboard (same clipboard/fallback pattern as Section 11.9's Share button), toast "Asset key copied".

Related assets: up to 6 assets sharing at least one tag with the current asset, same slot, excluding the current asset itself, sorted by shared-tag count descending then published_at descending. Empty related list renders no section at all (heading and grid both omitted), never an empty-state message — this is a supplementary panel, not a primary content area.

15.5 /hues and /hues/:hueIndex #

/hues: tabs across the top, one per hue_groups row, ordered by the group's sort_order column (Section 6). Active tab's swatch grid below: one 32×32 swatch per hue in the group, showing the sRGB conversion of the hue's table index 16, the ramp's visual midpoint (same convention as Section 11.6's swatch rendering, Section 8.3.1), each swatch linking to /hues/:hueIndex.

/hues/:hueIndex detail:

  • Hue name and index, heading format Hue <hueIndex> — <name>.
  • The full 32-colour ramp: a horizontal strip of 32 swatches, one per palette entry, each showing its index (0–31) on hover/focus via title attribute and an accessible label Shade <n> of 32.
  • Sample application: the hue applied live (client-side canvas, same hue-application algorithm as Section 8.3, reused as a small standalone component HueSwatchPreview) to the fixed reference asset configured per slot family (Section 10.3.7 names the mechanism: core/hue-preview-reference.ts). A small selector lets the visitor switch the reference asset among the 3 configured families (hair, torso_inner, body) without leaving the page.
  • Copy-hue-number action: copies the bare hueIndex integer as a string, toast "Hue number copied".

Search grammar, parsed by a hand-written tokenizer in core/search/tsvector.ts (no external query-language library):

<query> ::= <term>*
<term>  ::= <field> ":" <value> | <freetext>
<field> ::= "slot" | "hue" | "tag" | "body"

Examples and their resolution:

Query Interpretation
long hair free text: both terms ANDed against search_tsv
slot:hair hue:1102 slot filter hair AND hue filter exact index 1102
tag:wig-stand body:f red tag filter wig-stand AND body filter f AND free text red
hue:crimson hue filter by name, trigram match against hues.name
tag:wig-stand tag:event tag filter, either tag present (OR within the field)
color:red no recognized field named color; entire token falls back to free text color:red

Field values are case-insensitive for slot, tag, and body (matched against lowercase stored keys); hue numeric matching is exact-integer only, with no leading-zero or whitespace tolerance beyond standard integer parsing.

Rules:

  • Multiple free-text words are ANDed as separate tsquery lexemes.
  • A repeated field (e.g. two tag: terms) is ORed within that field, then ANDed against other fields — mirrors the tag query parameter's repeatable-OR semantics in 15.3.
  • hue: accepts either a numeric index or a quoted/unquoted hue name substring; numeric input matches hues.hue_index exactly, non-numeric input matches hues.name via trigram similarity.
  • body: accepts m or f only; any other value is treated as a free-text term instead of an error, since the reader is an end user, not an API client — malformed structured terms degrade to text rather than producing a visible error.
  • Unrecognized field names (e.g. color:red) degrade the same way: the whole field:value token is treated as free text.

Postgres implementation: search_tsv cannot be a GENERATED ALWAYS AS column here — Postgres rejects a generated column whose expression contains a subquery, and aggregating an asset's tag names requires one. search_tsv is instead a plain, indexed column maintained by a trigger (Section 6 owns assets.search_tsv tsvector in its table definition; this section owns the expression and the trigger that keeps it current):

-- Shared helper: computes the tsvector for one asset id from its current row plus its current tags.
-- Used by the AFTER trigger below, where the row is already committed and a fresh SELECT is safe.
CREATE OR REPLACE FUNCTION compute_asset_search_tsv(p_asset_id text) RETURNS tsvector AS $$
  SELECT
    setweight(to_tsvector('english', coalesce(a.display_name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce((
      SELECT string_agg(t.display_name, ' ')
      FROM asset_tags at2 JOIN tags t ON t.id = at2.tag_id
      WHERE at2.asset_id = a.id), '')), 'B')
  FROM assets a WHERE a.id = p_asset_id;
$$ LANGUAGE sql STABLE;

-- Fires on the assets row itself (display_name changed). Reads NEW directly rather than calling
-- compute_asset_search_tsv, because on BEFORE INSERT the row is not yet visible to a sub-SELECT —
-- calling the helper here would return NULL on every newly imported asset.
CREATE OR REPLACE FUNCTION assets_search_tsv_refresh() RETURNS trigger AS $$
BEGIN
  NEW.search_tsv :=
    setweight(to_tsvector('english', coalesce(NEW.display_name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce((
      SELECT string_agg(t.display_name, ' ')
      FROM asset_tags at2 JOIN tags t ON t.id = at2.tag_id
      WHERE at2.asset_id = NEW.id), '')), 'B');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER assets_search_tsv_biu
  BEFORE INSERT OR UPDATE OF display_name ON assets
  FOR EACH ROW EXECUTE FUNCTION assets_search_tsv_refresh();

-- Tag changes don't touch the `assets` row directly, so this AFTER trigger on the join table
-- re-derives and writes the affected asset's tsvector explicitly:
CREATE OR REPLACE FUNCTION asset_tags_search_tsv_sync() RETURNS trigger AS $$
DECLARE
  v_asset_id text := coalesce(NEW.asset_id, OLD.asset_id);
BEGIN
  UPDATE assets SET search_tsv = compute_asset_search_tsv(v_asset_id) WHERE id = v_asset_id;
  RETURN NULL; -- AFTER trigger; return value is ignored
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER asset_tags_search_tsv_aiud
  AFTER INSERT OR DELETE ON asset_tags
  FOR EACH ROW EXECUTE FUNCTION asset_tags_search_tsv_sync();

CREATE INDEX assets_search_tsv_idx ON assets USING GIN (search_tsv);
CREATE INDEX assets_display_name_trgm_idx ON assets USING GIN (display_name gin_trgm_ops);

Import (Section 7) populates search_tsv the same way: every insert/update to assets.display_name or asset_tags fires the trigger above, so there is no separate backfill step to keep in sync.

Query shape for a free-text term combined with the slot: structured filter (hue-group membership is keyed by hue_groups.group_key via hue_group_members, but — per 15.3 — hue group is a property of individual hues, not of an asset, so it is never a join condition against assets/asset_variants here; a hue: term instead resolves directly against the hues table, below):

SELECT a.*, ts_rank(a.search_tsv, query) AS rank
FROM assets a, plainto_tsquery('english', :freeTextTerms) AS query
WHERE a.search_tsv @@ query
  AND (:slotKey IS NULL OR a.slot_key = :slotKey)
  AND a.retired_at IS NULL
ORDER BY rank DESC, a.display_name ASC
LIMIT :limit;

A hue: term is resolved separately against the hues table (hues.hue_index for numeric input, hues.name via trigram similarity for text input) and the two result sets are intersected by the slots each hue's matching assets occupy; a bare hue:<index> search with no other terms returns the /hues/:hueIndex page's own content directly (15.5) rather than an asset list.

Typo tolerance: when plainto_tsquery returns zero rows for a free-text term, a fallback query re-runs using similarity(display_name, :term) > 0.3 (the pg_trgm extension, same GIN index as above), ordered by similarity(...) descending. This fallback only triggers on a true zero-result first pass, never blended with tsvector-ranked results, to keep ranking predictable.

Ranking weights: display name matches are weight A (1.0), tag matches are weight B (0.4) — the standard ts_rank default weight set {0.1, 0.2, 0.4, 1.0} for D,C,B,A is used unmodified. "Most Used" sort (15.3) is independent of search ranking and only applies when no free-text term is present; combining a free-text search with sort=most_used is rejected at the API validation layer (Section 16) in favor of relevance ranking, since the two orderings are not composable.

Result limits: search results follow the same cursor pagination as the rest of the catalog (Section 16), default limit=24, max limit=100. A free-text search additionally caps total scanned rows at 1,000 via LIMIT 1000 in a wrapping CTE before ranking, to bound query cost on the trigram fallback path; this cap is invisible to the user (no UI ever shows "1000+ results", pagination simply ends).

15.7 Empty, zero-result and error states #

State Trigger Copy Suggested action
Slot has zero published assets publishedAssetCount === 0 on /catalog/:slotKey "No options available for this slot yet. Check back after the next update." Link: "See the changelog" (/changelog)
Zero results after filtering Filters applied, meta.count === 0 "No results match your filters." Button: "Clear filters" (resets query string to bare /catalog/:slotKey)
Zero results from search Free-text term present, meta.count === 0 after both tsquery and trigram fallback "No matches for "". Try a different search term." Button: "Clear search"
Unknown slot key Route-level, Section 10.9 owns as 404 (404 page copy)
Unknown asset key Route-level, Section 10.9 owns as 404 (404 page copy)
Backend/query error Any unhandled exception in the catalog data loader Section 10.9's 500 page

15.8 Pagination behaviour #

Cursor model per Section 16. UI presentation is a "Load more" button below the grid, not automatic infinite scroll (unlike the designer's option panel, 11.6) — the standalone catalog favors an explicit action so the page remains a normal, indexable, back-button-friendly page with a bounded initial payload for SEO crawlers, which do not execute scroll-triggered fetches.

"Load more" button: appears when meta.nextCursor !== null, disabled with a spinner while the next page is in flight, replaced by the newly appended cards otherwise; removed entirely once meta.nextCursor === null. Clicking it updates the URL's limit parameter via history.replaceState (not pushState — intermediate pagination cursors are not meaningful back-button stops, only the initial filtered view is) to 24 × pagesLoaded and carries no cursor parameter, so a page reload replays from the start as a single larger first page rather than resuming mid-list — limit and cursor are independent parameters, and the "Load more" path only ever uses limit.

Accessible announcement: the grid container has aria-live="polite"; on successful load-more, a visually hidden status message is written: "Loaded more results, total" using meta.count values, so screen reader users get confirmation without the entire grid being re-announced.

15.9 Deep-linkable filter state, canonical URLs, indexability #

Every filter combination is representable in the URL query string (15.3's grammar) and is shareable/bookmarkable. Canonical URL rules:

  • Parameters are always emitted in a fixed order (sort, body, tag, newOnly, showRetired, cursor, limit) when the page itself constructs a URL (filter changes, load-more), so two users applying the same filters in a different UI order still land on byte-identical URLs.
  • sort=name (the default) and limit=24 (the default) are omitted from constructed URLs entirely rather than written explicitly, keeping default-state URLs minimal (bare /catalog/:slotKey).
  • A <link rel="canonical"> tag is emitted using the fully-normalized URL (defaults omitted, fixed param order) even when the incoming request URL differs only in param order or included explicit defaults, so search engines consolidate equivalent URLs.

Indexable filter combinations: only the bare /catalog/:slotKey (no query string) and /catalog/:slotKey?body=m / ?body=f are indexable (index, follow); every other filter combination (tag, search, newOnly, showRetired, pagination cursor) is noindex, follow — indexable via crawling but not intended as a search-landing page, since the combinatorial space of filters is large and low-value for search while the bare and body-only views cover the meaningful "browse this slot" intent. This mirrors the pattern already used for the transient designer query string in Section 10.6.

15.10 Performance #

Query budgets: every catalog list query (grid, filter facets, search) targets p95 under 80ms measured at the database, and under 200ms end-to-end including the WASM-free JSON response assembly, consistent with the API latency budget Section 19.1 sets for /api/v1/*.

Index usage: the GIN indexes in 15.6 back all text search; standard B-tree indexes on assets(slot_key, retired_at), asset_variants(asset_id, body), and asset_tags(tag_id, asset_id) (Section 6 owns their formal definitions) back the structured filters so no filtered list query requires a sequential scan at expected catalog sizes (low thousands of asset rows).

Thumbnail payload per page: at the default limit=24 and the WebP thumbnail size used by AssetCard (2x scale per Section 8.6, roughly 6–12KB per image based on sprite complexity), a first catalog page's image payload is budgeted at under 300KB total, enforced informally by the fixed thumbnail dimensions and format rather than per-request compression tuning.

Caching: catalog list responses follow the JSON API cache class (Section 19.4: short s-maxage with stale-while-revalidate, since catalog contents change only on an admin publish action, Section 17.5); rendered thumbnail images follow the content-addressed immutable render cache class (Section 8.9 / Section 19.4). Search results are never cached server-side beyond the standard JSON API class — free-text queries have too large a cardinality for a dedicated cache layer, and the underlying Postgres query is already fast enough (budget above) that a cache would add complexity without a measurable latency win.

16. Public HTTP API #

16.1 Base URL, versioning policy, deprecation policy #

Base URL: /api/v1, served from the same Deno process as the public site (Section 4). The API is public and unauthenticated end to end — it is the same API the front end's islands (Section 11) call, and any third party may call it directly under the polite-use terms in 16.11.

Versioning policy: the version segment (v1) bumps only on a breaking change — removing a field, changing a field's type or meaning, removing an endpoint, or changing an error code's meaning. Adding a new optional field to a response, adding a new endpoint, adding a new query parameter, or adding a new enum value to a field that is documented as open-ended, are all additive and never bump the version.

Deprecation policy: when v2 ships, v1 remains fully functional for a minimum of 12 months from v2's release, with deprecation communicated three ways: (1) a Deprecation: true and Sunset: <HTTP-date> header pair on every v1 response from the day v2 ships, per RFC 8594; (2) an entry in the public /changelog page (Section 10); (3) the /api/v1/version endpoint (16.8.18) reporting "deprecated": true and "sunset": "<ISO-8601 date>". There is no v2 in scope for this specification; this subsection defines the policy that governs one if it is ever built.

16.2 Request conventions #

  • Methods: GET and HEAD for every read endpoint; POST only for POST /designs (the sole write endpoint in the public API — everything else is read-only, matching the "read-mostly" framing of this section). No PUT, PATCH, or DELETE exist in the public API; admin mutations live under the separate /admin/api namespace (Section 17.17).
  • Content types: requests with a body (POST /designs) must send Content-Type: application/json; any other content type on that endpoint returns SF-1015 (415-mapped, see 16.4.2) with a 415 status. All responses are Content-Type: application/json; charset=utf-8 except the non-/api image endpoints (16.8.19), which are owned by Section 8 and Section 14.
  • Accepted headers: Accept (only application/json and */* are honored; anything else that explicitly excludes JSON returns 406 with SF-1016), Accept-Encoding (see below), If-None-Match / If-Modified-Since (16.6), X-Request-Id (optional client-supplied request id, echoed back — see below).
  • Accept-Encoding: the server transparently applies br (Brotli) or gzip compression based on the client's Accept-Encoding header, using Deno's built-in compression stream support at the HTTP layer; every JSON response is eligible. Responses under 512 bytes are sent uncompressed (compression overhead exceeds the saving).
  • Request id generation and echo: every request is assigned a ULID-based request id (jsr:@std/ulid, Section 4) at the top of the middleware chain. If the client supplied X-Request-Id on the request, that value is used verbatim (allowing client-side request tracing to correlate); otherwise the server generates one. The id is echoed on the response as X-Request-Id and embedded in the error envelope's requestId field (16.4) when an error occurs, and in every log line for that request (Section 21).
  • CORS policy: Access-Control-Allow-Origin: * on every /api/v1/* response — the API is intentionally open to cross-origin fetches from any web page, since it serves no user-specific or sensitive data (no accounts, no cookies read by these routes) and 16.11 explicitly invites third-party use. Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS. Access-Control-Allow-Headers: Content-Type, X-Request-Id, If-None-Match. Access-Control-Max-Age: 86400. Preflight OPTIONS requests are answered directly by the CORS middleware with a 204 and these headers, before reaching any route handler. Credentials (Access-Control-Allow-Credentials) are never enabled — the public API never reads or sets cookies, so credentialed CORS has no purpose here and enabling it would be a needless attack-surface increase.
  • HEAD support: every GET endpoint also answers HEAD with identical headers (including ETag, Cache-Control, Content-Length) and an empty body, implemented generically by running the full handler and stripping the body, not by separate handler code paths.

16.3 Success envelope #

Canonical shape, used by every successful response in the public API:

{
  "data": { "...": "endpoint-specific payload, object or array" },
  "meta": { "...": "optional; present only when the endpoint defines pagination or extra context" }
}

Rules:

  • data is always present. Its shape is a single resource object for singular endpoints (GET /designs/{code}) or an array of resource objects for collection endpoints (GET /assets).
  • meta is present only on endpoints that document it — primarily paginated collections (meta.nextCursor, meta.count, 16.5) and a small number of endpoints with extra context (GET /search includes meta.tookMs). An endpoint that does not document meta never includes it, not even as null or {}.
  • No top-level keys other than data and meta ever appear on a success response.

Example — singular resource (GET /slots/{slotKey}):

{
  "data": {
    "slotKey": "hair",
    "displayName": "Hair",
    "zOrder": 130,
    "hueable": true,
    "genderScope": "both",
    "choosable": true
  }
}

Example — paginated collection (GET /assets?slot=hair):

{
  "data": [
    { "assetKey": "hair.long-wavy", "slotKey": "hair", "displayName": "Long Wavy" },
    { "assetKey": "hair.short-crop", "slotKey": "hair", "displayName": "Short Crop" }
  ],
  "meta": { "nextCursor": "eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI2LTAxLTAxIiwidCI6IjAxSjhaSzNRTkdYUThONkM0VDJNMUZBVkIyIn0.qX3nR8vM2wZpL5tK9cB1yD7hF0eN4sJ6", "count": 2 }
}

16.4 Error envelope #

16.4.1 Canonical definition #

Every non-2xx JSON response uses exactly this shape:

{
  "error": {
    "code": "SF-2001",
    "message": "That item isn't in our catalog.",
    "field": "slots[0].asset",
    "details": [],
    "requestId": "01J8X9QK3R7VZQZ8N6C4T2M1FA"
  }
}
Field Type Always present Notes
code string yes SF- + 4 digits, see 16.4.2. Stable identifier for programmatic handling — clients should branch on code, never on message.
message string yes Human-readable English sentence. Not localized (Section 3's locale decision: English only in v1). Safe to display directly to end users.
field string | null yes (may be null) Dot/bracket path into the request body or query identifying the offending value, using the same path syntax Zod issues use (e.g. slots[0].asset, limit). null when the error is not attributable to one field (e.g. not-found, rate-limit, internal).
details array yes (may be []) Populated only for multi-field validation errors (16.4.3); each entry has the same shape as the top-level error object minus requestId and details itself (no nesting beyond one level).
requestId string yes The ULID from 16.2, always present so a user can quote it when reporting an issue.

16.4.2 Code shape and HTTP status mapping #

SF- followed by exactly 4 digits, grouped by leading digit into families. This section defines the shape and the family-to-HTTP mapping; Section 25 owns the exhaustive per-code registry.

Family Code range HTTP status Meaning
Validation SF-1000SF-1999 400 (422 for semantically invalid but syntactically well-formed input, e.g. unknown enum value) / 415 (wrong content type) / 406 (unacceptable Accept) Request shape or values are invalid.
Not found SF-2000SF-2999 404 (unknown resource) / 410 (resource existed but is permanently gone — retired design, takedown) Requested resource does not resolve.
Rate limit SF-3000SF-3999 429 Client exceeded a rate-limit bucket (16.7).
Auth SF-4000SF-4999 401 / 403 Reserved for the admin API (Section 17.17); the public API in this section never returns this family, since it requires no authentication. Listed here for completeness of the shape.
Render SF-5000SF-5999 500 (data/pipeline fault) / 502 (upstream object storage failure) / 503 (render queue back-pressure) / 504 (generation exceeded SKINFORGE_RENDER_TIMEOUT_MS) Rendering pipeline failure (Section 8) surfaced through an API response, e.g. GET /designs/{code}/render-urls when the underlying build data is unreadable.
Import SF-6000SF-6999 — (409 for SF-6012) Reserved for the admin/CLI import pipeline (Section 7, Section 17.5); the public API returns exactly one code from this family, SF-6012 (409, no build has ever been published yet — 16.8.14), since that is fundamentally a state of the import/publish pipeline rather than a validation or not-found condition.
Internal SF-9000SF-9999 500 (unexpected server error) / 502 (non-render-path storage unreachable) / 503 (maintenance mode active) message is a generic "Something went wrong. Please try again." — never leaks internal detail; the real cause is in the server log keyed by requestId (Section 21).

16.4.3 Multi-field validation errors #

When a request fails validation on more than one field at once (for example, POST /designs with both an invalid body and an invalid slot entry), the response uses a single top-level error whose code is SF-1000 (generic "request failed validation"), whose field is null, whose message is "Request failed validation. See details." and whose details array carries one entry per offending field, each with its own code (a more specific code where one applies, otherwise SF-1000 again), message, and field:

{
  "error": {
    "code": "SF-1000",
    "message": "Request failed validation. See details.",
    "field": null,
    "details": [
      { "code": "SF-1003", "message": "Choose a valid body type.", "field": "body" },
      { "code": "SF-2001", "message": "That item isn't in our catalog.", "field": "slots[0].asset" }
    ],
    "requestId": "01J8X9QK3R7VZQZ8N6C4T2M1FA"
  }
}

A single-field validation failure is reported with details: [] and the specific code/field/message promoted to the top level directly (no redundant single-entry details array), matching the shape shown in 16.4.1.

16.5 Pagination #

Cursor-based only — chosen for stability under concurrent writes (16.5.4) and to avoid the cost of a COUNT(*)/offset scan on large tables. No endpoint anywhere in this API accepts offset or page parameters.

16.5.1 Cursor format #

A cursor is an opaque, HMAC-signed, base64url-encoded payload of the form base64url(JSON) + "." + base64url(HMAC-SHA256(JSON, cursorKey)), where JSON is {"k": "<sort-key-name>", "v": "<sort-key-value-at-boundary>", "t": "<ULID tie-breaker>"} and cursorKey is an HKDF subkey derived from SKINFORGE_ADMIN_SESSION_SECRET with the fixed info string "skinforge-cursor-v1" (Section 20.2) — a subkey, not the raw secret, since the public API has no admin context and must not share key material directly. Clients never construct or parse a cursor; they only ever pass back a nextCursor value verbatim. A cursor whose signature does not verify, whose k does not match the endpoint's expected sort key (for example, a cursor from a differently-filtered request), or which was signed under a rotated secret, returns SF-1010 (400) — "That page reference isn't valid; start from the first page."

Example, decoded for illustration only (the . separator divides the payload from the signature; the signature bytes below are an illustrative placeholder, not a computed value):

{ "k": "created_at", "v": "2026-01-01T00:00:00.000Z", "t": "01J8ZK3QNGXQ8N6C4T2M1FAVB2" }

Encoded form (what actually appears in URLs and meta.nextCursor): eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI2LTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjIifQ.qX3nR8vM2wZpL5tK9cB1yD7hF0eN4sJ6

Each endpoint documents its own sort key (k) in 16.8; most default to created_at descending with a tie-breaker on the row's ULID primary key ascending, carried in the payload's t field (ULIDs are lexicographically time-ordered, so this gives a fully stable total order with no duplicate or skipped rows across pages even under concurrent inserts).

16.5.2 Parameters and bounds #

  • cursor (query, optional): an opaque cursor from a previous response's meta.nextCursor. Omitted or empty on the first page. A cursor that fails to decode, or whose k does not match the endpoint's expected sort key (for example, a cursor from a differently-filtered request), returns SF-1010 (400) — "That page reference isn't valid; start from the first page."
  • limit (query, optional): integer, 1 to 100 inclusive, default 24. A value outside range is clamped, not rejected: values below 1 become 1, values above 100 become 100 (chosen over hard rejection because it is friendlier to third-party integrators per 16.11 and never produces a security or performance concern, unlike accepting an unbounded value).

16.5.3 Response shape #

{
  "data": [ /* up to `limit` resources */ ],
  "meta": { "nextCursor": "eyJrIjoi....<sig>", "count": 24 }
}

meta.nextCursor is null when the returned page is the last page (exhausted). meta.count is the number of items in this page's data array (not a total count — a total count would require an expensive COUNT(*) and cursor pagination deliberately avoids it; endpoints that need an approximate total say so explicitly in their own reference entry, and none in this API do).

16.5.4 Stability guarantees #

  • Cursors are stable across concurrent writes: a page fetched with a given cursor never re-shows an item from an earlier page (no duplicates) and never skips an item that existed at the time the cursor was issued, because the sort key includes a unique, monotonic tie-breaker (the ULID primary key).
  • Cursors are not guaranteed stable across deploys that change an endpoint's default sort key — this would be a breaking, version-bumping change per 16.1, so it cannot happen within v1.
  • Cursors do not expire on a timer, but every outstanding cursor is invalidated when SKINFORGE_ADMIN_SESSION_SECRET is rotated (Section 24.5); a client receiving SF-1010 restarts from the first page.
  • A cursor referencing a since-retired filter combination (for example, a slot value that no longer exists) returns an empty data: [] with nextCursor: null rather than an error, since the absence of matching rows is a valid, representable result.

16.5.5 Worked example #

Request 1: GET /api/v1/assets?slot=hair&limit=2

{
  "data": [
    { "assetKey": "hair.long-wavy", "slotKey": "hair" },
    { "assetKey": "hair.short-crop", "slotKey": "hair" }
  ],
  "meta": { "nextCursor": "eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI1LTExLTAzVDA5OjEyOjAwLjAwMFoiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjMifQ.r7Mx2Ns5Vp0Lc9wK1yE6bT3dH8fQ4jZ7", "count": 2 }
}

Request 2: GET /api/v1/assets?slot=hair&limit=2&cursor=eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI1LTExLTAzVDA5OjEyOjAwLjAwMFoiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjMifQ.r7Mx2Ns5Vp0Lc9wK1yE6bT3dH8fQ4jZ7

{
  "data": [
    { "assetKey": "hair.buzz-cut", "slotKey": "hair" },
    { "assetKey": "hair.braided", "slotKey": "hair" }
  ],
  "meta": { "nextCursor": null, "count": 2 }
}

nextCursor: null signals the client to stop paging.

16.6 Caching and conditional requests #

Applies to every /api/v1/* JSON endpoint (image endpoints under /render and /og follow Section 8 and Section 14's own cache rules, referenced in 16.8.19; the health checks referenced in 16.8.17 are owned entirely by Section 21.7, including their own caching rule).

  • ETag: every response carries a strong ETag computed as "sha256:<first16hexchars>" of the serialized response body (the data+meta JSON, before compression). Catalog endpoints (slots/assets/hues/hue-groups/tags/builds) additionally fold the current published build_id into the tag input, so the ETag changes automatically the moment a new build publishes (Section 9), without any endpoint-specific invalidation code.
  • Last-Modified: set to the endpoint's most relevant timestamp — for a catalog resource, its row's updated_at; for a collection, the max(updated_at) across the page's rows; for GET /designs/{code}, the design's created_at (designs are immutable, so this never changes, per Section 13.7).
  • If-None-Match: if the client's supplied ETag matches the freshly computed one, the server returns 304 Not Modified with no body and only cache-relevant headers (ETag, Cache-Control, X-Request-Id) — computed by generating the full response internally and comparing tags (simple, since these endpoints are cheap reads against an indexed schema), not by a separate cheaper existence check.
  • If-Modified-Since: honored as a fallback when If-None-Match is absent, compared against Last-Modified at one-second granularity per HTTP semantics.
  • Cache-Control per endpoint class:
Endpoint class Cache-Control Rationale
Catalog reads (slots, assets, hues, hue-groups, tags, builds) public, max-age=60, s-maxage=300, stale-while-revalidate=3600 Changes only on admin publish (Section 9), which is infrequent; short browser TTL, longer shared/CDN TTL.
GET /designs/{code} public, max-age=31536000, immutable Designs never change after creation (Section 13.7); safe to cache forever.
GET /designs/random no-store Must not be cached or it stops being random.
GET /search public, max-age=30, s-maxage=60 Results can shift as the catalog grows; short TTL keeps results fresh without hammering the database on repeat queries.
GET /stats/popular public, max-age=300, s-maxage=900 Aggregate stats change slowly by nature.
GET /version public, max-age=60 Rarely changes; short TTL is enough to avoid staleness after a deploy.
POST /designs no-store Write endpoint; never cached.

16.7 Rate limiting #

Implemented via the Postgres-backed token bucket described in Section 4 (no Redis in v1), with an in-process LRU fast path for the common case of the same client hitting the same bucket repeatedly within a short window.

16.7.1 Buckets #

Bucket Scope key Limit Window
api-read client IP (respecting SKINFORGE_TRUSTED_PROXY_HOPS, Section 24) SKINFORGE_RATE_LIMIT_API_PER_MINUTE (default 120/min, Section 24) rolling 60s
render client IP (respecting SKINFORGE_TRUSTED_PROXY_HOPS, Section 24) SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE (default 60/min, Section 24) rolling 60s
design-create client IP SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR (default 30/hour, Section 24) rolling 3600s
search client IP 30/min (fixed; folded into api-read accounting but capped independently to protect the trigram query in 16.8.9 from abuse) rolling 60s

api-read covers every GET/HEAD endpoint under /api/v1 in this section except GET /designs/random, which is excluded from rate limiting entirely (random-design requests are cheap indexed lookups and are a core, expected-to-be-frequent interaction on the homepage per Section 10). The render bucket separately covers image requests under /render/* and /og/* (Section 8, Section 14) — not part of the /api/v1 namespace, but sharing this section's rate-limiting mechanism and header conventions. Health-check requests (Section 21.7) are excluded from rate limiting entirely, since a monitor probing liveness/readiness on a tight interval must never itself trip a limit. design-create covers only POST /designs and stacks with, rather than replaces, api-read.

16.7.2 Headers #

Every response, whether or not the client is being limited, carries:

RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 42

(RateLimit-Reset is seconds until the current window resets, per the IETF RateLimit header draft adopted here as the concrete format.) A response that exceeds the bucket additionally carries:

Retry-After: 42

(seconds, matching RateLimit-Reset at the moment of the 429).

16.7.3 429 body #

{
  "error": {
    "code": "SF-3002",
    "message": "You've created a lot of designs recently. Please wait before creating another.",
    "field": null,
    "details": [],
    "requestId": "01J8X9QK3R7VZQZ8N6C4T2M1FA"
  }
}

SF-3000 is the generic read-rate-limit code (api-read, search buckets); SF-3001 is specifically the render bucket; SF-3002 is specifically design-create — so clients can distinguish "back off browsing" from "back off requesting images" from "back off creating."

16.8 Endpoint reference #

All paths below are relative to /api/v1. Every endpoint supports HEAD per 16.2 and returns the error envelope (16.4) for its documented error cases; unlisted errors (malformed JSON body, wrong Content-Type, unacceptable Accept) apply uniformly per 16.2/16.4 and are not repeated per endpoint.

16.8.1 GET /slots #

Purpose: list the full slot registry (Section 3) so clients can build slot pickers without hardcoding the taxonomy.

Query parameters: none.

Response — data: array of SlotResource (16.9), every row from the Section 3 registry with visible = true (WHERE visible = true, Section 6.9 and 17.9's toggle) — up to all 19, including body and backpack when they are visible — ordered by zOrder ascending. No pagination (meta absent) — the set is small and fixed.

Example request: GET /api/v1/slots

Example response:

{
  "data": [
    { "slotKey": "body", "displayName": "Body", "zOrder": 10, "hueable": true, "genderScope": "both", "choosable": false },
    { "slotKey": "hair", "displayName": "Hair", "zOrder": 130, "hueable": true, "genderScope": "both", "choosable": true },
    { "slotKey": "facial_hair", "displayName": "Beard", "zOrder": 140, "hueable": true, "genderScope": "male", "choosable": true }
  ]
}

Errors: none beyond the common set. Caching: catalog class (16.6). Rate class: api-read.

16.8.2 GET /slots/{slotKey} #

Purpose: fetch one slot's definition.

Path parameters: slotKey (string, must match a known slot_key).

Response — data: single SlotResource.

Example request: GET /api/v1/slots/hair

Example response:

{ "data": { "slotKey": "hair", "displayName": "Hair", "zOrder": 130, "hueable": true, "genderScope": "both", "choosable": true } }

Errors: SF-2004 (404) if slotKey does not match any known slot. Caching: catalog class. Rate class: api-read.

16.8.3 GET /assets #

Purpose: browse published assets, the primary data source for both the catalog (Section 15) and the designer's per-slot pickers (Section 11).

Query parameters:

Param Type Constraint Default
slot string must be a slot_key none (unfiltered)
body string m | f none (returns assets compatible with either)
tag string a known tag key; repeatable (tag=fantasy&tag=dark); repeated values are ORed within the field, then the field's result is ANDed against every other filter none
q string free text, 1–200 chars, matched against display name via trigram search (same engine as 16.8.9) none
sort string name | newest | most_used; most_used is rejected with SF-1019 (422) when q is present, since relevance and popularity orderings are not composable (Section 15.6) newest
newOnly boolean (true) restricts to assets first published in the current game_builds row omitted (unfiltered)
showRetired boolean (true/false) when true, includes assets with a non-null retired_at false
cursor, limit see 16.5

Cursor sort key (k, 16.5.1): created_at by default and when sort=newest; display_name when sort=name; use_count when sort=most_used.

Response — data: array of AssetResource (16.9). meta: pagination (16.5).

Example request: GET /api/v1/assets?slot=hair&body=f&limit=2

Example response:

{
  "data": [
    {
      "assetKey": "hair.long-wavy",
      "slotKey": "hair",
      "displayName": "Long Wavy",
      "genderScope": "both",
      "tags": ["fantasy"],
      "thumbnailUrl": "/render/a/hair.long-wavy/f/0@2.webp",
      "retiredAt": null
    },
    {
      "assetKey": "hair.short-crop",
      "slotKey": "hair",
      "displayName": "Short Crop",
      "genderScope": "both",
      "tags": [],
      "thumbnailUrl": "/render/a/hair.short-crop/f/0@2.webp",
      "retiredAt": null
    }
  ],
  "meta": { "nextCursor": null, "count": 2 }
}

Errors: SF-1007 (400) if slot references an unknown slot key; SF-1000 (400) if tag references an unknown value; SF-1019 (422) if sort=most_used is combined with q; SF-1010 (400) for a bad cursor. Caching: catalog class. Rate class: api-read.

16.8.4 GET /assets/{assetKey} #

Purpose: fetch one asset's full detail, including its variants.

Path parameters: assetKey (string, AssetKeySchema shape from 13.2.5).

Response — data: single AssetResource, with an additional variants array of AssetVariantResource (16.9) — one entry per (body, hue) combination that has a generated render, used by the designer to know what is instantly available versus what triggers on-demand generation (Section 8).

Example request: GET /api/v1/assets/hair.long-wavy

Example response:

{
  "data": {
    "assetKey": "hair.long-wavy",
    "slotKey": "hair",
    "displayName": "Long Wavy",
    "genderScope": "both",
    "tags": ["fantasy"],
    "thumbnailUrl": "/render/a/hair.long-wavy/f/0@2.webp",
    "retiredAt": null,
    "variants": [
      { "body": "m", "hue": 0, "renderUrl": "/render/a/hair.long-wavy/m/0@2.webp" },
      { "body": "f", "hue": 0, "renderUrl": "/render/a/hair.long-wavy/f/0@2.webp" }
    ]
  }
}

Errors: SF-2001 (404, and only for a truly unknown key — showRetired semantics from 16.8.3 do not apply here, since a direct-by-key lookup always returns a retired asset, matching the immutability guarantee in Section 13.7; a retired asset returns 200 with retiredAt set, never 404 or 410, because it can still be referenced by existing designs). Caching: catalog class. Rate class: api-read.

16.8.5 GET /hues #

Purpose: browse the hue catalog.

Query parameters:

Param Type Constraint Default
group string a known hue-group key (e.g. skin) none
source string base | outlands | derived (Section 3) none
q string free text against hue name, 1–100 chars none
cursor, limit see 16.5

Response — data: array of HueResource (16.9). meta: pagination.

Example request: GET /api/v1/hues?group=skin&limit=2

Example response:

{
  "data": [
    { "hueIndex": 1002, "name": "Fair", "source": "base", "swatchArgb": "#F2C9A5" },
    { "hueIndex": 1003, "name": "Tan", "source": "base", "swatchArgb": "#D9A876" }
  ],
  "meta": { "nextCursor": "eyJrIjoiaHVlX2luZGV4IiwidiI6MTAwMywidCI6IjAxSjhaSzNRTkdYUThONkM0VDJNMUZBVkI0In0.tN4pV6xR1sQ8wL2yG9cE5bM7fH3jK0oZ", "count": 2 }
}

Errors: SF-1000 (400) for unknown group/source. Caching: catalog class. Rate class: api-read.

16.8.6 GET /hues/{hueIndex} #

Purpose: fetch one hue's full detail.

Path parameters: hueIndex (integer, 0–65535; 0 is a valid lookup and returns the sentinel "as-drawn" definition: {"hueIndex":0,"name":"As Drawn","source":"base","swatchArgb":null}).

Response — data: single HueResource.

Example request: GET /api/v1/hues/1002

Example response:

{ "data": { "hueIndex": 1002, "name": "Fair", "source": "base", "swatchArgb": "#F2C9A5" } }

Errors: SF-2002 (404) if hueIndex is out of the valid published range or otherwise unknown, except 0 which always resolves. Caching: catalog class. Rate class: api-read.

16.8.7 GET /hue-groups #

Purpose: list hue groups (e.g. skin, and any other curated groupings from Section 17.8), each with its member count, for building filter UIs.

Query parameters: none. Response — data: array of { "groupKey": string, "displayName": string, "memberCount": integer }. No pagination — the set is small.

Example request: GET /api/v1/hue-groups

Example response:

{ "data": [ { "groupKey": "skin", "displayName": "Skin Tones", "memberCount": 58 } ] }

Errors: none beyond the common set. Caching: catalog class. Rate class: api-read.

16.8.8 GET /tags #

Purpose: list all tags in use, for catalog filter chips (Section 15).

Query parameters: q (optional free text, 1–50 chars). Response — data: array of { "tagKey": string, "assetCount": integer }tagKey is the tags.tag_key value, not the curated display name — ordered by assetCount descending. No pagination — tags are a small, curated set (Section 17.8's counterpart for tags is asset admin, Section 17.7).

Example request: GET /api/v1/tags

Example response:

{ "data": [ { "tagKey": "fantasy", "assetCount": 214 }, { "tagKey": "noble", "assetCount": 87 } ] }

Errors: none beyond the common set. Caching: catalog class. Rate class: api-read.

Purpose: a single cross-catalog search over assets, hues, and tags, backing the site search box (Section 10, Section 15).

Query parameters:

Param Type Constraint Default
q string required, 1–200 chars
kind string asset | hue | tag | all all
cursor, limit see 16.5

Response — data: array of { "kind": "asset"|"hue"|"tag", "score": number, "resource": <AssetResource | HueResource | {tagKey, assetCount}> }, ordered by score descending. meta: { "nextCursor": ..., "count": ..., "tookMs": number }.

Search implementation: PostgreSQL trigram similarity (pg_trgm extension, GIN indexes on assets.display_name, hues.name, tags.display_name, per Section 6), score is the trigram similarity() value, results below 0.15 similarity excluded.

Example request: GET /api/v1/search?q=wavy&kind=asset&limit=1

Example response:

{
  "data": [
    { "kind": "asset", "score": 0.42, "resource": { "assetKey": "hair.long-wavy", "slotKey": "hair", "displayName": "Long Wavy" } }
  ],
  "meta": { "nextCursor": null, "count": 1, "tookMs": 4 }
}

Errors: SF-1001 (400) if q is missing; SF-1014 (400) if q is present but out of length bounds. Caching: search class (16.6). Rate class: search (16.7.1), stacked with api-read.

16.8.10 POST /designs #

Purpose: create (idempotently) a permalink for a design. Full semantics owned by Section 13.5; canonicalization and short-code mechanics by Section 13.3–13.4.

Request body: a DesignDocument per Section 13.2.5's Zod schema (no wrapper object — the body is the document itself).

Example request:

POST /api/v1/designs
Content-Type: application/json

{
  "v": 1,
  "body": "m",
  "skinHue": 1002,
  "slots": [
    { "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 }
  ]
}

Example response (new design, 201):

{ "data": { "code": "7h3k9m2wq1", "created": true, "renderUrls": { "primary": "/render/d/7h3k9m2wq1@2.webp" } } }

Example response (identical resubmission, 200): identical body shape with "created": false.

Errors: SF-1000 (400, multi-field wrapper, 16.4.3) for combined failures, or a single specific code for one offending field — SF-1001 (missing required field), SF-1008 (unknown top-level key), SF-1009 (duplicate slot), SF-2001 (unknown asset key), SF-1019 (422, asset exists but belongs to a different slot), SF-1017 (gender-incompatible asset), SF-2002 (unknown hue index); SF-3002 (429) for rate-limit. Caching: no-store. Rate class: design-create (stacked with api-read).

16.8.11 GET /designs/{code} #

Purpose: fetch a design's full document and metadata, used by the design page (Section 13.8) and by third parties embedding design data.

Path parameters: code (string, normalized per 13.4.4 before lookup).

Response — data: DesignResource (16.9).

Example request: GET /api/v1/designs/7h3k9m2wq1

Example response:

{
  "data": {
    "code": "7h3k9m2wq1",
    "document": {
      "v": 1, "body": "m", "skinHue": 1002,
      "slots": [ { "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 } ]
    },
    "buildId": "01J7Z0N4C2E8QK3T9W1R6X5H4B",
    "createdAt": "2026-08-14T10:22:31.000Z",
    "takenDown": false
  }
}

viewCount is intentionally not part of this resource: it changes continuously (every page load, 13.10.1) while this response is cached immutable for a year (below), and a live counter has no place in a response the cache is told never to revalidate. Aggregate popularity is exposed separately via GET /stats/popular (16.8.16); the admin design view (17.11) reads designs.view_count directly from the database, not through this endpoint.

Errors: SF-2000 (404) unknown or malformed code, indistinguishable by design (Section 13.4.1); SF-2005 (410) if the design was taken down (designs.taken_down_at IS NOT NULL, Section 6) — the standard error envelope (16.4.1) with code: "SF-2005" and field: null, and no design data; the short code is already in the request path and is not echoed in the body. Caching: public, max-age=31536000, immutable per 16.6 (except a taken-down design, which is no-store, since its status could later change via restore, Section 17.11). Rate class: api-read.

16.8.12 GET /designs/{code}/render-urls #

Purpose: enumerate every render URL (all scales and formats) for a design without the client having to construct the render URL scheme itself (Section 8 owns the scheme; this endpoint is a convenience wrapper over it).

Path parameters: code.

Response — data: { "code": string, "renders": [ { "scale": 1|2|3, "format": "webp"|"png", "url": string } ] } — 6 entries (3 scales × 2 formats).

Example request: GET /api/v1/designs/7h3k9m2wq1/render-urls

Example response:

{
  "data": {
    "code": "7h3k9m2wq1",
    "renders": [
      { "scale": 1, "format": "webp", "url": "/render/d/7h3k9m2wq1@1.webp" },
      { "scale": 1, "format": "png", "url": "/render/d/7h3k9m2wq1@1.png" },
      { "scale": 2, "format": "webp", "url": "/render/d/7h3k9m2wq1@2.webp" },
      { "scale": 2, "format": "png", "url": "/render/d/7h3k9m2wq1@2.png" },
      { "scale": 3, "format": "webp", "url": "/render/d/7h3k9m2wq1@3.webp" },
      { "scale": 3, "format": "png", "url": "/render/d/7h3k9m2wq1@3.png" }
    ]
  }
}

Errors: SF-2000 (404), SF-2005 (410, standard error envelope, no renders data) — same rules as 16.8.11. Caching: same as 16.8.11. Rate class: api-read.

16.8.13 GET /designs/random #

Purpose: fetch one random, non-taken-down, at-least-once-rendered-successfully design, backing a "surprise me" homepage feature (Section 10).

Query parameters: body (optional, m|f, filters the pool).

Implementation: TABLESAMPLE SYSTEM over designs filtered to taken_down_at IS NULL, retried up to 3 times if the sample misses (empty result), falling back to ORDER BY random() LIMIT 1 with the same filter if all 3 sampling attempts miss (only relevant when the table is small).

Response — data: DesignResource, same shape as 16.8.11.

Example request: GET /api/v1/designs/random?body=f

Errors: SF-2000 (404) — no designs exist yet matching the filter (only possible immediately after launch with zero designs created, or an implausible body filter — there are only two valid body values so this is otherwise unreachable). Caching: no-store (16.6). Rate class: excluded from rate limiting per 16.7.1.

16.8.14 GET /builds/current #

Purpose: fetch the currently published build's metadata, used by the front end to display a version footer and by third parties to know which build's data they are viewing.

Response — data: BuildResource (16.9).

Example request: GET /api/v1/builds/current

Example response:

{
  "data": {
    "buildId": "01J7Z0N4C2E8QK3T9W1R6X5H4B",
    "label": "2026.08-outlands-patch-114",
    "publishedAt": "2026-08-10T18:00:00.000Z",
    "assetCount": 1842,
    "hueCount": 3106
  }
}

Errors: SF-6012 (409) — no build has ever been published yet; only possible pre-launch, not reachable in normal operation (16.4.2 documents this as the one Import-family code the public API returns, since "is there a build to serve" is a state of the publish pipeline, Section 9, not a not-found condition). Caching: catalog class. Rate class: api-read.

16.8.15 GET /builds #

Purpose: list build history (currently- and formerly-published builds only, i.e. status IN ('published', 'archived', 'rolled_back') — builds still in draft or in_review are admin-only, Section 17.10).

Query parameters: cursor, limit (16.5), sorted by publishedAt descending.

Response — data: array of BuildResource. meta: pagination.

Example request: GET /api/v1/builds?limit=1

Example response:

{
  "data": [ { "buildId": "01J7Z0N4C2E8QK3T9W1R6X5H4B", "label": "2026.08-outlands-patch-114", "publishedAt": "2026-08-10T18:00:00.000Z", "assetCount": 1842, "hueCount": 3106 } ],
  "meta": { "nextCursor": "eyJrIjoicHVibGlzaGVkX2F0IiwidiI6IjIwMjYtMDgtMTAiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjUifQ.wQ8rT2vN6yM0sL4xE1cB9dJ7fP5hZ3k", "count": 1 }
}

Errors: SF-1010 (400) bad cursor. Caching: catalog class. Rate class: api-read.

Purpose: aggregate, non-PII popularity stats — most-used assets and hues over a trailing window, for a "trending" widget (Section 10) and community curiosity. No per-design or per-visitor data is ever exposed here (Section 20's privacy posture).

Query parameters: window (7d | 30d | all, default 7d), kind (asset | hue, default asset).

Response — data: array of { "assetKey"?: string, "hueIndex"?: integer, "useCount": integer }, top 20, computed from page_view_daily/stat_counters aggregates (Section 6, Section 21) — never from raw per-request logs.

Example request: GET /api/v1/stats/popular?window=7d&kind=asset

Example response:

{ "data": [ { "assetKey": "hair.long-wavy", "useCount": 812 }, { "assetKey": "robe.plain", "useCount": 754 } ] }

Errors: SF-1000 (400) for unknown window/kind. Caching: public, max-age=300, s-maxage=900 (16.6). Rate class: api-read.

16.8.17 Health endpoints (referenced, not redefined) #

This section does not define its own health-check endpoint. Liveness and readiness are owned entirely by Section 21.7: GET /health (liveness) and GET /health/ready (readiness, checks database and object storage). Both are root-level paths, not under /api/v1, carry no data/meta success envelope, and are excluded from rate limiting entirely (Section 21.7, Section 16.7.1) — a monitor polling liveness on a tight interval must never itself trip a limit. GET /api/v1/version (16.8.18) remains the one version-and-deprecation endpoint under this namespace.

16.8.18 GET /version #

Purpose: report the running application version and API deprecation status (16.1).

Response — data: { "appVersion": string (git short SHA or release tag), "apiVersion": "v1", "deprecated": boolean, "sunset": string | null }.

Example request: GET /api/v1/version

Example response: { "data": { "appVersion": "2026.09.03-a1b2c3d", "apiVersion": "v1", "deprecated": false, "sunset": null } }

Errors: none. Caching: public, max-age=60. Rate class: api-read.

16.8.19 Image endpoints (referenced, not redefined) #

  • /render/... — composite, per-asset, and slot-swatch image rendering. Full URL scheme, cache-key derivation, and headers are owned by Section 8.
  • /og/d/:code.png — per-design Open Graph image. Full generation trigger and caching are owned by Section 14.

These are not under /api/v1, carry no JSON envelope, and are not versioned by 16.1 — they are content-addressed binary endpoints whose contracts live entirely in their owning sections.

16.9 Response type definitions #

All types below live in core/schema/*.ts as Zod schemas (z.object(...)) with a corresponding inferred TypeScript type exported alongside; the API layer never hand-writes a parallel interface that could drift from the schema actually used to build responses.

// core/schema/resources.ts
import { z } from "zod";

export const SlotResourceSchema = z.object({
  slotKey: z.string(),
  displayName: z.string(),
  zOrder: z.number().int(),
  hueable: z.boolean(),
  genderScope: z.enum(["both", "male", "female"]),
  choosable: z.boolean(),
});
export type SlotResource = z.infer<typeof SlotResourceSchema>;

export const AssetVariantResourceSchema = z.object({
  body: z.enum(["m", "f"]),
  hue: z.number().int().min(0),
  renderUrl: z.string(),
});
export type AssetVariantResource = z.infer<typeof AssetVariantResourceSchema>;

export const AssetResourceSchema = z.object({
  assetKey: z.string(),
  slotKey: z.string(),
  displayName: z.string(),
  genderScope: z.enum(["both", "male", "female"]),
  tags: z.array(z.string()),
  thumbnailUrl: z.string(),
  retiredAt: z.string().datetime().nullable(),
  variants: z.array(AssetVariantResourceSchema).optional(), // present only on the single-resource endpoint (16.8.4)
});
export type AssetResource = z.infer<typeof AssetResourceSchema>;

export const HueResourceSchema = z.object({
  hueIndex: z.number().int().min(0).max(65535),
  name: z.string(),
  source: z.enum(["base", "outlands", "derived"]),
  swatchArgb: z.string().nullable(), // "#RRGGBB", null only for hueIndex 0
});
export type HueResource = z.infer<typeof HueResourceSchema>;

export const DesignResourceSchema = z.object({
  code: z.string().length(10),
  document: z.custom<import("./design.ts").DesignDocument>().optional(), // absent when takenDown
  buildId: z.string().length(26),
  createdAt: z.string().datetime(),
  takenDown: z.boolean(),
});
export type DesignResource = z.infer<typeof DesignResourceSchema>;

export const BuildResourceSchema = z.object({
  buildId: z.string().length(26),
  label: z.string(),
  publishedAt: z.string().datetime(),
  assetCount: z.number().int().min(0),
  hueCount: z.number().int().min(0),
});
export type BuildResource = z.infer<typeof BuildResourceSchema>;

These same schemas parse and validate outbound responses in a development-mode assertion middleware (disabled in production for latency) and are the source for the generated OpenAPI document in 16.10.

16.10 Machine-readable contract #

GET /api/v1/openapi.json serves an OpenAPI 3.1 document generated at build time (not hand-written) from the Zod schemas in 13.2.5 and 16.9, using zod-to-openapi-style schema conversion wired into a small in-repo generator script (scripts/generate-openapi.ts) that:

  1. Imports every route module under routes/api/v1/.
  2. Reads a co-located openapiMeta export from each route (method, path, summary, tags, the request and response Zod schemas, and the documented error codes) — a convention enforced by a lint rule that fails CI if a route file lacks openapiMeta.
  3. Converts each Zod schema to a JSON Schema fragment and assembles the full OpenAPI document.
  4. Writes it to static/openapi.json at build time; the /api/v1/openapi.json route serves that static file with the catalog Cache-Control class (16.6).

Testing (Section 22 owns the full test pyramid): a CI check runs the generator and diffs its output against the committed static/openapi.json, failing the build if they differ — this prevents the document from drifting from the actual route schemas. A separate contract test loads the generated document into an OpenAPI validator and replays a fixture request/response pair per endpoint, asserting both validate against the document.

16.11 Client usage guidance for the community #

SkinForge is an unofficial fan tool (Section 20 owns the full legal posture) and its API is intentionally open for third-party use — Discord bots, other fan sites, or personal scripts. Guidance, stated here as the canonical polite-use expectations referenced by 16.1 and 16.7:

  • Respect rate limits (16.7). They are generous for interactive use and are not intended to block reasonable automation; a script that needs sustained high-volume access should cache catalog data locally rather than polling.
  • Caching advice: catalog data (slots, assets, hues, hue-groups, tags, builds) changes only when a new build publishes, at most a few times a month (Section 9). Third-party consumers should cache these endpoints for at least the s-maxage given in 16.6 and treat ETag/If-None-Match (16.6) as the way to cheaply check for updates rather than re-fetching full payloads.
  • Attribution request: not legally required, but requested — any product or bot built on this API should credit "Outlands SkinForge (unofficial fan tool)" with a link back to the SkinForge site, consistent with the fan-project spirit and the disclaimer wording owned by Section 10.4.
  • No SLA: this is a free community tool with no uptime guarantee or support contract. GET /health and GET /health/ready (Section 21.7) and GET /version (16.8.18) are provided so automated consumers can detect and gracefully handle downtime or version changes.
  • Do not scrape image endpoints to rebuild a local copy of the game's art files. The render endpoints (Section 8) serve derived, hue-composited previews for display purposes; bulk-downloading them to reconstruct source game assets is outside the intended use and outside what Section 20's legal posture covers.

16.12 Backwards-compatibility test requirements #

Every endpoint in 16.8 has a corresponding contract fixture — a stored example request and its exact expected response shape (not exact dynamic values like timestamps or counts, which are matched by type/format instead) — checked into the test suite under tests/contract/api-v1/. Section 22's CI gates require:

  1. Every contract fixture passes against the live route handlers on every CI run.
  2. Any change to a response schema in 16.9 that would fail an existing fixture is treated as a breaking change requiring either (a) the change is reverted, (b) the fixture is deliberately updated with a changelog entry justifying why the change is additive, not breaking (reviewed by a human, not auto-approved), or (c) if genuinely breaking, deferred to a v2 per the versioning policy in 16.1 — v1 route handlers are never modified in a way that breaks an existing fixture.
  3. The OpenAPI-document diff check from 16.10 runs in the same CI job as the contract fixtures, so a schema change and its documentation are verified together.

17. Admin Console #

The admin console is staff-only. There is no public account system anywhere in SkinForge — end users never register, log in, or hold any kind of identity (Section 3, Section 13). Every admin capability described in this section is reachable only after the authentication flow in 17.1–17.2 succeeds, is served from the /admin route tree (Section 10's route list), and is excluded from search indexing per 17.16.

17.1 Access model #

  • Account creation: admin users are created exclusively by CLI (skinforge-cli admin create-user --email <email> --role <owner|curator|viewer>), which prompts for a password, generates a TOTP enrollment secret, and prints a provisioning QR code (as ANSI-art in the terminal, and as a .png written to the operator's working directory) plus 10 one-time recovery codes. There is no admin self-signup route, no public registration form, and no "forgot password" email flow anywhere in the system — admin auth deliberately has no email-based recovery path anywhere in this design; a locked-out admin uses a recovery code or CLI access instead (below).

  • Password reset: there is no password-reset-by-email mechanism. A locked-out admin authenticates with a recovery code (17.2.3) to regain access, then rotates their password and/or TOTP secret from the admin users screen (17.15) or via CLI (skinforge-cli admin reset-credentials --email <email>, which requires shell access to the host running the app — an equivalent trust boundary to SSH access, which is the accepted trust model for this staff-only tool per Section 20's threat model).

  • Password hashing — Argon2id, parameters (fixed, not configurable per-deployment, to keep the security posture uniform and auditable):

    Parameter Value
    Memory cost 19456 KiB (19 MiB)
    Time cost (iterations) 2
    Parallelism 1
    Hash output length 32 bytes
    Salt length 16 bytes, randomly generated per password

    These match the OWASP Password Storage Cheat Sheet's Argon2id baseline recommendation for a single-parallelism deployment target (Section 4's single-VPS-first posture). Stored as a PHC-format string ($argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>) in admin_users.password_hash.

  • TOTP — mandatory, no opt-out. Every admin account requires TOTP from first login; there is no "skip for now." Parameters: RFC 6238, SHA-1 (the near-universal authenticator-app default — chosen for compatibility with Google Authenticator, Authy, 1Password, etc., all of which assume SHA-1 unless told otherwise), 6 digits, 30-second step, verification window of ±1 step (accepts the previous, current, and next 30-second code to tolerate clock drift). The shared secret is a 160-bit (20-byte) random value, base32-encoded for the provisioning URI (otpauth://totp/SkinForge:<email>?secret=<secret>&issuer=<SKINFORGE_ADMIN_TOTP_ISSUER>&algorithm=SHA1&digits=6&period=30, Section 24 owns the SKINFORGE_ADMIN_TOTP_ISSUER env var).

  • Session cookie: name sf_admin, flags HttpOnly; Secure; SameSite=Lax, value is an opaque 128-bit random session token (base64url), never a JWT — the token is a lookup key into the admin_sessions table (Section 6), so a session can be revoked server-side instantly (17.2, 17.15).

  • Server-side sessions: admin_sessions rows carry admin_user_id, token_hash (the cookie value is hashed with SHA-256 before storage, so a database read alone cannot yield a usable session token), created_at, last_seen_at, expires_at, ip_address, user_agent, and revoked_at (nullable — NULL means live; set to the current timestamp by force-logout, 17.15, and never by a hard delete, per Section 6.23's soft-revoke convention).

  • Idle timeout: 30 minutes of inactivity (no authenticated request) invalidates the session. Every authenticated request that succeeds updates last_seen_at and extends the idle window.

  • Absolute timeout: 12 hours from created_at regardless of activity — a long-running admin session is force-expired and must re-authenticate, bounding the blast radius of a stolen cookie. Controlled by SKINFORGE_ADMIN_SESSION_TTL_HOURS (Section 24), default 12; the idle timeout of 30 minutes is fixed in code, not separately configurable, to keep the security posture predictable.

  • IP allowlist (optional control): if the database-backed setting admin_ip_allowlist (stored in app_settings, Section 6; edited in 17.14) is non-empty (a list of CIDR blocks), every /admin/* and /admin/api/* request is checked against it before authentication even runs; a non-matching IP gets a generic 404 (never a 403, to avoid confirming the admin console's existence to an unauthorized network position). Empty allowlist (the default) means no IP restriction — reasonable for a single small-team deployment where TOTP is the primary control, and left off by default so a locked-out staff member changing networks is never a self-inflicted outage.

17.2 Login, TOTP challenge, recovery codes, lockout #

17.2.1 Login flow #

  1. POST /admin/api/auth/login with { email, password }. On success, the server does not yet set sf_admin — it creates a short-lived (5-minute) admin_pending cookie (same flags as sf_admin) referencing a row in admin_pending_mfa (Section 6), and responds { "data": { "mfaRequired": true } }.
  2. POST /admin/api/auth/totp with { code }, authenticated by the admin_pending cookie. On a valid 6-digit code within the ±1-step window, the server deletes the admin_pending_mfa row, creates a real admin_sessions row, sets sf_admin, clears admin_pending, and responds { "data": { "redirectTo": "/admin/dashboard" } }.
  3. A wrong password returns SF-4002 "That email or password isn't correct." (401) — this never reveals whether the email itself is a known account, only that the password/email pair was rejected. A wrong TOTP code returns the registry's distinct SF-4004 "That code isn't correct." (401): revealing that the second factor was wrong carries no account-enumeration risk, because by the time a client can attempt the TOTP step it has already passed the password step (the admin_pending cookie only exists after a correct password) — the same reasoning 17.2.2 applies to a failed recovery code. An expired or missing admin_pending cookie (the TOTP step attempted with no valid pending session) returns SF-4001 "Your session has expired. Please sign in again." (401), directing the client back to step 1. Each failure also writes a row to admin_login_failures (Section 6; 17.2.3 owns how that row feeds lockout).

17.2.2 Recovery code use #

At the TOTP step, POST /admin/api/auth/recovery with { recoveryCode } is an alternate path: each of the 10 codes issued at account creation (17.1) is a 10-character Crockford-Base32 string (same alphabet as Section 13.4.1, reused for consistency, not for any cryptographic relationship to short codes), stored hashed (SHA-256) in admin_recovery_codes, one row per code, each usable exactly once (used_at set on use). An invalid or already-used recovery code returns SF-4007 "That recovery code isn't valid." (401) — distinct from SF-4002 (wrong password) and SF-4004 (wrong TOTP code), since by the time a client can attempt a recovery code it has already passed the password step (the admin_pending cookie only exists after a correct password), so this code carries no account-enumeration risk. Using a recovery code completes login exactly like a valid TOTP code and additionally triggers an audit event (17.2.4) and an in-app banner on the next dashboard load: "You signed in with a recovery code. N codes remain. Rotate your recovery codes soon" — surfaced when remaining unused codes drop to 3 or fewer, prompting a visit to /admin/account (17.3, 17.15) to rotate.

17.2.3 Lockout policy #

  • 5 consecutive failed attempts (counting failed password, failed TOTP, and failed recovery code together — each tallied as a row in admin_login_failures, Section 6, scoped per admin_users.id) within a rolling 15-minute window trigger a 15-minute cooldown: further attempts for that account return SF-3003 "Too many login attempts. Try again in 15 minutes." (429) regardless of whether the credentials supplied are actually correct, until the cooldown elapses. The rolling window is computed as a count of admin_login_failures rows for that admin_user_id with created_at within the last 15 minutes; rows older than the window are irrelevant to the count and are pruned by routine housekeeping (Section 23), not read on the hot path.
  • The failure counter resets to zero on any successful full login (password + second factor) — in practice, a successful login does not need to delete prior admin_login_failures rows, since only rows within the trailing 15-minute window are ever counted.
  • Lockout is per-account, not per-IP — an IP-based limit would let an attacker lock out a legitimate admin from a shared/proxy IP; a per-account limit only affects the targeted account and is paired with the audit trail in 17.2.4 so repeated lockouts are visible to other admins.

17.2.4 Audit events #

Every step writes an audit_log row (Section 6 owns columns; this section fixes the action values emitted here): admin.login.password_ok, admin.login.password_fail, admin.login.totp_ok, admin.login.totp_fail, admin.login.recovery_used, admin.login.locked_out, admin.logout, admin.session.expired_idle, admin.session.expired_absolute. Each row records admin_user_id (nullable — a failed login before the email resolves to a known user still logs with admin_user_id null and the attempted email in metadata), ip_address, user_agent, created_at, and a metadata JSON blob. Viewable in 17.13.

17.3 Navigation map and roles #

Three roles, strictly ordered by privilege: viewer < curator < owner. A role is assigned per admin user at creation (17.1) and changeable only by an owner (17.15).

Route Purpose Minimum role
/admin Login none (pre-auth)
/admin/dashboard Overview tiles (17.4) viewer
/admin/account Own password, TOTP and recovery codes (17.15) viewer
/admin/imports Import activity list, grouped by build (17.5) viewer (start/cancel needs curator)
/admin/imports/:buildId Import detail for one build's stage-progress strip (17.5) viewer (retry/cancel needs curator)
/admin/candidates Candidate review queue (17.6) curator
/admin/assets Assets admin (17.7) viewer (edit/retire needs curator)
/admin/assets/:assetKey Asset detail (17.7) viewer (edit/retire needs curator)
/admin/hues Hues admin (17.8) viewer (edit needs curator)
/admin/slots Slots admin (17.9) viewer (edit needs owner)
/admin/tags Tag management viewer (edit needs curator)
/admin/builds Builds admin (17.10) viewer (publish/rollback needs owner)
/admin/designs Designs admin (17.11) viewer (takedown/restore needs curator)
/admin/takedowns Takedown inbox (17.12) curator (resolve needs owner)
/admin/audit Audit log viewer (17.13) owner
/admin/settings Settings (17.14) owner
/admin/users Admin users (17.15) owner

17.3.1 Permission matrix over every action #

Action viewer curator owner
View any admin screen and its data yes yes yes
Start/cancel an import run no yes yes
Approve/reject/reassign candidates no yes yes
Edit asset metadata (name, tags, thumbnail) no yes yes
Retire/unretire an asset no yes yes
Edit hue metadata, grouping no yes yes
Edit slot ordering/visibility/gender rules no no yes
Publish or roll back a build no no yes
Take down or restore a design no yes yes
Resolve a takedown request (final decision) no no yes
View audit log no no yes
Change settings no no yes
Rotate own password / TOTP / recovery codes (/admin/account) yes yes yes
Create/disable admin users, force-logout, rotate another user's recovery codes no no yes

A request attempting an action above the caller's role returns SF-4005 "Insufficient permissions" (403) from the /admin/api layer (17.17); the corresponding UI never renders the control at all for a role that lacks it (controls are omitted, not disabled-but-visible, to avoid confusing lower-privilege staff with actions they cannot use).

17.4 Dashboard #

/admin/dashboard — six tiles, each independently loaded (skeleton-loading per tile, so one slow query never blocks the others), each with its own data source:

Tile Content Data source
Active build Build label, publish date, asset/hue counts GET /admin/api/builds/currentgame_builds row where status = 'published' order by published_at desc limit 1
Pending candidates Count of extraction_candidates awaiting staff action SELECT count(*) FROM extraction_candidates WHERE status IN ('pending', 'needs_operator_input')
Designs created (7d) Count SELECT count(*) FROM designs WHERE created_at > now() - interval '7 days'
Render cache size Total bytes in object storage under renders/ Storage-driver-reported bucket/prefix size (Section 4's storage interface exposes a sizeOf(prefix) method for both fs and s3 drivers), refreshed at most every 10 minutes via a cached job result (Section 9's job runner), not computed synchronously on every dashboard load.
Failed jobs Count of jobs rows with status = 'failed' in the last 24h SELECT count(*) FROM jobs WHERE status = 'failed' AND created_at > now() - interval '24 hours'
Storage usage Total object storage bytes (all prefixes: catalog/, renders/, og/, imports/, per Section 8.9's canonical layout) Same storage-driver size method, same 10-minute cache

Each tile links through to its detail screen (imports, candidates, designs, or — for cache/storage tiles — the settings screen's maintenance section, 17.14).

17.5 Imports screen and import detail #

17.5.1 Starting a run #

/admin/imports lists past import activity grouped by build — one row per game_builds row (Section 6.4) with a rolled-up stage-progress strip built from that build's import_runs rows (Section 6.6: one row per pipeline-stage execution, not one row per import), newest build first. Each strip shows all eight stages (inventory → identify → probe → extract → classify → normalize → import → verify, Section 6.6's canonical stage enumeration) with the latest row's status per stage, started_at/finished_at, triggered_by (admin user), and a summary of candidates produced. A "New Import" button (role curator+) opens a form with two mutually exclusive source modes:

  • Server path: the operator enters an absolute path on the host running skinforge-cli, which must be run from a terminal with access to that path — the web form only records the path and enqueues a job; the actual file walk happens in the CLI process, not the web process, per Section 4's two-process architecture. This mode is only usable when staff have shell access to the host, which is the expected common case for a self-hosted operator (Section 23).
  • Upload: a .zip upload of the client directory (or a relevant subset), capped at SKINFORGE_IMPORT_MAX_UPLOAD_MB (Section 24), streamed to SKINFORGE_IMPORT_WORKDIR (Section 24), unpacked, then processed identically to the server-path mode from that point on. Chosen as the fallback for operators without shell access to the deployment host.

Either mode creates a new game_builds row (status = 'draft', Section 6.4) and enqueues a jobs row (Section 4's Postgres-backed job runner) that runs the discovery/extraction pipeline (Section 7's eight stages) as a background job. Each stage the job executes creates its own import_runs row scoped to (build_id, stage), with that row's status progressing queuedrunning → one of succeeded, failed, cancelled independently per stage (Section 6's canonical import_runs.status vocabulary) — a build therefore accumulates one row per stage, typically 8–16 rows counting occasional re-runs (Section 6.6), never a single row for the whole import.

17.5.2 Live progress #

/admin/imports/:buildId polls GET /admin/api/imports/:buildId every 2 seconds while any stage's status is queued/running, switching to no further polling once every stage has reached a terminal state. The response includes:

  • stages: the latest import_runs row per stage for this build, each { stage, status, startedAt, finishedAt }, in the pipeline order from Section 6.6.
  • currentStage: the stage value of the most recent non-terminal run for this build, or null if nothing is currently running.
  • stageProgress: { current: number, total: number } — item counts within currentStage (e.g. files inventoried so far / files discovered).
  • log: an array of { ts, level, message } entries for the current stage's import_runs row, append-only, capped to the most recent 500 in the API response (the full log is retained in object storage as a plain-text artifact, per below).

17.5.3 Stage-by-stage logs and artifacts #

Every stage transition writes a structured log line ({ts, level: "info", stage, message}) tied to that stage's import_runs row, and, on completion of the verify stage (the pipeline's last, Section 6.6), the build's artifacts are written to object storage under imports/<build_id>/: the full per-stage logs as log.ndjson, a manifest of every classified file (manifest.json), and per-candidate normalized image sets. The import detail page links each artifact by its storage URL (owner: this screen — object storage layout for imports is not reused by any other section).

17.5.4 Cancel, retry, states #

Each stage's import_runs.status moves through exactly these values (Section 6's canonical vocabulary): queuedrunning → one of succeeded, failed, cancelled.

  • Cancel (role curator+, available while any stage's status is queued or running): sets a cooperative cancellation flag the background job checks between stage steps (never mid-file-decode, to avoid leaving corrupt partial artifacts); the currently-running stage's import_runs row then finalizes with status = 'cancelled', already-completed stages keep their succeeded status unchanged, and whatever candidates the pipeline had already produced remain in extraction_candidates for review — cancellation does not discard partial progress.
  • Retry (role curator+, available when the build's furthest-attempted stage has status = 'failed'): creates a new import_runs row for that stage referencing the same source (path or the previously-uploaded, still-retained archive) rather than mutating the failed row — failed stage rows are kept for diagnosis, never overwritten — and resumes the pipeline forward from there.

Pipeline mechanics (what each stage actually does) are owned by Section 7; this section owns only the screen and the stage-lifecycle states.

17.6 Candidate review queue #

/admin/candidates is the highest-volume staff screen — reviewing hundreds to thousands of extracted sprites after an import run — and is designed for speed above all else.

17.6.1 Grid layout #

A dense grid (configurable 4/6/8 columns, persisted per-admin-user in browser localStorage, never server-side) of candidate thumbnails, each tile showing: the extracted sprite at scale 2, a confidence badge (Section 7's classification confidence score, 0–100%, colour-coded: green ≥85%, amber 50–84%, red <50%), the guessed slot_key and gender, and a small "previous build" thumbnail overlay toggle for side-by-side diffing (17.6.2).

17.6.2 Side-by-side diff #

Hovering (or, on touch, tapping a compare icon) swaps the tile's image with a 50/50 split-view against the same asset_key's art from the previous published build, when one exists (identified by matching asset_key — a candidate whose classification assigns it a pre-existing asset_key is treated as an update to that asset; a candidate with no matching prior key is a wholly new asset). Purely additive new assets show no diff toggle.

17.6.3 Filters #

status (pending default, needs_operator_input, approved, rejected — Section 6's canonical extraction_candidates.status vocabulary), slotKey, confidenceMin/confidenceMax, isNewAsset (boolean), importRunId. Filters combine as AND and are reflected in the URL query string so a filtered view is bookmarkable/shareable among staff.

17.6.4 Bulk actions #

Multi-select via click+shift-click range selection or "select all matching filter" (capped at 500 per bulk action to keep the confirming operation bounded). Bulk actions: Approve selected, Reject selected, Assign slot (opens a slot picker, applies to all selected), Assign gender (male, female, both), Tag (opens a multi-tag picker, adds tags to all selected). Every bulk action requires a confirmation modal stating the count and action, and writes one audit_log row per affected candidate (not one row for the whole batch), so individual reversals/investigations remain precise.

17.6.5 Per-candidate actions #

Renaming (asset_key and display name — renaming a candidate that will become a new asset only; renaming an already-published asset happens in 17.7, not here), slot reassignment (dropdown, filtered to Section 3's slot registry), gender assignment, individual approve/reject.

17.6.6 Keyboard-driven review flow #

The queue supports full keyboard operation once a tile has focus (arrow keys move focus through the grid in reading order):

Key Action
Arrow keys Move focus between tiles
A Approve focused candidate, advance focus to next
R Reject focused candidate, advance focus to next
Space Toggle selection of focused candidate (for a subsequent bulk action)
Shift+Arrow Extend selection range from the last focused tile
D Toggle diff view on focused candidate
19, 0 Quick-assign one of the 10 most-recently-used slot keys to the focused candidate (a most-recently-used list shown in a persistent legend at the top of the grid)
G then M/F/B Assign gender male/female/both to the focused candidate (two-key chord, mirroring the slot quick-assign pattern)
Enter Open the full-size single-candidate detail panel
Esc Close the detail panel / clear selection

Approvals and rejections issued via keyboard are optimistic in the UI (the tile updates immediately, Section 17.16 owns the general optimistic-update rule) with the network request firing asynchronously; a failed request reverts the tile and shows a toast error, never a blocking dialog, so the reviewer's keyboard flow is never interrupted by a transient network hiccup.

Approving a candidate creates (or updates, for a matched asset_key) the corresponding assets / asset_variants / asset_images rows (Section 6) in draft state within the current in-progress build (Section 9); it does not publish anything — publishing is a separate, explicit action in 17.10.

17.7 Assets admin #

/admin/assets — search (by asset_key or display name, trigram-backed like Section 16.8.9's public search), filters (slotKey, tag, retired tri-state: active/retired/all).

/admin/assets/:assetKey detail view, editable fields (role curator+): display name, tags (multi-select against the tag catalog, with inline "create new tag" for curator+), canonical thumbnail (a picker among the asset's own rendered variants — Section 8's render URLs — defaulting to the (body="m", hue=0) variant if unset). Retire/unretire toggle: retiring sets retired_at (does not touch deleted_at, per the soft-delete convention); a retired asset immediately stops appearing in the public catalog and designer picker (Section 10, Section 11) but keeps rendering for any existing design that references it (Section 13.7). The detail view also shows, read-only: full provenance (the import_run_id, source_files offsets, and extraction metadata that produced this asset, per Section 7), and every render (design_renders / asset_images, Section 6) ever derived from it, as a thumbnail grid with generation timestamps — useful for spotting a bad extraction that produced visibly wrong output across many renders.

17.8 Hues admin #

/admin/hues — a table of every hue with columns hueIndex, name, source (base/outlands/ derived), swatch preview, and group memberships.

  • Import results: after an import run classifies hue data (Section 7's D4/D6 stages), newly discovered hues appear here with source pre-set from the discovery heuristic (an index within the documented base 1–3000 range defaults to base; anything outside it, or matching a known Outlands custom-hue signature, defaults to outlands) and a needs_naming flag if no name could be derived from source data.
  • Naming: inline edit of name, required before a hue can be included in a publish (17.10) — an unnamed hue blocks publish with a clear inline count ("14 hues need naming before this build can publish").
  • Grouping, including curating skin: a many-to-many hue_group_members editor (Section 6); staff add/remove hues from the skin group here, which is what makes them selectable as skinHue values in the public designer (Section 11) and searchable via GET /hues?group=skin (Section 16.8.5). Groups beyond skin can be created freely (e.g. thematic groups like "metallics") for catalog browsing (Section 15) without special-casing in code — groups are pure data.
  • Swatch override: the swatch preview is normally computed from the hue table itself (Section 8's hue math, taking a representative mid-ramp colour); an override lets staff pin a specific ARGB value when the computed representative colour is misleading (rare, mostly for gradient hues), stored in hues.swatch_override_argb, nullable.
  • Marking custom Outlands hues: the source = 'outlands' value itself is the marker (Section 3); this screen is where staff correct a hue's source when the automatic discovery heuristic guessed wrong.
  • Bulk edit: multi-select rows, apply a group add/remove or a source correction to all selected, same confirmation-modal and per-row-audit-log pattern as candidate bulk actions (17.6.4).

17.9 Slots admin #

/admin/slots — the 19-row slot registry (Section 3) is seeded by migration (Section 6) and is editable only by owner, covering: display name, zOrder, gender rule, and a visible toggle (a slot hidden from the public designer/catalog while still valid in stored historical designs — used if a slot type needs to be temporarily pulled, e.g. discovering a whole category of art is misclassified).

Blast-radius warning: changing zOrder on any slot changes the render composite order for every existing design that includes that slot, the moment it takes effect, because rendering always composites in current z-order (Section 8.4) rather than a per-design snapshot of z-order — z-order is part of the rendering algorithm, not part of the immutable design document (Section 13.2 deliberately excludes z-order from what is stored per design; only the set of slot/asset/hue choices is stored). The edit form therefore requires typing the slot's slotKey to confirm (a "type to confirm" pattern, not just an OK button) and shows a live count of how many published designs include that slot, computed before the confirmation is accepted. Section 8 owns the rendering algorithm this affects; this screen only owns the admin UI and warning.

17.10 Builds admin #

/admin/builds — list with status (Section 6's canonical game_builds.status vocabulary: draft, in_review, published, archived, rolled_back), label, counts, and publish/created timestamps.

  • Diff view: /admin/builds/:id/diff compares a draft build's asset/hue set against the currently published build: added, changed (same asset_key, different image data — detected by comparing content hashes, Section 7's D6 stage), retired-in-this-build, and unchanged counts, each expandable to a thumbnail grid.
  • Publish (role owner): requires passing the naming-completeness check (17.8) and a confirmation summary dialog showing: number of new assets, number of changed assets, number of retirements, number of new hues, and the exact label and timestamp that will become published_at. Publishing is transactional: it flips the build's status to published, flips the previously-published build (if any) to archived — ordinary supersession by a newer build — and is the trigger that changes catalog ETags (Section 16.6) — implemented as a single database transaction that updates game_builds.status and writes an audit_log row (action = 'build.publish') together, so the two are never observed inconsistently.
  • Rollback (role owner): re-publishes a previously-published, now-archived build as current, through the same transactional path as publish (it is implemented as "publish this older build," not a distinct code path), and triggers the same cache invalidation described next. The build being rolled back away from (the one currently published at the moment rollback is invoked) transitions to rolled_back rather than archived, so the audit trail and build list distinguish an explicit rollback from ordinary supersession. Rollback does not delete or alter build rows — every build remains in history and is itself re-rollback-able (re-publishable) regardless of its current status.
  • Cache invalidation on publish/rollback: per Section 19's CDN mechanics, publishing enqueues a job that purges the catalog-endpoint cache tier and, if SKINFORGE_CDN_PURGE_URL/_TOKEN (Section 24) are configured, calls the CDN purge API for the catalog JSON paths (never for /render/ content, which is content-addressed and immutable by construction, per Section 8, and therefore never needs purging).

17.11 Designs admin #

/admin/designs — lookup by short code (exact match) or by pasting a full /d/:code URL (the form strips the path). No free-text search over design contents, since designs carry no free text (Section 13.9.1) — lookup is by code only.

  • View: renders the same preview as the public design page (Section 13.8) plus admin-only metadata: buildId, viewCount, creation timestamp, and — if applicable — takedown status and history.
  • Takedown (role curator+): sets designs.taken_down_at (Section 6) to the current timestamp, immediately making the public page and API return 410 (Section 13.11, Section 16.8.11). Requires a mandatory reason, free text, 10–500 characters, recorded in the audit log (action = 'design.takedown', metadata.reason). The design row and its rendered image artifacts are not deleted — a takedown is a visibility change, not a data-destruction event; the row and its history persist exactly as they do for any other design, since designs are never auto-deleted (Section 13.9.3).
  • Restore (role curator+): clears taken_down_at (sets it back to NULL), also requires a mandatory reason, also audited (action = 'design.restore'). The full legal process governing when a takedown is warranted, and the relationship to a takedown_requests inbox entry, is owned by Section 20; this screen is the mechanical control that a decision under that process is executed through.

17.12 Takedown requests inbox #

/admin/takedowns — intake and triage for external takedown requests (e.g. a rights-holder or a concerned party emailing SKINFORGE_CONTACT_EMAIL, Section 24).

  • Intake form fields (entered by staff on the requester's behalf, since there is no public submission form — matching the no-accounts, no-public-write-surface posture): requester name, requester email, designCode (the specific permalink, if applicable — a request can also target a general asset rather than one design), description (free text, required), evidence links (array of URLs, optional), received-at timestamp.
  • States (Section 6's canonical takedown_requests.status vocabulary — four terminal-or-transit values, no separate "closed" state): newtriaging → one of upheld (takedown applied via 17.11's mechanism, request linked to the resulting audit entry), rejected (with a mandatory internal-only reason), or withdrawn (the requester rescinds the request before triage completes, recorded with the same mandatory-reason field, e.g. "requester withdrew via email").
  • SLA: a new request must move to triaging within 2 business days and reach a final state (upheld/rejected/withdrawn) within 10 business days; the inbox surfaces overdue requests with a red badge computed client-side from received_at against these thresholds (no automated escalation emails in v1 — the badge is the entire mechanism, reviewed manually by an owner who checks the inbox as part of routine operations, per Section 23's operational runbooks).
  • Response template: a canned, editable-before-send email body (sent manually by staff through their own email client — SkinForge sends no outbound email itself, matching the no-third-party-service, minimal-infrastructure posture) with placeholders for requester name and outcome, stored as a single string in the database-backed settings (17.14) so it can be tuned without a deploy.

17.13 Audit log viewer #

/admin/audit (role owner only — the audit trail itself is treated as more sensitive than the data it describes, since it can reveal staff behaviour patterns).

  • Filters: action (exact or prefix match, e.g. design.*), adminUserId, date range, free-text search over metadata (Postgres jsonb containment/text search, Section 6).
  • Retention: audit log rows are retained indefinitely — never pruned, unlike the regenerable render/OG-image artifacts that Section 13.9.3 prunes after 90 days of no traffic — because the audit log is the accountability record for every privileged action in the system and its evidentiary value does not decay.
  • Export: a "download CSV" action (role owner) streams the currently filtered result set as CSV, capped at 100,000 rows per export (pagination via repeated exports with a narrower date range for larger needs) — implemented as a streaming response so large exports do not buffer the full result set in memory.
  • Immutability guarantee: audit log rows are insert-only at the database level — no admin API route anywhere issues an UPDATE or DELETE against audit_log, and this is enforced defense-in-depth by a Postgres REVOKE UPDATE, DELETE ON audit_log FROM skinforge_app grant (Section 6), so even a bug in application code cannot alter or remove an existing entry — only the database owner role, used solely for migrations, has that privilege.

17.14 Settings #

/admin/settings (role owner) — a single screen listing every configurable behaviour, split into two groups:

  • Database-backed settings, all stored as rows in the app_settings table (Section 6) — a key-value store of operator-tunable content, editable here, taking effect immediately with no deploy required: the takedown response template (17.12), admin_ip_allowlist (17.1), the donation link display text (the URL itself is environment-only, below), the "designs like this" strip's minimum shared-slot threshold (Section 13.8, default 2), and the homepage's featured/curated design list (an ordered array of short codes staff can pin, if any — an empty list means the homepage shows only the random-design feature from Section 16.8.13, which is the default and requires no curation to operate).
  • Environment-only settings (displayed read-only here for operational visibility, sourced from the running process's environment — the screen never accepts edits to these, since changing them requires a redeploy, and their canonical names/types/defaults are owned entirely by Section 24): every SKINFORGE_* variable, masked for secret-shaped ones (*_SECRET, *_TOKEN, *_KEY, matching the redaction rule applied throughout this system) and shown as their literal value otherwise.
  • Maintenance mode toggle: writes the database-backed maintenance_mode setting (distinct from, and layered on top of, the environment-only SKINFORGE_MAINTENANCE_MODE kill-switch in Section 24 — the env var is the "hard off" used during deploys/migrations and requires a restart to change; this database toggle is the "soft" version an owner can flip instantly from the UI for planned pauses, and the middleware checks both, treating either as true as sufficient to show the maintenance page to public visitors). Toggling is audited (action = 'settings.maintenance_mode').
  • Donation link: the display text/label editable here; the destination URL is SKINFORGE_DONATION_URL (Section 24, environment-only, since it is operationally a "who receives the money" concern that should require the same trust level as a deploy, not a UI toggle).

17.15 Admin users screen #

/admin/users (role owner) — table of every admin_users row: email, role, created_at, last_seen_at (derived from the most recent admin_sessions.last_seen_at across that user's sessions), enabled/disabled status.

  • Create: this screen does not create users directly (17.1's CLI-only rule); instead it shows the exact CLI command to run, pre-filled with a suggested role, for staff to copy and execute on the host — a deliberate friction point that keeps account creation tied to host access.
  • Disable (role owner): sets admin_users.disabled_at, immediately invalidating every active session for that user (17.15's force-logout mechanism, below, runs automatically as part of disable) and rejecting future login attempts at the password step with SF-4002 (the same wrong-password message a disabled account would get if its password were merely incorrect — a disabled account never gets a distinguishing error, to avoid confirming account existence/status to someone who should not have access).
  • Force-logout: sets revoked_at = now() on every non-revoked admin_sessions row for the target user immediately, independent of disabling the account (usable when a session is merely suspected compromised but the account itself should stay active) — audited as action = 'admin_user.force_logout'. Revoked rows are never deleted (Section 6.23's soft-revoke convention); they are pruned by routine housekeeping 30 days after revocation (Section 6.33), which preserves the session audit trail in the meantime.
  • Rotate recovery codes (role owner, for any user, from this screen; or any role, for their own codes only, from /admin/account, 17.3): invalidates all 10 existing recovery codes and generates 10 new ones, displayed exactly once at generation time (never retrievable again after navigating away — matching standard recovery-code handling practice), audited as action = 'admin_user.rotate_recovery_codes'.

17.16 Admin UX rules #

  • Destructive-action confirmations: any action that is hard to reverse (retiring many assets in bulk, publishing a build, taking down a design, disabling an admin user, deleting nothing — since hard deletes barely exist per the soft-delete convention) requires a confirmation modal naming the exact consequence in one sentence and, for the highest-blast-radius actions (build publish, slot z-order change, admin user disable), a type-to-confirm field matching a specific identifier (build label, slot key, or user email respectively) rather than a bare OK button.
  • Optimistic vs. pessimistic updates: single-item toggles with cheap, easily-reversible effects (candidate approve/reject via keyboard, 17.6.6; asset retire/unretire toggle, 17.7) are optimistic — the UI updates immediately, the request fires async, a failure reverts with a toast. Actions that are expensive, irreversible, or affect many rows at once (bulk candidate actions, 17.6.4; build publish/rollback, 17.10; takedown/restore, 17.11) are pessimistic — the UI shows a loading state and waits for the server's confirmation before reflecting the change, because a false-positive optimistic update on an irreversible action is worse than a moment's latency.
  • Table density: every list screen (imports, candidates, assets, hues, designs, audit) defaults to a compact row height (32px) with an optional "comfortable" density toggle (44px row height), persisted per-admin-user in browser localStorage — a staff preference, not a system setting, so it is not part of 17.14.
  • noindex and sitemap exclusion: every /admin/* response carries X-Robots-Tag: noindex, nofollow, and the entire /admin path prefix is disallowed in /robots.txt (Section 10) and never appears in /sitemap.xml (Section 10). This is defense-in-depth alongside the auth requirement itself — the admin console must never be discoverable via search engines even before considering that it also requires credentials.

17.17 Admin API surface #

Admin actions go through a separate /admin/api namespace, distinct from the public /api/v1 namespace in Section 16 — never mixed into it, so the public API's open-CORS, no-auth, no-CSRF posture (Section 16.2) never accidentally applies to a privileged action.

  • CSRF protection: /admin/api is cookie-authenticated (sf_admin, 17.1), which makes it a CSRF target unlike the public API (which reads no cookies). Every state-changing /admin/api request (POST/PUT/PATCH/DELETE) requires a CSRF token. Mechanism: the signed double-submit cookie owned by Section 20.5 — cookie sf_csrf (HttpOnly: false; Secure; SameSite=Lax), header X-SF-CSRF, value base64url(HMAC-SHA256(sessionTokenHash, SKINFORGE_ADMIN_SESSION_SECRET)), compared in constant time by middleware ahead of every mutating /admin/api route. A mismatch or missing header returns SF-4003 "Your session looks out of date. Please refresh and try again." (403), and the request is rejected before any handler logic runs. GET/HEAD requests to /admin/api are exempt (they are not state-changing and CSRF only protects state-changing requests). The cookie is reissued on every login and cleared on logout, alongside sf_admin. This section owns only the fact that /admin/api requires CSRF protection; Section 20.5 owns the mechanism's exact shape, so it is specified in exactly one place.
  • Every /admin/api response uses the same success/error envelopes as the public API (Section 16.3, 16.4), including SF- error codes, so admin-console frontend code reuses the same client-side response handling as the public site's islands — the only differences from Section 16 are the auth requirement, the CSRF requirement, and the absence of open CORS (/admin/api sends no Access-Control-Allow-Origin header at all, relying on same-origin requests only, since the admin console is never embedded or called cross-origin).

18. Design System, Visual Language & Accessibility #

18.1 Design principles for a pixel-art fan tool #

  • The art is the hero. Sprites are small, high-detail pixel art. UI chrome around them uses flat colour, no gradients, no drop shadows heavier than a 1px separation line, so nothing competes with the composited character for visual attention.
  • Chrome recedes. Panels, rails and controls use low-contrast surface tones; only interactive affordances (buttons, active states, focus rings) use saturated accent colour. Borders are thin (1px) and low-contrast except where they carry meaning (selection, warning, error).
  • No visual noise near the preview. The preview stage (Section 12) has no background texture, no decorative imagery, and a flat single-tone backdrop so pixel-art edges stay crisp and colour judgements (hue picking) are not biased by a busy surrounding.
  • Dark-first with a light theme. The product is designed dark-first because pixel art historically reads best against a dark backdrop and most reference screenshots of the game itself are dark-UI. A fully equivalent light theme is provided (18.2) for users who prefer it or whose system preference requests it; neither theme is "secondary" in implementation effort or contrast compliance.

18.2 Colour tokens #

All tokens are defined once as CSS custom properties on :root (dark, default) and overridden under :root[data-theme="light"] (18.10 owns the selection mechanism). Values are sRGB hex.

Token Dark value Light value Usage
--sf-surface #14161c #f7f7f9 page background
--sf-surface-raised #1c1f27 #ffffff cards, panels, rails
--sf-border #4a5060 #9aa0ae hairline separators
--sf-text-primary #f1f2f5 #15171c body text, headings
--sf-text-muted #9aa0ae #5b6070 secondary text, placeholders
--sf-accent #3f5fd8 #3355dd primary actions, active/selected state, white-on-accent surfaces (the Share button)
--sf-accent-hover #5476ea #2846c2 hover/active state of accent elements
--sf-accent-tint color-mix(in srgb, var(--sf-accent) 12%, transparent) same formula selected-state tints (rail, chips) — an alpha derivation, not a separate hex token
--sf-success #4ade80 #1a8a4a success toast, valid states
--sf-warning #fbbf24 #a15c00 gender-incompatible badge, non-blocking warnings
--sf-danger #f87171 #c92a2a destructive actions, error states
--sf-focus-ring #3f5fd8 #3355dd focus outline colour, same as accent per theme, rendered with a 2px offset (18.5) so the visible ring always sits against surface, never directly over an accent-filled element

Contrast ratios (WCAG 2.2 AA; body text minimum 4.5:1, large text/UI boundaries and non-text UI components minimum 3:1), recomputed honestly against the token each is drawn on:

Pairing Dark ratio Light ratio Requirement Pass
text-primary on surface 16.2:1 16.8:1 4.5:1 yes
text-primary on surface-raised 14.7:1 17.9:1 4.5:1 yes
text-muted on surface 6.9:1 5.9:1 4.5:1 yes
text-muted on surface-raised 6.3:1 6.3:1 4.5:1 yes
accent (as icon/large UI element only — never body-sized text, see note) on surface 3.3:1 5.6:1 3:1 yes
white text on accent (button label) 5.5:1 6.0:1 4.5:1 yes
border on surface (decorative panel hairline — see note) 2.3:1 2.5:1 3:1 no
focus-ring on surface 3.3:1 5.6:1 3:1 yes
warning (as icon) on surface 10.8:1 4.9:1 3:1 yes
danger (as icon) on surface 6.5:1 5.1:1 3:1 yes

Notes on the two rows above that are not simple passes:

  • accent as text/icon: accent is never set as the colour of body-sized running text anywhere in this document — only as a button/chip fill, a focus ring, a selection border, or an icon at 20px+ (Section 18.6). Its applicable bar is therefore the 3:1 large-scale/UI-component threshold, which both themes clear.
  • border on surface fails 3:1 in both themes at the exact values fixed above. This token draws only the hairline edge of a surface-raised panel (18.4) — a decorative separator, not the sole means of identifying a UI component or its state (WCAG 1.4.11 governs "graphical objects required to understand content" and UI component boundaries; a panel that remains fully legible and operable without its hairline is not one). Every control whose boundary IS required to understand its state — the rail's selected item, swatches, buttons, form inputs, the focus ring — uses the accent or focus-ring token instead, both verified ≥3:1 above. border is not used as a substitute for those states anywhere in this document.

success/warning/danger are never used as the ONLY signal (icon shape and text label always accompany colour, satisfying 1.4.1 Use of Color alongside the 18.8 conformance table).

18.3 Typography #

Font stack: system-ui stack for all UI and body text — -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif — chosen over a webfont to keep the interactive designer's first paint free of font-loading layout shift, which matters directly for the interaction-to-paint budget in Section 11.15.

Bundled display face: "Pixelify Sans" (open-licence, self-hosted as a WOFF2 subset), used exclusively inside generated Open Graph images (Section 14) for the heading/wordmark, never in the live UI — this keeps the OG image visually distinctive and on-brand for a pixel-art tool while the live app stays on fast system fonts.

Type scale (rem, 1rem = 16px base):

Role Size Line height Weight
Display (page hero, rare) 2.25rem 1.2 700
H1 1.75rem 1.25 700
H2 1.375rem 1.3 600
H3 1.125rem 1.35 600
Body 1rem 1.5 400
Body small 0.875rem 1.5 400
Caption/label 0.75rem 1.4 500

Rule: pixel-art thumbnails never sit on a textured or gradient background. Every thumbnail container uses a flat --sf-surface-raised fill so nearest-neighbour-scaled sprite edges stay legible; this applies uniformly to the catalog grid (Section 15.3), the designer option panel (Section 11.6), and the preview stage backdrop (Section 12).

18.4 Spacing, radii, elevation, base grid #

4px base grid. All spacing, sizing, and radii are multiples of 4px, expressed as a Tailwind 4.x @theme block:

/* web/styles/theme.css */
@theme {
  --spacing-0_5: 0.125rem;   /* 2px, hairline nudge only */
  --spacing-1: 0.25rem;      /* 4px */
  --spacing-2: 0.5rem;       /* 8px */
  --spacing-3: 0.75rem;      /* 12px */
  --spacing-4: 1rem;         /* 16px */
  --spacing-6: 1.5rem;       /* 24px */
  --spacing-8: 2rem;         /* 32px */
  --spacing-12: 3rem;        /* 48px */
  --spacing-16: 4rem;        /* 64px */

  --radius-sm: 0.25rem;      /* 4px, chips, inputs */
  --radius-md: 0.5rem;       /* 8px, cards, buttons */
  --radius-lg: 0.75rem;      /* 12px, panels, modals */
  --radius-full: 9999px;     /* pills, swatches, avatars */

  --color-surface: var(--sf-surface);
  --color-surface-raised: var(--sf-surface-raised);
  --color-border: var(--sf-border);
  --color-text-primary: var(--sf-text-primary);
  --color-text-muted: var(--sf-text-muted);
  --color-accent: var(--sf-accent);
  --color-accent-hover: var(--sf-accent-hover);
  --color-accent-tint: var(--sf-accent-tint);
  --color-success: var(--sf-success);
  --color-warning: var(--sf-warning);
  --color-danger: var(--sf-danger);
  --color-focus-ring: var(--sf-focus-ring);
}

Elevation is expressed as border + background-tone changes, not box-shadow, to keep the flat, low-noise principle from 18.1: surface-raised panels have a 1px solid var(--color-border) outline and no shadow at rest; the only shadow in the system is a small 0 4px 12px rgba(0,0,0,0.25) used exclusively on the mobile bottom sheet (Section 11.12) and dropdown/tooltip popovers, to separate a floating layer from page content behind it.

Z-index scale, fixed to four layers to keep stacking predictable across the designer, catalog and admin surfaces:

Layer z-index Contents
Base 0 page content, cards, rail, panels
Sticky 10 sticky filter bars, sticky catalog headers
Overlay 100 tooltips, dropdown popovers
Modal 1000 bottom sheet, modal, toast container

18.5 Component library #

For each component: anatomy, states, class/API surface, accessibility requirements.

Button (web/components/ui/button.tsx)

  • Variants: primary (accent fill, white text — Share, Randomize), secondary (bordered, transparent fill — Reset, Cancel), ghost (no border, text-only — inline actions), danger (danger-fill, admin destructive actions only, Section 17).
  • Sizes: sm (32px height), md (40px height, default), lg (48px height).
  • States: default, hover, active/pressed, focus-visible (2px focus-ring outline, 2px offset — the offset is load-bearing on the primary variant: it keeps the ring drawn against surface, never directly on top of the button's own accent fill, so ring and fill are always visually distinct even though both derive from the same token), disabled (reduced opacity 0.5, aria-disabled="true", pointer-events none), loading (spinner replaces label, aria-busy="true", label retained in an aria-live region for announcement).
  • API: { variant, size, disabled?, loading?, onClick, children }. Accessibility: renders a native <button>; icon-only buttons require aria-label (18.6).

Icon button

  • Square, sized to match Button heights (32/40/48px), single icon, no visible label.
  • States: same as Button. Accessibility: aria-label is mandatory (enforced by a TypeScript required prop, not optional), never satisfied by title alone.

Chip/swatch

  • Chip: pill shape (radius-full), used for tags, filter selections, "New"/"Retired" badges. States: default, selected (accent border + --color-accent-tint fill, 18.2/18.4), removable (trailing "x", aria-label="Remove <label>").
  • Swatch: fixed-size square (24px in pickers, 32px in the hue browser grid), radius-sm, filled with the hue's computed sRGB colour, 2px accent ring when selected. Accessibility: role="button", aria-pressed reflecting selection, aria-label = hue name and index.

Card

  • Used for catalog AssetCard (15.3) and catalog overview slot cards (15.2). Anatomy: thumbnail region (flat surface background per 18.3), title, optional badges/chips, optional metadata line. Entire card is a single <a> with the visible content inside, never a <div onClick>, so it is keyboard and screen-reader navigable natively.

Tabs

  • Used for hue group tabs (11.6, 15.5) and any future grouped content. role="tablist" on the container, role="tab" with aria-selected on each item, role="tabpanel" with aria-labelledby on the content region. Keyboard: ArrowLeft/ArrowRight moves selection, Home/End jump to first/last tab, matching the standard ARIA tabs pattern.

Segmented control

  • Used for body filter (15.3), sort control (11.6, 15.3). 2–4 options, single-select, styled as a connected pill group. role="radiogroup" on the container, role="radio" with aria-checked on each segment. Keyboard: arrow keys move and select (single-select group, not tab-then-activate).

Select

  • Native <select> element wherever the option set is short and unstyled behaviour is acceptable (used nowhere in the designer, which prefers segmented controls and grids for its short, meaningful choice sets); reserved for admin console table page-size and sort dropdowns (Section 17). Always paired with a visible <label>.

Text input with search affordance

  • Used for AssetSearchBar (11.6), hue search (11.6, 15.5), catalog search (15.6). Leading magnifying-glass icon (decorative, aria-hidden="true"), trailing clear ("x") button shown only when non-empty, aria-label="Clear search". The input itself always has a visible or aria-label-equivalent label — placeholder text alone never substitutes for a label, per 18.8's 4.1.2 row.

Tooltip

  • Used for rail item labels at lg+ (11.5), disabled-state explanations. Appears on hover AND focus (never hover-only), dismissed on Escape, pointerleave, or blur. Implemented as a positioned popover using the native popover attribute where supported, associated to its trigger via aria-describedby (not aria-label, since a tooltip supplements rather than replaces the trigger's own accessible name).

Toast

  • Used for Share success/error (11.9), copy-hue-number (15.5), copy-asset-key (15.4). role="status", aria-live="polite" container fixed at the bottom of the viewport (bottom-center on basesm, bottom-right on md+), auto-dismisses after 4 seconds unless it contains an interactive element (the clipboard fallback input, 11.9), in which case it persists until manually dismissed via a close button.

Modal

  • Reserved for the admin console (Section 17); not used anywhere in the public designer. role="dialog", aria-modal="true", labelled via aria-labelledby pointing at its heading, focus-trapped (Section 11.11's trapping mechanism, shared code in web/components/ui/use-focus-trap.ts), closed on Escape and on scrim click, focus restored to the trigger on close.

Bottom sheet

  • Mobile option panel container (Section 11.12). role="dialog", aria-modal="true" while open, same focus-trap hook as Modal, resize handle is a <button> with aria-label="Resize sheet" and aria-description="Press Enter to toggle sheet height, Escape to close" (Section 11.14) — dragging the handle is a pointer-only enhancement over the same Enter-toggled heights, never the only way to resize (WCAG 2.2's 2.5.7 Dragging Movements).

Skeleton

  • Flat surface-raised rectangles with a subtle shimmer animation (18.7 governs the animation, including its reduced-motion fallback), used for asset grid loading (11.6, 15.3) and load-more states (15.8). aria-hidden="true" on the skeleton nodes themselves; the loading state is announced separately via the aria-live region already described for that context (15.8).

Empty state

  • Icon or illustration (flat, single-colour, matching text-muted), heading, short body text, optional action button. Used throughout (11.13, 11.14 empty option panel copy, 15.7).

Banner

  • Full-width, dismissible or persistent, coloured by intent (accent for informational — e.g. remix hint 11.10; warning for the retired-asset/dropped-field notices, 11.13). Dismissible banners store dismissal in-memory only (component state), never in storage, so they reappear on next page load — a "no persistent tracking of UI dismissals" stance. The one exception is the fan-project disclaimer banner (Section 10.4), which is not dismissible at all and therefore has no dismissal state to store. role="status" for informational, role="alert" for the ones surfacing dropped/invalid input (11.13), since the latter warrants immediate announcement.

Pagination control

  • The "Load more" button (15.8) and its accessible live-region announcement. No numbered page-link pagination exists anywhere in the product, keeping one pagination mental model everywhere (cursor-based, Section 16).

18.6 Iconography #

Source: Lucide (open-licence, ISC, lucide-preact package), used exclusively — no mixing icon sets, no custom SVG icons outside Lucide's set except the SkinForge wordmark/logo itself (a bespoke static SVG, not part of the icon system).

Sizes: 16px (inline with body-small text, e.g. chip icons), 20px (default UI icon size, buttons and rail items at basemd), 24px (rail items at lg+, section headings).

Rule against icon-only controls without accessible names: every icon-only interactive element (IconButton, swatch, chip remove control) MUST carry an explicit aria-label prop; this is enforced at the TypeScript level (the prop is required, not optional, on every icon-only component in web/components/ui/) and checked by an automated accessibility test in Section 22.6's test suite that fails the build if an icon-only interactive element is found in the rendered DOM without an accessible name.

Icon usage table (Lucide names, mapped to their exact UI role):

Icon Used for
shirt torso_inner / torso_middle rail items
footprints footwear rail item
scissors hair rail item
smile face rail item
gem earrings rail item
crown head rail item
backpack backpack rail item
shuffle Randomize button
undo-2 Undo control
redo-2 Redo control
rotate-ccw Reset button
share-2 Share button
copy Copy Asset Key / Copy Hue Number actions
search search input leading icon
x clear-search, chip remove, dialog close
chevron-down select affordance, expandable filter groups
sliders-horizontal mobile "Filters" toggle (Section 15.3)
info retired-asset info badge (Section 11.13)
triangle-alert gender-incompatible warning badge (Section 11.13)
check selected state confirmation inside swatches/chips where a checkmark supplements the ring
zoom-in / zoom-out keyboard-accessible preview zoom controls (Section 18.8's 2.1.1 row)

Every icon above is imported individually from lucide-preact (tree-shaken, never the full icon barrel) at its component's declared size from the three sizes above.

18.7 Motion #

Element Duration Easing
Hover/focus state changes (colour, border) 120ms ease-out
Toast enter/exit 200ms ease-out (enter), ease-in (exit)
Bottom sheet slide 250ms cubic-bezier(0.32, 0.72, 0, 1) (standard "sheet" easing)
Modal fade/scale 180ms ease-out
Tooltip fade 100ms ease-out
Skeleton shimmer 1400ms loop linear
Live composite canvas redraw 0ms (instant) n/a — the canvas itself never animates a transition; only its CSS pinch-zoom transform (11.12) animates, at 150ms ease-out when snapping back

What animates: only opacity, transform, and background-color/border-color properties — never width/height/top/left, keeping every transition compositor-friendly.

prefers-reduced-motion behaviour: a single global CSS rule disables all of the above:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

Under reduced motion, state changes (toast, modal, bottom sheet) still occur but appear instantly rather than animating; no functionality is lost, only the transition. The skeleton shimmer becomes a static flat-tone placeholder instead of a looping animation.

18.8 Accessibility standard #

WCAG 2.2 Level AA is the committed target for the entire public application and the admin console.

Conformance table for the criteria most at risk in this product:

Criterion Risk area How met
1.4.3 Contrast (Minimum) Small text on tinted panels Every token pairing verified in 18.2's table, both themes, ≥4.5:1 body / ≥3:1 large text
1.4.11 Non-text Contrast Swatches, focus rings, control borders accent and focus-ring tokens verified ≥3:1 against surface (18.2) and are what carries every required UI-component boundary (selection, focus, swatch ring); the border token itself is a decorative panel hairline only (18.2's note) and is never the sole means of identifying a component or its state
2.1.1 Keyboard Slot rail, hue picker, canvas preview, catalog filters Every interactive element reachable and operable via keyboard: rail (11.5 listbox pattern), hue picker (grouped tabs + swatch grid, standard tab/arrow patterns), pinch-zoom has a keyboard equivalent (+/- keys zoom the preview stage in 25% steps when focused, an addition to the touch gesture in 11.12), filters (native form controls, 15.3)
2.1.4 Character Key Shortcuts Global shortcuts (11.11) No single-character shortcut exists anywhere in the designer: every designer shortcut requires a modifier (Alt+R, Alt+S, Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y); the numeric rail-jump shortcuts from earlier drafts are removed entirely. The candidate-queue shortcuts in 17.6.6 are single-character but are active only while a candidate tile has focus, satisfying 2.1.4's activation-on-focus exception; they are never global
2.4.7 Focus Visible Custom controls (swatches, rail items, cards) All custom components use the shared --color-focus-ring outline via :focus-visible (never suppressed with outline: none without a replacement); verified per-component in Section 22.6's accessibility test pass
2.4.11 Focus Not Obscured (Minimum) Sticky filter bar (18.4 z=10), bottom sheet/modal/toast (z=1000) Every scroll container sets scroll-margin-block equal to the sticky header height, so a focused element scrolls clear of it; while the bottom sheet, a modal, or a toast with focusable content is open, focus is trapped inside it (11.11's trapping mechanism, 18.5), so no other focusable element can sit behind an overlay layer
2.5.7 Dragging Movements Bottom sheet resize handle (11.12) The handle is a real <button>: Enter/Space cycles the sheet between 50% and 85% height; dragging the handle is a pointer-only enhancement over those same two heights, never the only way to resize or dismiss (scrim tap and Escape also close it)
2.5.8 Target Size (Minimum) Dense grids (hue swatches, asset thumbnails) on touch All interactive targets meet the 44×44 CSS px minimum via padding (Section 11.12); the 24px visual swatch sits inside a 44×44 tappable wrapper as the exception permitted by the criterion's spacing alternative, satisfied here directly by size instead
4.1.2 Name, Role, Value Icon-only controls, custom widgets (rail, tabs, sheet) Every icon-only control has aria-label (18.6); every custom widget uses the matching ARIA role/state pattern documented per-component in 18.5 and per-interaction in Section 11

Automated tooling (axe-core, Section 22.6) catches structural violations (missing labels, contrast, roles) but cannot evaluate 2.1.4, 2.5.7, or 2.4.11 — each requires exercising the actual interaction (triggering a shortcut, dragging a handle, opening an overlay over scrolled content) with a human tester or a targeted Playwright script that asserts the behaviour described above. The launch checklists in Sections 26 and 27 require these three as explicit manual checks, not only the automated audit.

18.9 Screen-reader model for the designer and preview #

Landmark structure: <header> (site header), <nav aria-label="Main"> inside it, <main id="main-content"> wrapping the designer/page content, <aside aria-label="Slot selection"> for the rail, <aside aria-label="Options"> for the option panel, <footer> for the global footer (Section 10.4). Exactly one <main> per page.

Live-region policy: exactly three live regions exist at once in the designer, each with a single clear purpose, to avoid the common failure mode of competing or redundant announcements:

  1. A polite region for non-urgent status (load-more results, hue applied confirmation) — reused across the catalog (15.8) and designer.
  2. An assertive region reserved for the dropped-field/retired-asset banners (11.13), since those represent a change the user did not request and should hear promptly.
  3. The toast container (18.5), polite, dedicated to action confirmations (Share, copy actions).

Design-as-text listing: the preview stage exposes a visually-hidden (sr-only utility class, not display:none, so it remains in the accessibility tree) textual summary alongside the canvas: "Character skin: , skin hue <hue name or 'as-drawn'>. : , hue <hue name or 'as-drawn'>." repeated per filled slot, in z-order. This listing updates live (inside the polite region above, but only announced on an explicit request — see announcement wording below — not on every keystroke, to avoid announcement flooding during rapid hue adjustments).

Announcement wording: the live text summary is exposed via a "Describe this design" icon button (18.6-compliant, aria-label="Describe this design") next to the preview stage; activating it moves focus to the sr-only summary and triggers a single polite announcement of its full current text. The summary is NOT announced automatically on every designSig change — only recomputed silently in the DOM so it is current whenever requested — because the coalescing/debounce behaviour in Section 11.7 that governs undo history does not apply to screen-reader announcement pacing, and continuous auto-announcement during interactive hue adjustment would be unusable.

18.10 Theming implementation #

Theme choice: system preference only (prefers-color-scheme), read server-side is not possible (no client hints are trusted for this), so the mechanism is:

  1. The server always renders with data-theme="dark" on the root <html> element (the default, matching 18.1's dark-first stance) and includes a tiny inline, blocking <script> in <head> (before any stylesheet paints) that reads matchMedia("(prefers-color-scheme: light)").matches and, if true, swaps the attribute to data-theme="light" before first paint.
  2. This inline script is the ONLY blocking script on the page and is under 200 bytes minified; it contains no framework code, only the media-query check and attribute swap, to avoid a formal sr-blocking bootstrap cost.
  3. No cookie, no localStorage, no user override control exists, because there is no account system to persist a preference against (Section 20.7's no-PII, no-persistent-tracking stance extends to theme choice) — the system preference IS the preference, re-evaluated fresh on every load.

No-flash guarantee: because the theme-detecting script runs before any CSS referencing theme tokens is applied to visible content (it is placed as the first child of <head>, before the stylesheet <link>), there is no flash of the wrong theme. This is verified in Section 22's visual regression pass (Section 22.5) by asserting computed background colour matches the expected theme within the first animation frame under a forced prefers-color-scheme: light emulation.

18.11 Content style guide #

  • Capitalization: sentence case for all UI text (buttons, headings, labels) — "Use in designer", not "Use In Designer". Proper nouns (SkinForge, UO Outlands, hue/asset names as stored) keep their source casing.

  • Tone: plain, friendly, brief. No exclamation points except in success toasts sparingly ("Copied!" is the sole exception, kept for its established micro-interaction convention). No jargon beyond the domain terms defined in Section 3's glossary, which the UI uses consistently rather than inventing synonyms (always "hue", never "color"/"tint" interchangeably; always "asset", never "item"/"piece" interchangeably).

  • Numbers: hue indexes are always shown as plain integers with no leading zeros or grouping separators ("1102", never "1,102"). Counts under 1,000 are shown exactly; counts of 1,000 or more are abbreviated with one decimal ("1.2k") only in compact contexts (catalog card badges), and shown exact with grouping separators ("1,204") in full metadata contexts (asset detail page, admin console).

  • Naming hues and assets in the UI: hues are always shown as "" with the numeric index available on demand (tooltip, detail page) rather than inline in running text, except where the index itself is the actionable content (copy-hue-number, 15.5). Assets are always shown by their curated display_name (Section 6), never their raw asset_key, in any player-facing surface; the raw key appears only in the "Copy Asset Key" action's copied value and in URLs.

  • Unofficial fan-project disclaimer, exact wording owned by Section 10.4; quoted verbatim everywhere it appears:

    "Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."

    This exact sentence is used verbatim in the header banner, the footer, and the /legal page's opening statement — never paraphrased — so the disclaimer is textually identical everywhere it appears, satisfying the "persistent and clear" scope requirement with one canonical string maintained in core/i18n/en.ts under the key disclaimer.fanProject.

  • Consistency check: every UI string that is not itself content-driven (asset names, hue names, tag names, build labels) lives in core/i18n/en.ts; no component hard-codes an inline literal string for a label, button, tooltip, or message, which is what allows Section 4's future-locale note to hold — adding a language is a data change to that one file, never a component rewrite.

19. Performance, Caching & CDN Strategy #

19.1 Performance budgets #

All budgets are measured on a mid-tier mobile device profile (Moto G Power class, 4x CPU throttle, simulated Slow 4G for the cold case and cable-equivalent for the warm case) unless stated otherwise. Budgets apply to the production deployment described in Section 23, fronted by the CDN in Section 19.2.

Metric Target Route scope Notes
LCP (Largest Contentful Paint) ≤ 2.0s (warm), ≤ 3.0s (cold) /, /d/:code LCP element is the paperdoll composite image
INP (Interaction to Next Paint) ≤ 200ms / Measured on hue-swatch click and asset-picker open
CLS (Cumulative Layout Shift) ≤ 0.05 all public routes Preview surfaces reserve fixed aspect-ratio boxes (Section 12)
Time-to-first-preview ≤ 1.2s (warm cache), ≤ 2.5s (cold cache) / Time from navigation start to first paperdoll pixel visible, default body/skin only
TTFB ≤ 150ms (cache hit), ≤ 500ms (cache miss) all HTML routes Measured at origin, before CDN

API latency budgets, by endpoint class (class definitions per Section 16):

Endpoint class p50 p95 Notes
Reads — catalog list/detail, hue list 30ms 120ms Postgres-backed, indexed lookups only
Reads — design resolve by short code 15ms 60ms Single indexed lookup on designs.short_code
Writes — design create 60ms 250ms Includes canonicalization, hashing, insert, and enqueue of render job
Admin reads 50ms 200ms Lower priority than public traffic; not CDN-cached
Admin writes 80ms 400ms Includes audit log insert (Section 20.5)

Render latency budgets (Section 8 defines the render algorithm; this section owns the numeric targets):

Render class p50 cold p95 cold p50 warm (cache hit)
Single design composite, scale 1 40ms 150ms 4ms
Single design composite, scale 3 90ms 300ms 6ms
Single asset variant 15ms 60ms 3ms
Slot swatch strip 60ms 220ms 4ms
OG image (Section 14) 180ms 500ms 5ms

"Cold" means no object-storage or in-process cache hit — the server performs the full composite and encode. "Warm" means a Cache-Control-eligible response served from the CDN edge or the in-process LRU (Section 19.2). These numbers are the pass/fail thresholds for the load tests in Section 22.7.

Page-weight ceiling per route class, transferred bytes after compression, excluding the first paperdoll composite image itself (which is budgeted separately above):

Route class Ceiling
/ — the designer (initial HTML + CSS + islands JS) 180 KB
/d/:code (permalink, no-JS-capable) 90 KB
/catalog and /catalog/:slotKey 130 KB
/hues 110 KB
/admin/* 260 KB (not CDN-cached, budget is generous but still enforced)

The designer is / and only /. /design is a permanent redirect to it (Section 10.5), not a page with a budget, a cache entry or a test scenario of its own; every budget above that concerns the designer names /.

19.2 Cache layers #

Five layers, checked in this order on a read path. A hit at any layer short-circuits every layer below it.

Layer What lives there Key shape TTL Invalidation trigger
Browser cache Rendered images, static JS/CSS bundles, fonts Full request URL Per Cache-Control in Section 19.3 Content-addressed URLs never need invalidation; HTML uses short max-age
CDN (edge) Rendered images, OG images, static assets, HTML with s-maxage Full request URL Per Cache-Control in Section 19.3 Explicit purge call (Section 19.5) for HTML on publish; images are immutable and never purged
Object storage Encoded render outputs (PNG/WebP) renders/:first2/:next2/:hash.:ext (Section 8) Infinite — content-addressed Never invalidated; a new build hash produces a new key
In-process LRU Decoded sprite RGBA buffers, hue tables, hot design JSON, rate-limit bucket counters Application-defined string keys (e.g. sprite:<assetKey>:<body>) Process lifetime, capacity-bounded (see below) Evicted by LRU capacity; cleared on process restart; explicit invalidation on publish (Section 19.5)
Postgres Source of truth: designs, assets, hues, builds, jobs SQL primary/unique keys N/A N/A — always consulted on a full miss

In-process LRU sizing on the reference 4-vCPU / 8 GB VPS (Section 23.1):

Cache Max entries Max bytes Eviction policy
Sprite RGBA buffers 4,000 512 MB LRU, size-weighted
Hue tables 4,000 (one per hue group member; effectively all of them) 32 MB LRU, never evicted in practice — dataset is small
Hot design JSON 10,000 64 MB LRU, size-weighted
Rate-limit token buckets 50,000 16 MB TTL 1 hour, then LRU

These are process-local, not shared across app replicas. Section 23.12 addresses multi-replica scaling; at that point sprite/hue caches remain per-replica (cheap to rebuild, read-only data) and rate-limit buckets move to the Postgres-backed path exclusively (Section 20.1 threat: render-queue exhaustion; the in-process LRU is a fast path only, never the sole enforcement point).

19.3 Cache-Control matrix #

Route class Example Cache-Control value Reason
HTML — designer home / public, max-age=0, s-maxage=300, stale-while-revalidate=86400 Content rarely changes; CDN can serve slightly stale during a burst. /design is a permanent redirect to / (Section 10.5); the redirect response itself carries public, max-age=86400
HTML — permalink page /d/:code public, max-age=0, s-maxage=300, stale-while-revalidate=86400 Immutable design (Section 13), but the wrapping HTML (nav, footer, ads-free banner) can change
HTML — catalog/hues /catalog, /catalog/:slotKey, /hues public, max-age=0, s-maxage=300, stale-while-revalidate=86400 Same rationale; catalog content changes only on publish
HTML — static info /about, /faq, /legal, /support, /changelog public, max-age=60, s-maxage=3600, stale-while-revalidate=86400 Rarely edited; longer edge TTL acceptable
Render URLs /render/d/:code@:scale.:ext, /render/a/..., /render/s/... public, max-age=31536000, immutable Content-addressed by build hash + design JSON (Section 8.9); the URL never changes meaning
OG images /og/d/:code.png public, max-age=31536000, immutable Same content-addressing argument, generation trigger in Section 14
API reads GET /api/v1/* Per endpoint class, Section 16.6 (owner) — not a single value Catalog, design-resolve, search, stats and version each have their own TTL; GET /api/v1/designs/random is no-store, because an edge-cached random endpoint stops being random. This matrix does not restate those six values; Section 16.6 is the one place they are defined
API writes POST /api/v1/designs, any admin mutation no-store Writes must never be cached or replayed from a cache
Sitemap/robots/opensearch /sitemap.xml, /robots.txt, /opensearch.xml public, max-age=3600, s-maxage=86400 Crawled infrequently, cheap to regenerate
Admin pages /admin/* private, no-store Never cached by any shared cache; contains session-scoped content
Admin API /admin/api/* private, no-store Same reasoning
Health checks /health, /health/ready no-store Must always reflect live state
Metrics /metrics no-store Token-protected, must reflect live state (Section 21.3)

19.4 Immutable content addressing #

Render URLs and OG image URLs embed a hash derived from buildHash + canonicalDesignJson + scale + format (Section 8.9) or, for OG images, the design's short code plus its build hash (Section 14). Because the URL path itself changes whenever the underlying pixels would change, every such response can carry Cache-Control: public, max-age=31536000, immutable with no correctness risk: a stale copy of an immutable URL is impossible, since staleness would require the same URL to serve different bytes, which cannot happen by construction.

This removes the need for time-based expiry, cache versioning query parameters, or manual purge for the overwhelming majority of bytes served by the application — every rendered image, every OG image, and every hashed static JS/CSS bundle (Fresh's build step fingerprints asset filenames). The only remaining invalidation surface is HTML (which references the current catalog and current builds by label, not by hash) and the small set of non-hashed top-level files (robots.txt, sitemap.xml). Section 19.5 covers what must be purged when a build is published.

When SKINFORGE_STORAGE_DRIVER=s3, every public render and OG URL is rewritten to SKINFORGE_S3_PUBLIC_BASE_URL (Section 24.2) instead of the app's own origin, so the CDN and browser fetch object bytes directly from the configured public storage origin; the immutability and content-addressing guarantees above are unaffected by which origin ultimately serves the bytes.

19.5 Invalidation on build publish #

Publishing a build (Section 9) changes which assets are active, which can change catalog listings, hue lists, and — for any design created against the newly retired build's assets — nothing, because designs pin build_id and continue rendering from the archived build's assets (Section 13, immutability rule). Publish therefore requires:

Must be purged:

  • CDN cache for /catalog, /catalog/:slotKey, /catalog/:slotKey/:assetKey (all combinations touched by the build's asset changes), /hues, /hues/:hueIndex for changed hues, /sitemap.xml.
  • CDN cache for / (the default preview may reference a newly retired asset key and must fall back per Section 19.5's fallback rule below).
  • In-process LRU sprite and hue caches. In v1 the application is a single process (Section 23.1), so this is a direct in-process function call made by the publish workflow — invalidateSpriteAndHueCaches() — not an HTTP endpoint. There is no /internal/* route in v1, and none appears in Section 10's route list or Section 16's endpoint list. Section 23.12 step 4 (a second app replica) is the point at which this becomes a fan-out, and that step owns specifying its transport, its authentication and its envelope; until then, adding an HTTP form would be an unauthenticated internal surface with no caller.

Must not be purged:

  • Any /render/... or /og/... URL. These are immutable per Section 19.4; a publish never changes what an existing hash means, it only stops new designs from referencing the retired asset keys.
  • Any existing /d/:code permalink's rendered content — only its wrapping HTML cache entry is purged, and only because the HTML includes catalog navigation links, not because the design itself changed.

CDN purge call: the admin publish workflow (Section 17) issues an HTTP request to the URL in SKINFORGE_CDN_PURGE_URL, authenticated with SKINFORGE_CDN_PURGE_TOKEN (Section 24), with a JSON body listing the exact paths above (never a wildcard purge, to avoid a thundering-herd of cache misses against origin for immutable render URLs that do not need purging):

async function purgeCdnPaths(paths: string[]): Promise<void> {
  const res = await fetch(Deno.env.get("SKINFORGE_CDN_PURGE_URL")!, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SKINFORGE_CDN_PURGE_TOKEN")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ paths }),
  });
  if (!res.ok) {
    throw new PurgeFailedError(res.status, await res.text());
  }
}

Fallback when purge fails: the publish workflow does not roll back the database change — the publish itself succeeds and is recorded (Section 9 owns publish semantics). The purge call is retried up to 3 times with exponential backoff (500ms, 2s, 8s) via the job runner (Section 6, jobs table). If all retries fail, an SF-9002 internal error is logged (Section 25.2) and an admin.publish.purge_failed event is emitted (Section 21.2); the operator sees a banner in /admin/dashboard prompting a manual purge. Because HTML pages also carry s-maxage=300, the worst case is stale catalog navigation for up to 5 minutes even with a fully failed purge — never stale render content, and never a broken permalink.

19.6 Image delivery #

Format selection is client-side, not server-side. The extension in the path is authoritative (Section 8.8) and the Accept header is never consulted on /render/* or /og/* — the URL grammar in 8.8 makes the extension mandatory, so there is nothing left to negotiate and no request in this namespace can produce a 406. Preview surfaces choose WebP by publishing both URLs in <picture>/srcset markup and letting the browser pick the first source it can decode; download links (Section 12's "Download" action) request .png explicitly, since WebP support in image-editing tools remains inconsistent and downloads are meant for external use. Keeping the choice in the client is also what preserves the immutability argument in 19.4: one URL always maps to exactly one byte stream, which a Vary: Accept negotiation would break. The <picture> markup used on preview surfaces:

<picture>
  <source type="image/webp" srcset="/render/d/{code}@1.webp 1x, /render/d/{code}@2.webp 2x, /render/d/{code}@3.webp 3x">
  <img src="/render/d/{code}@1.png" width="260" height="330" alt="Character preview" loading="eager" fetchpriority="high">
</picture>

Responsive srcset for thumbnails: catalog and slot-swatch thumbnails (Section 15) use scale-1 art at native 260×330 (or the per-slot swatch dimensions in Section 12) with 1x/2x/3x srcset density descriptors as shown above. No separate width-based breakpoints — the paperdoll art is a fixed small canvas per Section 8, so density switching (not viewport-width switching) is the correct model.

Lazy loading rules: the primary paperdoll composite on / and /d/:code uses loading="eager" fetchpriority="high" since it is the LCP element (Section 19.1). Every other image — catalog grids, slot swatch strips below the fold, hue swatches beyond the first row — uses loading="lazy" with no fetchpriority override (defaults to auto).

Sprite sheet vs individual files: individual files, not sprite sheets. Decision and justification: render URLs are content-addressed per design/asset/hue combination (Section 8.9), which is the property that makes Cache-Control: immutable safe (Section 19.4) and that lets the CDN cache each combination independently with no coupling between unrelated assets. A sprite sheet would require either (a) one sheet per possible asset combination, which does not reduce request count because there is no finite shared sheet across the space of designs, or (b) a shared per-slot sheet of all variants, which forces every client to download every hue variant of every asset up front — far exceeding the page-weight ceilings in Section 19.1 for a designer UI that only ever displays a handful of variants at a time. Individual, small (a few KB), immutably-cached, HTTP/2-multiplexed requests are cheaper end to end for this access pattern than any sprite-sheet scheme.

Preload hints for the permalink page: /d/:code includes in its <head>:

<link rel="preload" as="image" href="/render/d/{code}@1.webp" type="image/webp" fetchpriority="high">
<link rel="preconnect" href="https://{SKINFORGE_PUBLIC_BASE_URL host}">

No other route preloads images — preload is reserved for the confirmed LCP element to avoid contending with it for bandwidth.

19.7 Database performance #

Query patterns that matter, in descending order of request volume:

  1. Design resolve by short code: SELECT * FROM designs WHERE short_code = $1 — the hottest query in the system, backing every /d/:code and /render/d/:code@... request. Indexed uniquely on designs.short_code (Section 6).
  2. Catalog listing by slot: SELECT * FROM assets WHERE slot_key = $1 AND retired_at IS NULL ORDER BY display_order — indexed on (slot_key, retired_at) partial index WHERE retired_at IS NULL.
  3. Hue lookup by index, render path: SELECT * FROM hues WHERE hue_index = $1 AND build_id = $2 — served by the unique constraint on (hue_index, build_id) (Section 6.13). Two properties of this query matter and are easy to get wrong in opposite directions. build_id is never omitted: hues rows are unique per build, not globally, so a lookup missing build_id would return one row per build that has ever defined that index and risk rendering an existing design with a newer build's colour table — exactly the failure Sections 9.8 and 13.7 forbid. And no retired_at predicate is applied here: retirement governs discovery, never rendering, so a design that pinned a since-retired hue still renders it from its own build (Sections 8.8, 8.14, 9.7, 9.8, 13.7). The discovery-side queries are the mirror image — the /hues listing, the hue picker and the catalog queries in item 2 above all carry AND retired_at IS NULL, because a retired hue must stop being offered the moment its build is superseded.
  4. Design insert on create: single-row insert into designs, guarded by the short-code collision check in Section 13 (a SELECT on short_code inside the same transaction as the INSERT, using INSERT ... ON CONFLICT (short_code) DO NOTHING RETURNING * and a re-hash loop on conflict).
  5. Job claiming: UPDATE jobs SET status = 'running', claimed_at = now() WHERE id = (SELECT id FROM jobs WHERE status = 'queued' AND run_after <= now() ORDER BY priority DESC, created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING * — indexed on (status, run_after, priority, created_at). The waiting state is queued, not pending: jobs.status uses the same five values as import_runs.statusqueued, running, succeeded, failed, cancelled (Section 6) — and no query or screen in this document says pending for a job.

N+1 avoidance rules:

  • Every list endpoint that returns nested data (e.g. a design with its slot assignments, an asset with its images at every scale) fetches nested rows with a single follow-up query using WHERE x = ANY($1::text[]) batched over the parent IDs already fetched — never one query per row.
  • Repository modules under core/db/ expose batch-shaped functions (getAssetsByKeys(keys: string[])) as the only way to fetch by key; there is no public single-row-in-a-loop helper, which makes the N+1 pattern structurally unavailable rather than merely discouraged.
  • Every admin list screen (Section 17) that joins to a count (e.g. asset count per slot) uses a single aggregate query with GROUP BY, never a per-row count query.

Connection pool sizing: postgres.js pool size is set by SKINFORGE_DB_POOL_SIZE (Section 24), default 10 for the web process and 4 for the CLI process (Section 4). On the reference 4-vCPU VPS (Section 23.1) with Postgres also co-located, total connections across both processes plus the Postgres max_connections default of 100 leaves headroom for psql and admin tooling. Sizing rule: pool size ≈ 2 × vCPU count for the primary web process, since queries are short (sub-10ms typical per 19.7 query patterns above) and the workload is not connection-hold-heavy.

Statement timeouts: SET statement_timeout = '2000ms' at the pool-connection level for the web process (public-facing queries must never hang a request past the API budget in Section 19.1), and SET statement_timeout = '30000ms' for the CLI/import process (bulk inserts during import can legitimately take longer). A statement timeout breach raises Postgres error 57014, mapped to SF-9001 (Section 25.2).

19.8 Render queue tuning #

The render queue is the Postgres jobs table (Section 6) claimed with FOR UPDATE SKIP LOCKED (Section 4). Starting numbers for the reference 4-vCPU / 8 GB VPS:

Parameter Starting value Env var Rationale
Render worker concurrency 4 SKINFORGE_RENDER_MAX_CONCURRENCY Matches the reference 4-vCPU VPS core count (Section 23.1); compositing is CPU-bound WASM work (Section 8) and the HTTP server's largely I/O-bound work shares cores opportunistically rather than needing one held in permanent reserve
Queue depth soft limit 200 render jobs in queued status (internal constant, not env-configurable) Beyond this, back-pressure engages (below)
Per-render timeout 8,000 ms SKINFORGE_RENDER_TIMEOUT_MS Generous relative to the p95 cold budget of 300ms (Section 19.1); catches hangs, not slow-but-normal renders. A breach aborts that render and returns 504 with SF-5002 (Section 25.2)
Back-pressure response HTTP 503 with Retry-After: 2 and error SF-5004 Applied when queue depth exceeds the soft limit; protects the process from unbounded memory growth under a render storm

Why four workers, and when to lower it. Four render workers saturate a 4-vCPU host without starving the web tier: encoding releases the CPU during WASM I/O, so the HTTP server's largely I/O-bound work interleaves with compositing rather than queuing behind it, and the queue-depth soft limit — not a spare core — is what protects the web tier under a render storm. An operator who co-locates Postgres on the same host under sustained load should lower this to 3, which is the one supported deviation; the value stays 4 everywhere else in this document (Sections 1, 4, 8, 23.1).

Every render request follows the single claim/poll path owned by Section 8.10. The first requester claims the jobs row and streams its own freshly encoded bytes; concurrent requesters for the same cache key poll that row and are served the same bytes when it completes. There is no scale-based or format-based split, no 302, and no 202 on the image namespace — a still-generating request is served 200 with the pending placeholder and X-SF-Render-Pending: 1 per Section 25.3, and 202 only to a client that explicitly sends Accept: application/json. A bodyless 202 to an <img> tag is a broken image in every browser, which is why the image namespace never issues one. This section owns only the concurrency, queue-depth and timeout numbers; Section 8.10 owns the mechanism.

Back-pressure specifically protects against the render-queue-exhaustion threat in Section 20.1: an attacker requesting many distinct never-before-seen design/hue combinations cannot force unbounded concurrent WASM work, because concurrency is capped at SKINFORGE_RENDER_MAX_CONCURRENCY and excess requests queue up to the soft limit before the server sheds load with 503s rather than degrading every in-flight request's latency.

19.9 Front-end performance #

Island payload budget: 60 KB gzipped total for all islands loaded on / (the designer, and the heaviest route), broken down as: designer state/controls island ≤ 30 KB, preview-canvas island ≤ 20 KB, share/permalink island ≤ 10 KB. /d/:code ships zero required islands for the read-only view (server- rendered HTML is fully functional without JS per Section 19.9's no-blocking-JS rule below); an optional "open in designer" island loads on interaction only.

Code splitting: Fresh's per-route island boundary is the unit of splitting — each island is its own JS chunk, fetched only on the routes that reference it. The catalog browse islands (filters, infinite-scroll trigger) never ship on /d/:code, and the designer islands never ship on /catalog.

Font loading: none on the live UI. Section 18.3 fixes the interface on the system-ui stack precisely to eliminate webfont-induced layout shift on first paint, so no route preloads, links or @font-face-declares a font, and no font bytes count against the page-weight ceilings in 19.1. The one bundled face, Pixelify Sans (Section 18.3), is loaded only inside the OG image generator's own process (Section 14) and is never referenced by a page.

No-blocking-JS rule for permalink pages: /d/:code renders the composite image via the <picture> markup in Section 19.6 server-side; no JavaScript is required to see the shared design. Any <script> tag on that route carries type="module" defer or is an island's own deferred hydration script — never a synchronous, render-blocking <script> in <head>. This is verified by the no-JS E2E scenario in Section 22.5.

19.10 Load expectations and capacity model #

Normal traffic (baseline planning assumption): 5,000 unique visitors/day, 15,000 page views/day, 2,000 design creations/day, 40,000 render requests/day (most served from CDN cache after the first render). Sustained request rate: roughly 0.5 req/s average, bursty around UO Outlands event announcements.

Reddit/Discord spike on one permalink: a single /d/:code gets linked from a high-traffic external source. Modeled spike: 2,000 requests in 5 minutes (≈ 7 req/s sustained, higher in the first 30 seconds) concentrated on one permalink page and its one render URL at up to 3 scales.

Layer Behavior under the spike
CDN Absorbs nearly all of it — the permalink HTML has s-maxage=300, the render URLs are immutable; after the first request each, every subsequent request in the spike is an edge cache hit and never reaches origin
Origin (app process) Sees at most a handful of origin requests: one per (page, scale×format) combination, until the CDN's s-maxage window rolls over — well under the render concurrency limit in Section 19.8
Render queue Not engaged beyond the first 1-3 render requests for that design (one per requested scale/format); repeat requests are cache hits at the CDN before reaching the queue
Database One designs row lookup on first HTML render per edge PoP (each PoP's first miss), then nothing — no per-request DB load during the spike
Rate limiter Per-IP design-creation and render rate limits (Section 20.1, Section 24) are irrelevant here since this is read traffic to a cached permalink, not write or render-generation traffic

The architecture's central capacity property: because permalinks are immutable and content-addressed (Section 19.4), a viral spike on one design degrades to "CDN serves a popular file," which requires no origin capacity planning beyond the first-render cost. The failure mode that would matter — many distinct new designs created in a short window, each requiring a fresh render — is bounded by the design-creation rate limit (SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR, Section 24) and by the render queue back-pressure in Section 19.8, not by CDN behavior.

Capacity ceiling on the reference VPS: at SKINFORGE_RENDER_MAX_CONCURRENCY=4 and a cold p95 of 150ms (scale 1), sustained new-design-render throughput is roughly 26 renders/second before queueing begins, comfortably above the 2,000-creations/day baseline (≈0.02/s average) and able to absorb a burst of a few hundred simultaneous new designs before back-pressure engages.

19.11 Performance testing method #

Performance budgets in Section 19.1 are enforced automatically in CI (Section 22.11) using Lighthouse CI for LCP/INP/CLS/page-weight against a locally built production bundle, and a custom k6 load-test script for the API and render latency budgets, run against a disposable Docker Compose stack seeded with the fixture data set (Section 22.10). The load scenarios (normal traffic and the permalink spike, Section 19.10) are codified as k6 scenarios and re-run before every release (Section 22.7, Section 22.12). A budget breach fails the release gate; it does not fail every commit's CI run, since the load scenarios take several minutes and run on a separate, slower CI job than the unit/integration suite.

20.1 Threat model #

Assets to protect: the design database (public data, but integrity matters — a corrupted design must never render), the asset/hue catalog and its source game-file provenance, admin credentials and sessions, the extraction pipeline's host filesystem access, server compute and bandwidth, and the operator's reputation as a fan-project operator (legal exposure per Section 20.8).

Actors:

Actor Access Motivation
Anonymous public visitor Public pages, public API, render endpoints Legitimate use; occasionally abusive (spam, scraping)
Automated bot/scraper Public pages, public API Bulk data harvesting, hotlinking, SEO scraping
Malicious visitor Same as anonymous, at volume or with crafted input Denial of service, data exfiltration attempts, defacement via crafted names
Staff/admin Admin console, CLI on the host Legitimate operation; account compromise is the risk, not intent
Operator (host owner) Full host access, CLI, database Trusted; out of scope as an adversary

Trust boundaries: (1) public internet → CDN, (2) CDN → app process (HTTP), (3) app process → Postgres (SQL over a local/private network link), (4) app process → object storage (S3 API or local filesystem), (5) admin browser → app process (authenticated HTTP), (6) app process → the operator's local game-client directory, read-only, CLI-invoked only, never reachable from the public HTTP surface, (7) extraction sandbox (Section 20.6) → host filesystem, permission-scoped.

Top ten threats:

# Threat Likelihood Impact Control
1 Scraping/hotlinking of rendered images at volume High Medium (bandwidth cost) Content-addressed immutable URLs are cheap to serve from CDN (Section 19.4); Cross-Origin-Resource-Policy header (20.4) limits cross-site embedding without blocking normal <img> use; render rate limit per IP (SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE) caps origin-generation cost specifically, not CDN-served cache hits
2 Render-queue exhaustion via many distinct uncached combinations Medium High (origin CPU/availability) Render concurrency cap and back-pressure (Section 19.8); per-IP render rate limit; per-IP design-create rate limit bounds the rate of new renderable combinations
3 Design-spam (mass automated design creation) Medium Medium (storage growth, DB bloat) SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR per IP; designs are cheap (a JSON row) so impact is capped even under sustained abuse; no CAPTCHA in v1 — rate limiting is the sole control, revisited if abuse is observed (Section 21.5 alerting)
4 Path traversal in asset keys or file paths Low High (arbitrary file read/write) Asset keys validated against a strict pattern (Section 20.2); all filesystem access under the storage driver's fs root uses path.resolve + a prefix check that rejects any resolved path escaping the root, never raw string concatenation
5 SSRF via import paths or operator-supplied URLs Low High (internal network access) The extractor takes only local filesystem paths, never URLs (Section 7); no code path in the web app accepts an operator-supplied URL for server-side fetch; the CDN purge call (Section 19.5) targets a fixed, configured, admin-only URL, never user input
6 Malicious/malformed client files fed to the extractor Medium Medium (crash, resource exhaustion, memory corruption in a parser) Extraction runs sandboxed (Section 20.6) with strict permission scoping, size caps, and per-file timeouts; format readers are fuzz-tested (Section 22.8); a parse failure produces a flagged candidate, never a crash of the host process
7 Admin credential theft (phishing, credential stuffing, leaked password) Medium High (full catalog control) Argon2id password hashing, mandatory TOTP second factor, brute-force lockout, session hardening (Section 20.5); credential stuffing mitigated by TOTP requirement even if a password leaks
8 Session fixation / session hijack Low High (admin takeover) Session ID regenerated on login and on privilege change; HttpOnly; Secure; SameSite=Lax cookie; session bound to a server-side row invalidated on logout, password change, or by admin action (Section 20.5)
9 XSS via asset/hue names extracted from game files Medium Medium (stored XSS in catalog/admin pages) All extracted strings are untrusted (Section 20.3); output-encoded on every render path (Preact JSX auto-escapes; no dangerouslySetInnerHTML anywhere in the codebase, enforced by lint rule in Section 5); CSP (Section 20.4) as defense in depth
10 Supply-chain compromise (malicious dependency update) Low High (full compromise) Lockfile-pinned installs, deno.lock integrity checking, minimal dependency surface (Section 4), scheduled dependency audit (Section 20.10), least-privilege Deno permission flags in production (Section 20.10) limiting blast radius even if a dependency is compromised

20.2 Input validation posture #

Every external input — query parameters, path parameters, JSON bodies, headers used for logic (never for security decisions), and file uploads (admin only) — is validated by a Zod 4.x schema under core/schema/ before it reaches any handler logic, per the shared-validation rule in Section 5 and Section 16. No handler performs ad hoc type coercion or manual regex checks outside a schema module. Validation failures return SF-1000-family errors (Section 25.2) with the offending field populated in the error envelope (Section 16).

Specific rules:

Input Rule
Asset key (asset_key) Pattern ^[a-z0-9]+(?:[-_.][a-z0-9]+)*$, max 128 characters, exactly as defined by AssetKeySchema in Section 13.2.5 — this section does not restate it, it enforces it. Rejects anything containing /, \, .., %, control characters, or non-ASCII (all fall outside the pattern already). A key failing the pattern returns 404 SF-2001 and never reaches the storage driver or a lookup query.
Hue index (hue) Integer, 0 <= hue <= 65535 per HueIndexSchema (Section 13.2.5); existence is additionally checked against the hues row for the design's pinned build_id (Section 19.7), not just the numeric range
Short code Exactly 10 characters, Crockford Base32 alphabet minus i/l/o/u (Section 13), case-insensitive on input, canonicalized to lowercase before lookup. A code failing this shape check and a well-formed code with no matching design are deliberately indistinguishable: both are rejected as SF-2000 (404) before any further lookup, never as a 400, so a client probing for valid codes cannot tell "malformed" from "not found" (Section 13.4.1).
File paths (CLI/import only, never public) Resolved with path.resolve against the configured work directory (SKINFORGE_IMPORT_WORKDIR), then checked with path.relative to confirm the result does not start with .. or become absolute outside the root. Symlinks are resolved and re-checked (Section 20.6).
Body code Enum m | f exactly, per Section 3.
Pagination cursor Opaque, application-generated, HMAC-signed with SKINFORGE_ADMIN_SESSION_SECRET-derived subkey so a client cannot forge a cursor to probe internal row ordering; malformed or invalid-signature cursors return SF-1010.
Search/query grammar (Section 15) Parsed by a dedicated grammar parser under core/search/, itself covered by the Zod schema for its raw string length (max 200 characters) before parsing; the parser never constructs SQL by string concatenation — it builds a parameterized query via postgres.js tagged templates only.

20.3 Untrusted content from game files #

Every string extracted from client files during import (Section 7, Section 9) — sprite/gump display names, hue group names, tiledata flags text, any operator-supplied "format note" — is treated as untrusted user input, identical in posture to a public form submission, even though the operator is trusted to run the extractor. The game files themselves may be corrupted, hand-edited, or sourced from a modified client, so the content within them is not assumed clean.

Rules applied at import time, before any extracted string reaches the database:

  • Length cap: display names truncated to 120 characters; any format note field truncated to 2,000 characters. Truncation is logged (import.string_truncated event, Section 21.2), never silent data loss without a trace.
  • Character allowlist: printable UTF-8 only; control characters (except tab/newline in the format-note free-text field) are stripped. Names used as *_key natural keys (Section 6.2) are additionally restricted to the asset-key pattern in Section 20.2 — any extracted name that does not fit is not used as a key; the importer generates a synthetic key and stores the raw extracted name only as a display label.
  • Escaping: no HTML escaping is performed at import time — escaping is an output-time responsibility (Section 20.1, threat #9) so that the stored value remains the faithful source string and every rendering surface (HTML via Preact auto-escaping, JSON via standard serialization, log lines via structured JSON per Section 21.1) applies its own correct encoding. This avoids the classic double-escaping bug where an already-HTML-escaped string gets escaped again in a different output context.
  • Null bytes: rejected outright; a string containing a null byte fails import validation for that field and the candidate is flagged needs_operator_input (Section 7's staged-discovery fallback rule) rather than silently truncating at the null byte.

20.4 HTTP security headers #

Applied by shared middleware in core/http/security-headers.ts, mounted before every route handler. Two profiles: public (all routes under /, /d, /catalog, /hues, /about, /faq, /legal, /support, /changelog, /render, /og, /api/v1) and admin (all /admin/* routes, including /admin/api/*, Section 17.17).

Public profile:

Content-Security-Policy: default-src 'self'; img-src 'self' data: blob: ${SKINFORGE_S3_PUBLIC_BASE_URL}; script-src 'self' 'wasm-unsafe-eval' 'nonce-{PER_RESPONSE_NONCE}'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ${SKINFORGE_S3_PUBLIC_BASE_URL}; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()
Cross-Origin-Resource-Policy: cross-origin
Cross-Origin-Opener-Policy: same-origin
X-Frame-Options: SAMEORIGIN

Admin profile: identical except:

Content-Security-Policy: default-src 'self'; img-src 'self' data: blob: ${SKINFORGE_S3_PUBLIC_BASE_URL}; script-src 'self' 'wasm-unsafe-eval' 'nonce-{PER_RESPONSE_NONCE}'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ${SKINFORGE_S3_PUBLIC_BASE_URL}; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Cross-Origin-Resource-Policy: same-origin
X-Frame-Options: DENY

The middleware generates a fresh, per-response 128-bit base64 nonce and every inline script — the theme script (Section 18.10) and each island's hydration script — carries nonce={nonce}; no <script> is ever inline without it. ${SKINFORGE_S3_PUBLIC_BASE_URL} is substituted only when SKINFORGE_STORAGE_DRIVER=s3 (Section 24.2); on the fs driver it is omitted entirely since renders are served same-origin.

Header Reason
Content-Security-Policy default-src 'self' blocks any third-party script/style/connect injection, consistent with the no-third-party-request privacy stance (Section 20.7); style-src 'unsafe-inline' is required because Fresh/Preact islands set inline styles for dynamic hue-preview swatches; script-src permits no bare inline script — the one blocking theme script (Section 18.10) runs only via its per-response nonce, and 'wasm-unsafe-eval' is required for the client-side WASM compositor (Section 8.12) to compile at all in Chromium-family browsers; img-src data: blob: plus the configured public storage origin allows the client-side composite canvas (Section 8) to embed data/blob-URL sprites and, on the s3 storage driver, to load renders from a different origin than the app; connect-src carries the same storage-origin addition for the same reason; object-src 'none' and base-uri 'none' close classic injection vectors; frame-ancestors differs by profile — public pages are same-origin-framable only, and social unfurls consume the OG image (Section 14) rather than iframing the page, while admin pages must never be framed at all (clickjacking defense)
Strict-Transport-Security Forces HTTPS for a year including subdomains; preload opts into browser HSTS preload lists since the site has no legitimate plain-HTTP use case
Referrer-Policy Sends full referrer to same-origin, only the origin to cross-origin — avoids leaking full permalink paths (which could reveal a design someone shared privately) to third-party analytics on outbound links
X-Content-Type-Options Prevents MIME-sniffing of render/OG image responses or JSON API responses as executable content
Permissions-Policy Explicitly denies powerful browser features the app never uses, reducing the attack surface of any future injected content
Cross-Origin-Resource-Policy cross-origin on public routes intentionally allows hotlinking of render/OG images (the whole point of a shareable permalink and OG preview) while same-origin on admin routes blocks any cross-site read of admin responses
Cross-Origin-Opener-Policy Isolates the browsing context group, mitigating cross-window attacks (e.g. Spectre-class side channels, window.opener abuse)
X-Frame-Options Legacy-browser fallback for frame-ancestors, same reasoning per profile

20.5 Admin security #

Authentication and TOTP enrollment flows are owned by Section 17. This section specifies the security controls around those flows:

  • CSRF: every state-changing admin request (POST/PUT/PATCH/DELETE under /admin/* and /admin/api/*) requires a CSRF token issued as a signed double-submit cookie (sf_csrf, HttpOnly: false so the client script can read it, Secure; SameSite=Lax) matched against a request header X-SF-CSRF. A mismatch or missing token returns SF-4003 (Section 25.2). SameSite=Lax on the session cookie itself already blocks the classic cross-site form-POST CSRF case; the double-submit token is defense in depth against subdomain-hosted content and covers fetch-based CSRF that SameSite=Lax alone does not fully address for non-navigational requests. The CSRF cookie value is base64url(HMAC-SHA256(sessionTokenHash, SKINFORGE_ADMIN_SESSION_SECRET)), so a token minted for one session is not valid for another and a cookie-injection attack on a sibling subdomain cannot forge a matching pair.
  • Session hardening: sessions are server-side rows in admin_sessions (Section 6), referenced by an opaque, high-entropy (128-bit) token in the sf_admin cookie — the token itself carries no decodable claims. Session lifetime, idle timeout and absolute timeout are owned by Section 17.1; this section adds no separate TTL. Session ID is regenerated on login and immediately invalidated on logout, password change, or TOTP re-enrollment.
  • Brute-force protection: login attempts are rate-limited per account exactly as specified in Section 17.2.3 — 5 combined failures in a rolling 15-minute window trigger a 15-minute cooldown returning SF-3003 (429) — counted in admin_login_failures (Section 6). There is no per-IP login lockout, for the reason 17.2.3 gives: an IP-based limit would let an attacker lock a legitimate admin out from a shared or proxied address. There is no exponential backoff beyond that fixed cooldown, and TOTP failures are counted in the same 5-failure budget rather than a separate one, so brute-forcing the 6-digit code within its 30-second window is bounded by the same counter. Section 17.2 owns this mechanism; this section states the control and adds no second one.
  • Privilege separation: role model and the per-action permission matrix are owned by Section 17.3 (three ordered roles — viewer, curator, owner); this section adds no separate role rules. Every admin user (admin_users table, Section 6) is created via CLI only, per Section 17.1's access model; there is no in-console user self-registration or self-elevation.
  • Audit requirements: every admin mutation (publish, retire, edit, user create/delete, settings change, takedown action) writes a row to audit_log (Section 6) with actor (admin user id), action, target, before/after JSON diff where applicable, created_at, and the request's requestId for cross-referencing with structured logs (Section 21.1). Audit log rows are append-only — no update or delete path exists in the application layer.

20.6 Extraction sandboxing #

The extractor (skinforge-cli extract, Section 7) runs entirely offline against a local directory the operator points it at. It never touches the network and never runs inside the public-facing web process — it has no network permission at all, not even a scoped one. It writes an extraction manifest to disk; a separate import command, invoked afterward with its own, distinct permission set, reads that manifest and performs every database and object-storage write. Splitting the two steps is what makes the "never touches the network" claim in this section literally true rather than contradicted by the pipeline's need to reach Postgres. Sandboxing is enforced via Deno's permission model — each command is invoked with an explicit, minimal permission set rather than --allow-all:

Step 1 — extract (offline, sandboxed):

deno run \
  --allow-read="$SKINFORGE_IMPORT_WORKDIR,<operator-supplied-client-dir>" \
  --allow-write="$SKINFORGE_IMPORT_WORKDIR" \
  --allow-env=SKINFORGE_ENV,SKINFORGE_IMPORT_WORKDIR,SKINFORGE_IMPORT_MAX_UPLOAD_MB \
  --no-prompt \
  cli/main.ts extract --source <operator-supplied-client-dir> --build-label <label>

Step 2 — import (network-permitted, ingests the manifest):

deno run \
  --allow-read="$SKINFORGE_IMPORT_WORKDIR" \
  --allow-write="$SKINFORGE_IMPORT_WORKDIR,$SKINFORGE_STORAGE_FS_ROOT" \
  --allow-net="<db-host>:5432,<s3-host>:443" \
  --allow-env=SKINFORGE_ENV,SKINFORGE_DATABASE_URL,SKINFORGE_IMPORT_WORKDIR,SKINFORGE_S3_ENDPOINT,SKINFORGE_S3_BUCKET,SKINFORGE_S3_ACCESS_KEY_ID,SKINFORGE_S3_SECRET_ACCESS_KEY,SKINFORGE_STORAGE_DRIVER,SKINFORGE_STORAGE_FS_ROOT \
  --no-prompt \
  cli/main.ts import --manifest "$SKINFORGE_IMPORT_WORKDIR/<label>.manifest.json"

Notes on each flag:

  • Step 1's --allow-read is scoped to exactly two directories: the import working directory and the operator-supplied source client directory, passed as an explicit CLI argument, never inferred or expanded. Its --allow-write is scoped to the import working directory only — the extractor never writes inside the source client directory (read-only respect for the operator's game installation) and never writes anywhere else on the host.
  • Step 1 has no --allow-net flag at all — the extractor has no legitimate reason to make a network call, closing the SSRF and exfiltration surface (Section 20.1, threat #5) at the permission level, not just by code review. Its --allow-env allowlist correspondingly omits SKINFORGE_DATABASE_URL and every storage credential — the extractor never needs them, because it never talks to Postgres or object storage; it only writes the manifest to disk for step 2 to consume.
  • Step 2's --allow-net lists exactly the database host and the object-storage endpoint it needs to reach to ingest the manifest; it carries no listener since the import command is not a server.
  • Neither command uses --allow-run — nothing ever shells out to another process or binary; all format decoding is pure TypeScript/WASM per Section 4.2's stack decision.
  • --allow-env in both commands is an explicit allowlist, not blanket env access.
  • --no-prompt ensures a permission gap fails closed (the process exits with an error) rather than blocking on an interactive terminal prompt that would hang an unattended re-import run.

Resource limits: the extractor enforces its own internal caps independent of the OS: per-file read cap of 512 MB (larger files are flagged needs_operator_input, never streamed unbounded into memory), per-file decode timeout of 30 seconds (a hung decode is killed and the candidate flagged decode_timeout), and a total run wall-clock budget of 2 hours after which the run is marked partial and resumable (Section 9 owns resumability semantics). These caps directly address threat #6 in Section 20.1 — a malformed or adversarially crafted file can degrade that one candidate's processing, never exhaust host memory or hang the run indefinitely.

20.7 Privacy #

No accounts, no cookies for public visitors. The single exception: the transient editor state in the query string (Section 13) is not a cookie and carries no identifier — it is justified because it is the mechanism by which the designer UI persists in-progress work across a page reload without any server-side state or client-side storage, and it contains only the design fields already defined in Section 13, never anything identifying the visitor. No localStorage, no cookie, no tracking pixel and no analytics storage is used for public visitors. The single exception is a sessionStorage key recording dismissal of the one-time informational banner (Section 11), which contains no identifier, is per-tab, and is never read by the server. Admin visitors receive the sf_admin session cookie and sf_csrf token (Section 20.5) — necessary-for-the-service cookies that, under GDPR Article 6(1)(b)/(f) and the ePrivacy Directive's "strictly necessary" exemption, do not require consent, and which no public/anonymous visitor ever receives.

What is logged: structured request logs per Section 21.1 — method, path, status, duration, truncated IP (below), user agent, referrer. No request body is logged for public endpoints. Admin request logs additionally include the acting admin user id (never a public visitor identifier, since none exists).

IP handling and truncation: the full IP address is used transiently, in memory, for rate-limit bucket keys (Section 20.1). Full IP addresses are additionally written to (a) rate_limit_buckets.bucket_key, (b) admin_sessions.ip_address and audit_log.ip_address for staff accounts only, and (c) Caddy's own access log (Section 23.2) — none of these are public-visitor identifiers in the personal sense, since staff accounts are trusted operators and rate-limit buckets are transient. What is persisted in the application's own structured request logs (Section 21.1) is a truncated form — the last octet zeroed for IPv4 (203.0.113.0) and the last 80 bits zeroed for IPv6 (/48 network) — sufficient for coarse abuse-pattern analysis (Section 21.6) without storing an individually identifying address in that stream. Rate-limit buckets themselves (rate_limit_buckets table, Section 6) key on the full IP but expire and are deleted within 24 hours of the bucket's last activity (Section 6.33).

Retention periods:

Data Retention
Structured request logs (Section 21.1) Container stdout only, rotated by Docker (50 MB × 5 files, Section 21.1); never queried by the application and never a system of record
Rate-limit buckets (full IP, transient) 24 hours after last activity (Section 6.33)
Aggregate daily counters (page_view_daily, design_view_daily, stat_counters, Section 6) Indefinite — contains no per-visitor data, only rollup counts
Designs Never deleted; indefinite and immutable (Section 13.7), except that the takedown path (20.8) marks one taken-down and serves 410 without deleting the row
Admin audit log Indefinite — an operational and legal record
Admin sessions Soft-revoked (revoked_at set) on expiry, logout or admin action per Section 17.1's TTLs; revoked rows are pruned after 30 days (Section 6.33). Rows are never hard-deleted, so the session audit trail survives the revocation
Caddy access log (/data/access.log, full untruncated IPs, Section 23.2) Operator-managed infrastructure logging, excluded from the application-log rotation policy above (Section 21.1)

Why no consent banner is required: the site sets no cookie and performs no client-side storage for any visitor who is not staff logging into the admin console. It loads no third-party script, no third-party font, no third-party analytics, and no third-party embed (Section 20.4's CSP structurally enforces this — default-src 'self' would block any such inclusion). Under the GDPR and the ePrivacy Directive, a consent banner is required for non-essential tracking technologies; since none are used, none is shown. The privacy statement (below) discloses this reasoning explicitly so the absence of a banner is not itself confusing to a visitor who expects one.

Plain-English privacy statement (published at /legal, Section 10):

SkinForge does not require an account and does not use tracking cookies. We do not run analytics scripts, advertising, or any third-party tracker. When you visit the site, our server temporarily sees your IP address to prevent abuse (for example, to stop one visitor from overwhelming the service); only a coarsened version of it ever appears in our application logs, which are a rotating operational tail we do not keep or query long-term. If you create a character design, it is saved so its shareable link keeps working — the design itself contains no personal information, only your chosen character appearance. We do not sell or share any data, because we do not collect any to sell. If you have questions, contact us at the address on our support page.

Unofficial/not-affiliated disclaimer — the exact wording is owned by Section 10.4 and is reproduced verbatim wherever it appears, including in the footer of every public page (Section 10 owns placement):

Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts.

All game art, names, and trademarks referenced or displayed are the property of their respective owners and are used here solely to preview character appearances for players of the game. No raw game files are distributed by this site — every image shown is a preview rendered from data extracted by the site operator from their own licensed client installation.

No raw game files are ever distributed. Only derived preview renders (PNG/WebP composites, Section 8) are served publicly. The original MUL/UOP/hue-table bytes never leave the server's private storage; there is no download, export, or API endpoint anywhere in the system (Section 16's endpoint list, Section 10's route list) that returns raw extracted game data. This is a structural property, not a policy note — no route handler in the codebase has read access to the raw source-file storage location; only the CLI/import process does (Section 20.6).

Operator must own a legitimate client installation. The extractor operates on a local directory the operator supplies (Section 7); the documentation (Section 27) states plainly that the operator is responsible for running the tool only against a client installation they are legitimately entitled to use, and the tool performs no license verification of its own — that responsibility is the operator's, stated here as a condition of use rather than a technical control.

Trademark acknowledgement: stated in the paragraph above ("the property of their respective owners") and repeated in full on /legal alongside the takedown process.

Takedown/DMCA process, published at /legal:

If you are a rights holder and believe content on SkinForge infringes your rights, contact us at the address below with (1) a description of the content, (2) its URL, and (3) your contact information. We will review requests within 5 business days and remove or disable access to content found to infringe. Contact: the address configured for this purpose (see below).

The contact address is SKINFORGE_CONTACT_EMAIL (Section 24), rendered into the /legal and /support pages at build/request time rather than hard-coded, so it stays a single source of truth. Response SLA: 5 business days to first response, stated in the published text above.

410 behaviour for removed designs: a design actioned through the takedown workflow (takedown_requests table, Section 6; workflow owned by Section 17) is marked with a takedown timestamp. Its permalink (/d/:code) and every render URL derived from it (/render/d/:code@..., /og/d/:code.png) return HTTP 410 Gone with error SF-2005 (Section 25.2) and a short HTML page stating the content was removed following a rights request, rather than 404410 signals permanence to crawlers and CDNs so the URL is dropped from caches and search indexes rather than retried. This is the sole exception to the immutability rule in Section 13.7, and it is implemented as a status flag checked before the normal render/lookup path, not by deleting the underlying row (audit and reversal remain possible; Section 17 owns the reversal workflow).

20.9 Content licensing #

The tool's own source code, original UI copy, and original design-system assets (Section 18) are the operator's work product; the specification does not mandate a particular open-source license and leaves that choice to the operator as a business decision outside this document's scope, except to state that whatever license is chosen must be recorded in a LICENSE file in the repository root (Section 5 owns repository layout) so the choice is unambiguous to contributors.

Game art, sprite data, hue tables, and any other content extracted from UO Outlands client files remain the property of their original owners at all times. SkinForge's database stores only positional/provenance metadata and derived renders (Section 6, Section 9) — it never asserts ownership over the underlying art, and the disclaimer in Section 20.8 states this explicitly and persistently.

20.10 Dependency and supply-chain security #

Lockfile policy: deno.lock is committed to the repository and is the sole source of truth for resolved dependency versions at deploy time; deno.json's import map states version ranges per Section 4's major-line policy, and deno.lock pins the exact resolved versions within those ranges. CI (Section 22.11) fails if deno.lock is out of sync with deno.json (deno install --frozen detects drift).

Integrity checking: Deno verifies module integrity against deno.lock's recorded hashes on every run; a tampered or substituted module fails to load rather than silently executing. npm: dependency integrity is additionally covered by the standard npm package-lock-equivalent hash verification Deno performs when resolving npm: specifiers.

Update cadence: dependency versions are reviewed monthly (a recurring operator task, Section 23.10) and immediately upon a published security advisory affecting a direct dependency. Updates follow the major-line policy in Section 4 — patch and minor updates within the stated major line are applied routinely; a major-line bump is a deliberate, tested change, not an automatic one.

Vulnerability scanning: CI runs deno outdated and an npm-advisory check against the resolved npm: specifiers (Section 22.11) on every push to the main branch and on a weekly schedule independent of pushes, so a newly published advisory against an already-deployed dependency is caught even without new commits. A finding at "high" or "critical" severity blocks the release gate (Section 22.11) until resolved or explicitly waived with a recorded reason in the CI configuration.

Deno permission flags used in production: the web process runs with an explicit permission set, never --allow-all:

deno run \
  --allow-net="0.0.0.0:${SKINFORGE_PORT},postgres:5432,minio:9000,api.cdn.example:443" \
  --allow-read="/app,${SKINFORGE_STORAGE_FS_ROOT},${SKINFORGE_RENDER_CACHE_DIR},${SKINFORGE_IMPORT_WORKDIR}" \
  --allow-write="${SKINFORGE_STORAGE_FS_ROOT},${SKINFORGE_RENDER_CACHE_DIR},${SKINFORGE_IMPORT_WORKDIR}" \
  --allow-env \
  --no-prompt \
  main.ts

--allow-net lists the listen address plus every outbound host the web process actually dials — Deno's --allow-net allowlist governs outbound connects as well as inbound listens, so omitting the database host, the object-storage endpoint and the CDN purge host would make it impossible for the process to ever reach Postgres, object storage, or the CDN purge API (Section 19.5). postgres and minio are the Compose service DNS names (Section 23.2); api.cdn.example is a placeholder for the operator's real SKINFORGE_CDN_PURGE_URL host (Section 24.2) and must be replaced with it. --allow-read additionally covers the storage filesystem root and the render cache directory — the fs driver reads stored renders and catalog objects back out of both on every cache hit, not just writes them — and the import work directory (the admin candidate-preview route, Section 7.11, which reads it server-side). --allow-write covers the same three directories: the storage root and render cache for generated images, and the import work directory because the admin ZIP-upload source mode (Section 17.5.1) has the web process stream an uploaded archive into it. Omitting the third would make that upload fail on its first written byte with Deno.errors.PermissionDenied, and --no-prompt makes that failure hard rather than an interactive prompt.

The flags above are written with ${VARIABLE} names for readability. Docker performs no variable expansion inside an exec-form CMD, so the Dockerfile in Section 23.2 carries the same flags with the literal container paths those variables resolve to (/data/storage, /data/render-cache, /data/import-work) — the two are the same permission set, written for two different readers. --allow-env is unscoped here (unlike the extractor in Section 20.6) because the web process legitimately consumes the full SKINFORGE_* configuration table (Section 24); this is an accepted, documented asymmetry — the web process's blast radius from a compromised dependency is bounded instead by the narrow, explicitly-listed --allow-net/--allow-read/--allow-write scopes, which prevent arbitrary outbound connections or filesystem access even if application code is compromised.

20.11 Incident response #

Severity levels:

Severity Definition Example
SEV1 Public site down, or data integrity/confidentiality breach in progress Database compromised, admin account taken over, site returning 5xx for all traffic
SEV2 Significant degradation, no confirmed breach Render pipeline down, sustained high error rate on one route class
SEV3 Localized or cosmetic issue One asset renders incorrectly, a single admin workflow broken

First responder steps (the operator, since this is a one-person-operator system per Section 23.10):

  1. Confirm scope using the dashboards in Section 21.4 and the alert that fired (Section 21.5).
  2. For a suspected breach (SEV1): rotate the affected secret immediately (Section 24.5's rotation procedure), invalidate all admin sessions with UPDATE admin_sessions SET revoked_at = now() WHERE revoked_at IS NULL; — a soft revoke, never a DELETE, so the session rows remain available for the incident timeline (Section 6.23) — and enable maintenance mode (Section 23.9) if public data integrity is in question.
  3. Contain: for a render-queue or rate-limit-driven outage, tighten the relevant SKINFORGE_RATE_LIMIT_* value (Section 24) and redeploy; for a bad publish, roll back to the prior build (Section 9's rollback rule, Section 23.5).
  4. Eradicate and recover: apply the fix, verify with the smoke test (Section 23.5), disable maintenance mode.
  5. Record a timeline as it happens — this becomes the post-incident review input.

Communication template, used for any SEV1/SEV2 publicly visible incident, posted on /changelog or the operator's chosen community channel:

[Date] — SkinForge experienced [brief description] between [start] and [end]. Impact: [what users saw]. Cause: [one sentence, non-technical]. Status: resolved / monitoring. We will [any follow-up action] to reduce the chance of recurrence.

Post-incident review requirement: every SEV1 and SEV2 incident gets a written review within 7 days covering timeline, root cause, what worked, what did not, and concrete follow-up actions with owners (the operator, in the one-person model) — filed as a dated entry in the operations log referenced in Section 23.10. SEV3 incidents are logged but do not require a formal review unless a pattern of related SEV3s emerges (three or more related SEV3s in 30 days is treated as one SEV2 for review purposes).

20.12 Security testing requirements and pre-launch checklist #

Security testing is specified in full in Section 22.8: dependency audit, HTTP header assertions, CSP validation, an authorization matrix test suite (every admin route tested for a rejection when called without a session, with an expired session, and with a non-admin-scoped session), and fuzzing of the MUL/UOP/hue-table format readers with malformed and truncated inputs.

Pre-launch security checklist, run once before the first production deploy and again before any major release (Section 22.12 ties this to the definition of done):

  • All HTTP security headers present and correct on both profiles (20.4), verified by the CI header-assertion test (22.8).
  • CSP contains no bare 'unsafe-eval' token in any directive — matched as a whole token, never by substring, since the expected and required 'wasm-unsafe-eval' contains unsafe-eval as a substring and a substring check would fail on a correct policy; unsafe-inline appears only in style-src; every inline script is permitted only via the per-response nonce, never a bare 'unsafe-inline' in script-src (20.4).
  • Admin login requires TOTP with no bypass path; TOTP recovery codes (admin_recovery_codes, Section 6) are single-use and hashed at rest.
  • CSRF protection verified on every state-changing admin route (20.5).
  • Rate limits configured and verified against the values in Section 24, not left at development-friendly defaults.
  • deno.lock committed and CI drift check passing (20.10).
  • Dependency audit clean of high/critical findings, or findings explicitly waived and recorded (20.10).
  • Extractor invoked only with the scoped permission flags in Section 20.6, verified by inspecting the CLI's documented invocation, never --allow-all.
  • No secret present in the repository (git log -p scan plus .gitignore covering .env*, Section 24.5).
  • Object storage bucket/filesystem root is not publicly listable/browsable independent of the application (verified against the storage driver configuration, Section 23.2).
  • Takedown workflow tested end to end: a design actioned through it returns 410 on every derived URL (20.8).
  • Privacy statement (20.7) published at /legal and matches the actual data practices verified by code review of logging call sites.
  • Backup restore drill (Section 23.6) completed successfully within the last 90 days.

21. Observability — Logging, Metrics & Analytics #

21.1 Logging #

All application logs are structured JSON, one object per line, written to stdout (captured by Docker Compose's logging driver per Section 23.2). No plain-text log lines anywhere in the codebase.

Log schema — every log line carries these fields; handler-specific fields are added on top:

Field Type Always present Description
timestamp string, ISO-8601 UTC with Z yes Event time, generated at log-call time, not at flush time
level "debug" | "info" | "warn" | "error" yes See level guidance below
event string, dot-namespaced yes Machine-readable event name, e.g. design.created, see the catalogue in 21.2
requestId string, ULID yes for request-scoped logs Matches the requestId in the API error envelope (Section 16), correlates every log line for one request
route string yes for HTTP logs The matched route pattern, e.g. /d/:code, never the raw path with live values
method string yes for HTTP logs HTTP method
status number yes for HTTP logs Response status code
durationMs number yes for HTTP/job logs Wall-clock duration of the handler or job
buildId string, ULID when relevant The active game build involved, for render/catalog/import events
actor string, ULID admin events only The acting admin_users.id; never present for public-visitor events since none are identified (Section 20.7)
errorCode string on error The SF-XXXX code (Section 25)
errorMessage string on error Internal diagnostic message — distinct from the user-facing message in the API envelope, may include stack context
ip string HTTP logs only Truncated per Section 20.7 (last octet/80 bits zeroed) before it is ever passed to the logger — the logger never receives a full IP

Levels and when to use each:

Level Use
debug Verbose internal state, disabled by default (SKINFORGE_LOG_LEVEL=info default per Section 24), enabled only for local troubleshooting
info Normal operational events: requests served, designs created, jobs completed, imports progressing
warn Recoverable anomalies: a retry occurred, a cache purge failed and fell back, a rate limit was hit, an extraction candidate needed operator input
error Unrecovered failures: an unhandled exception, a job exhausted its retries, a render failed, an internal error (SF-9xxx) was returned to a client

What must never be logged: full IP addresses (only the truncated form, above), session tokens, CSRF tokens, TOTP secrets or codes, password hashes, admin recovery codes, database connection strings, S3 credentials, or any raw request body for public endpoints. Admin request bodies are logged only with secret-bearing fields (password, totpCode, any *_secret/*_token key) redacted by a shared redactSecrets() pass applied before every log call that includes a request body — this is enforced by a lint rule (Section 5) that forbids console.log/direct body interpolation outside the logging module.

Sampling: info-level HTTP access logs for high-volume, low-value routes (/render/... cache hits served without touching application logic beyond a storage read) are sampled at 10% in production (configurable via SKINFORGE_LOG_LEVEL's effect combined with an internal sampler, not a separate env var — sampling only applies to the specific render-hit access log event, never to warn/error events, which are always logged at 100%). All other events are logged at 100%.

Rotation: the application does not implement its own log rotation — stdout is captured by Docker's json-file logging driver configured with max-size: "50m" and max-file: "5" in the Compose file (Section 23.2), giving a bounded 250 MB per container with automatic rotation. Container stdout is the only place structured request logs live; it is an operational tail for debugging, never a queryable system of record. This rotation policy is the whole of the application's log-retention commitment — there is no separate 30-day log-retention promise anywhere in this document, and Sections 20.7 and 23.2 refer back to this paragraph rather than to one. Anything that must survive rotation lives instead in the synchronously-written aggregate tables — page_view_daily and design_view_daily (Section 6, Section 21.6) — which contain no per-visitor data and are retained indefinitely; the specification does not mandate shipping raw stdout anywhere beyond Docker's own rotation.

21.2 Event catalogue #

Every named event the application emits, grouped by area. Each event's row lists the event-specific fields beyond the common schema in 21.1.

Public traffic:

Event Fields
http.request route, method, status, durationMs — emitted for every HTTP response
design.created designId, shortCode, buildId, slotCount
design.resolved shortCode, cacheHit (boolean), found (boolean)
design.takedown_served shortCode — a 410 was served for a taken-down design
render.served renderKey, scale, format, cacheHit, durationMs
render.queued renderKey, queueDepth
render.failed renderKey, errorCode, durationMs
og.generated shortCode, durationMs
catalog.viewed slotKey (nullable), assetKey (nullable)
rate_limit.exceeded bucket, limit, windowSeconds

Import/extraction:

Event Fields
import.run_started importRunId, sourcePath (host path is never logged; only a redacted label the operator assigns per run)
import.stage_completed importRunId, stage (one of inventory|identify|probe|extract|classify|normalize|import|verify, Section 6.6), durationMs, itemCount
import.candidate_flagged importRunId, candidateId, reason (needs_operator_input, decode_timeout, checksum_mismatch)
import.string_truncated importRunId, field, originalLength
import.run_completed importRunId, durationMs, candidateCount, status (succeeded|failed|cancelled, Section 6.6)
build.published buildId, actor, assetCount
build.rolled_back buildId, previousBuildId, actor

Admin:

Event Fields
admin.login_succeeded actor
admin.login_failed reason (bad_password|bad_totp|locked), attempted account identifier is not logged in cleartext beyond an internal hash for lockout accounting
admin.session_revoked actor, reason (logout|expired|admin_action)
admin.publish.purge_failed buildId, attempt, errorCode
admin.audit_write actor, action, target — mirrors the audit_log row (Section 20.5) into the log stream for real-time alerting
admin.takedown_actioned actor, shortCode, takedownRequestId

Jobs:

Event Fields
job.claimed jobId, jobType, attempt
job.completed jobId, jobType, durationMs
job.failed jobId, jobType, attempt, errorCode, willRetry (boolean)
job.exhausted jobId, jobType, attempts — final failure after all retries

System:

Event Fields
app.started version, env, effectiveConfigSummary (non-secret config, per Section 24.6)
app.migration_applied migrationName
app.shutdown_initiated reason (sigterm|sigint)

21.3 Metrics #

Exposed at GET /metrics in Prometheus text exposition format, protected by a bearer token (SKINFORGE_METRICS_TOKEN, Section 24) checked against the Authorization header — this endpoint is never publicly cacheable (no-store, Section 19.3) and never listed in the public route map (Section 10). Gated entirely by SKINFORGE_METRICS_ENABLED; when false, the route returns 404.

Registry:

Metric Type Labels Description
skinforge_http_requests_total Counter route, method, status Total HTTP requests
skinforge_http_request_duration_ms Histogram route, method Request latency, buckets at 5/10/25/50/100/250/500/1000/2500ms
skinforge_render_queue_depth Gauge Render jobs currently in queued status (Section 6's jobs.status vocabulary: queued, running, succeeded, failed, cancelled — a job is never pending)
skinforge_render_duration_ms Histogram scale, format, cacheHit Render latency, same buckets as above
skinforge_cache_hit_ratio Gauge layer (cdn|lru|storage) Rolling 5-minute hit ratio, computed from counters below
skinforge_cache_hits_total Counter layer Cache hits by layer
skinforge_cache_misses_total Counter layer Cache misses by layer
skinforge_designs_created_total Counter Total designs created
skinforge_import_stage_duration_ms Histogram stage Import stage duration, labelled with the import_runs.stage value (Section 6.6: inventory, identify, probe, extract, classify, normalize, import, verify) — never a D1D6 abbreviation, which is Section 7's internal name for the pipeline stage, not a column value
skinforge_job_failures_total Counter jobType Job failures after final retry
skinforge_job_queue_depth Gauge jobType Jobs in queued status, by type
skinforge_storage_bytes Gauge kind (catalog|renders|og|imports) Object storage usage, sampled hourly
skinforge_db_pool_in_use Gauge Active Postgres connections held by the pool
skinforge_rate_limit_rejections_total Counter bucket Requests rejected by rate limiting
skinforge_admin_login_failures_total Counter reason Failed admin login attempts

Exposition format: standard Prometheus text format v0.0.4, generated by an in-process registry (core/metrics/registry.ts) with no third-party metrics dependency — counters/gauges/histograms are maintained as plain in-memory structures and rendered to text on scrape, consistent with the no-Redis/no-broker architecture decision (Section 4.9).

Scrape guidance: scrape interval 15 seconds, recommended via a self-hosted Prometheus container or the operator's existing monitoring stack (Section 21.10 does not mandate a specific product); histograms are cheap enough at this traffic volume (Section 19.10) that no down-sampling is needed.

21.4 Dashboards #

Four dashboards, each described panel by panel. Implementation is left to whatever the operator points at the /metrics endpoint (Grafana is the reference assumption for panel layout, but the metric names in 21.3 are the actual contract).

Traffic dashboard:

  1. Requests per second, stacked by route class (line, 5-minute rate of skinforge_http_requests_total).
  2. Status code breakdown (2xx/3xx/4xx/5xx stacked area, from the same counter's status label).
  3. p50/p95/p99 request duration by route class (from skinforge_http_request_duration_ms).
  4. Top 10 permalinks by view rate over the last 7 days (derived from design_view_daily, Section 6, Section 21.6 — synchronously written at request time, not log-derived; annotated on the panel as aggregate-table-derived).

Rendering dashboard:

  1. Render queue depth over time (skinforge_render_queue_depth).
  2. Render duration p50/p95 split by cache-hit vs cold (skinforge_render_duration_ms).
  3. Cache hit ratio by layer (skinforge_cache_hit_ratio).
  4. Render failures per minute (render.failed event rate).
  5. Storage bytes by kind, trended over 30 days (skinforge_storage_bytes).

Ingestion dashboard:

  1. Import stage duration, latest run, one bar per stage — eight bars, in import_runs.stage order (Section 6.6) — from skinforge_import_stage_duration_ms.
  2. Candidates flagged over time, by reason (import.candidate_flagged event rate, stacked by reason).
  3. Job queue depth by type (skinforge_job_queue_depth).
  4. Job failure rate by type (skinforge_job_failures_total).
  5. Time since last successful build publish (single-stat panel, derived from build.published events).

Health dashboard:

  1. Uptime / restart count (from process start events, app.started).
  2. Database connection pool utilization (skinforge_db_pool_in_use vs configured SKINFORGE_DB_POOL_SIZE).
  3. Admin login failure rate (skinforge_admin_login_failures_total).
  4. Rate-limit rejection rate by bucket (skinforge_rate_limit_rejections_total).
  5. Current readiness/liveness status (from the /health//health/ready checks in 21.7, polled externally).

21.5 Alerts #

Alert Condition Threshold Severity First response
Site down /health fails 3 consecutive failed external checks (1-minute interval) SEV1 Follow Section 20.11 incident response
High 5xx rate 5xx / total requests > 5% over 5 minutes SEV1 Check the traffic and health dashboards; likely a bad deploy — consider rollback (Section 23.5)
Render queue backed up skinforge_render_queue_depth > 150 for 5 minutes (soft limit is 200, Section 19.8) SEV2 Check for an abuse pattern (Section 20.1 threat #2) vs a legitimate traffic spike (Section 19.10); tighten render rate limit if abuse
Render failure spike render.failed rate > 10/minute for 5 minutes SEV2 Check errorCode distribution; likely a missing sprite from a bad publish or an encoder fault
DB pool saturation skinforge_db_pool_in_use SKINFORGE_DB_POOL_SIZE for 2 minutes SEV2 Check for a slow-query regression (Section 19.7) or a connection leak
Job failures skinforge_job_failures_total rate > 5 in 10 minutes for one jobType SEV2 Inspect job.exhausted logs for the failing type; check downstream dependency (storage, DB)
Admin login abuse skinforge_admin_login_failures_total rate > 20/hour SEV2 Confirm brute-force protection engaged (Section 20.5); consider IP block at the CDN/firewall layer
CDN purge failures admin.publish.purge_failed any occurrence after all retries exhausted SEV3 Manually purge per Section 19.5's fallback; investigate purge endpoint health
Storage growth anomaly skinforge_storage_bytes week-over-week delta > 3x the trailing 4-week average growth rate SEV3 Check for a design-spam pattern (Section 20.1 threat #3) or a runaway render cache
Certificate expiry Caddy-managed TLS cert < 14 days to expiry SEV3 Should auto-renew (Section 23.2); investigate Caddy ACME logs if not

21.6 Product analytics without tracking #

Counted server-side, with no client-side tracking script and no per-visitor identifier (Section 20.7): design creations, permalink views (design.resolved events), catalog page views (catalog.viewed events), and top assets/hues by appearance count in created designs.

Bot exclusion: the bot filter used for every view/page-count purpose is defined exactly once, in Section 13.10.2, and applied identically here — no second definition exists. Excluded requests are still served, still logged (21.1), and still count toward rate limits (Section 20.1); the exclusion applies only to the analytics rollup, not to serving or protection.

Counting is synchronous, not log-derived. Page views are written at request time into page_view_daily and per-design views into design_view_daily (Section 6), using the bot filter defined once in Section 13.10.2. A nightly job (Section 6 jobs table, run once daily at 00:10 UTC) only computes the derived top-N asset/hue counters in stat_counters from designs.canonical_json; it reads no logs and no raw request data. The job is idempotent — re-running it for a day it has already rolled up recomputes and overwrites that day's counters rather than double-counting.

Retention of raw versus rolled-up data: structured request logs live only in container stdout, rotated by Docker (Section 21.1, Section 20.7) — there is no queryable raw-log retention job. Rolled-up daily aggregate rows in page_view_daily, design_view_daily and stat_counters (Section 6) are retained indefinitely since they contain no per-visitor data, only counts, and are cheap to store at one row per route-class/design per day.

21.7 Health checks #

Two endpoints, both no-store (Section 19.3), both excluded from the public route list in Section 10 (operational endpoints, not product surface) but reachable without authentication since external uptime monitors need them. This section is the sole owner of both; Section 16.8 references them without redefining their shape, and GET /api/v1/version remains a separate, versioned endpoint.

GET /health — liveness. Returns 200 if the process is running and able to handle requests at all, independent of dependency health. Used by Docker Compose's healthcheck (Section 23.2) and container orchestration to decide whether to restart the container.

{ "status": "ok", "version": "2026.09.1" }

GET /health/ready — readiness. Returns 200 only if every hard dependency is reachable; 503 if any is not, with the specific failing dependency named so the health dashboard (21.4) can pinpoint the cause immediately. database and storage are the two checks mandated as readiness signals; jobRunner is included as an additional, non-critical signal.

{
  "status": "ok",
  "checks": {
    "database": { "status": "ok", "latencyMs": 3 },
    "storage": { "status": "ok", "latencyMs": 8 },
    "jobRunner": { "status": "ok", "queueDepth": 4 }
  }
}

A failing check replaces its "status": "ok" with "status": "error" and adds an "error" string field; the top-level status becomes "degraded" (some non-critical check failing, e.g. job runner slow but DB/storage fine — still returns 200 since the site can serve reads) or "error" (a critical check failing, e.g. database unreachable — returns 503). database and storage are critical; jobRunner being behind is degraded, not critical, since cached/already-rendered content still serves.

21.8 Tracing decision #

Decision: no distributed tracing system (no OpenTelemetry collector, no Jaeger/Tempo/Zipkin) in v1. Justification: the deployment topology is a single Deno process talking to Postgres and object storage on the same or an adjacent host (Section 23.1) — there is no multi-service call graph to trace. A trace would add operational complexity (a collector to run, another dependency to secure per Section 20.10) for a topology where the correlation problem it solves does not yet exist.

Correlation-id approach that replaces it: every inbound HTTP request is assigned a requestId (ULID, generated at the top of the middleware chain if the client did not supply one) which appears in: the structured log line for that request (21.1), every log line emitted by code called during that request's handling (passed through as request-scoped context, not a global), the API error envelope (Section 16), and any job enqueued as a direct result of that request (the job's jobId is distinct, but its first log line records the originating requestId). This gives full request-to- completion traceability across the synchronous HTTP path and the asynchronous job path within the single process, which is the entire call graph that exists — sufficient without a tracing backend. If a future version introduces additional services (Section 23.12's scaling path), this correlation ID is the field a tracing system would key on, so the decision does not foreclose adding one later.

21.9 Operational reporting #

A weekly summary is rendered as a panel on /admin/dashboard (Section 17.4), computed on demand from the aggregate tables each time it is viewed, for the week ending on the requested Sunday (defaulting to the most recent one). It is deliberately not stored: every input is already a retained aggregate row, so a stored copy would be a second, drifting source of the same numbers, and no report retention rule or storage location is needed. In particular it is not written to app_settings, whose contents are the operator-editable settings enumerated in Section 17.14 and Section 24.1 and nothing else. A scheduled job (Sunday 00:20 UTC, immediately after the daily rollup in Section 21.6) warms the panel's query so the first view of the week is fast, and emits nothing else. SkinForge sends no outbound email of any kind; this in-app dashboard panel is the entire delivery surface for the report (Section 17.12). Contents, all sourced from page_view_daily and stat_counters (21.6) plus the metrics registry (21.3) sampled at report time:

  • Total page views and unique route-class breakdown for the week, and week-over-week delta.
  • Designs created this week, cumulative total, and the top 10 assets and top 10 hues by appearance count across designs created this week.
  • Render cache hit ratio for the week (from skinforge_cache_hit_ratio history, averaged).
  • Any SEV1/SEV2 incidents in the week (pulled from the post-incident review log, Section 20.11).
  • Storage bytes used and week-over-week growth.
  • Pending admin queue sizes at report time: unreviewed extraction candidates, open takedown requests.

The report is generated server-side from the same data the dashboards read — it introduces no new data source and no new table, only a summary view over existing aggregates, keeping the one-person-operator model (Section 23.10) informed through a single dashboard panel rather than requiring the operator to piece the same picture together from raw metrics.

22. Testing Strategy & Quality Gates #

22.1 Test pyramid #

Layer Target count Runtime budget Runs on
Unit ~600+ tests ≤ 60s total Every push, every PR
Integration (HTTP + real Postgres) ~150 tests ≤ 4 minutes total Every push, every PR
Golden-image (render/compositor) ~40 fixtures × 3 scales × 2 formats = ~240 assertions ≤ 90s total Every push, every PR
End-to-end (Playwright) ~25 scenarios × 3 viewports where noted ≤ 8 minutes total Every PR, and on merge to main
Accessibility (axe + manual) Automated: every route in Section 10.1's site map (13 routes) plus the four error pages in Section 10.9 (17 total); manual: quarterly and at every launch gate Automated ≤ 2 minutes Automated: every PR; manual: quarterly, pre-major-release
Performance (k6 + Lighthouse CI) 2 load scenarios + budget checks on 6 routes ≤ 10 minutes total Pre-release gate only (Section 22.11), not every PR
Security Dependency audit, header assertions, authz matrix (~40 cases), fuzzing (4 format readers, 10k iterations each) ≤ 5 minutes for audit/headers/authz; fuzzing runs nightly, not per-PR Audit/headers/authz: every PR; fuzzing: nightly schedule
Pipeline (extraction/import) ~30 tests against the synthetic fixture client ≤ 3 minutes Every PR

Total CI wall-clock for the PR-blocking layers (unit, integration, golden-image, E2E, a11y automated, security minus fuzzing, pipeline): under 20 minutes, parallelized across CI jobs per Section 22.11.

22.2 Unit tests #

Run with Deno's built-in test runner (deno test), no external test framework dependency, consistent with the minimal-dependency architecture decision (Section 4.2). Every module path named in this section is a path Section 5.1's repository tree defines; that tree is the authority and this section never introduces a path of its own.

What must be unit tested: every pure function in core/ — hue math, canonical JSON serialization, short-code encoding/decoding, cursor pagination encode/decode, search grammar parsing, Zod schema modules (both accept and reject cases), format-reader byte-level decoding functions, and every repository module's SQL-building logic (using a query-capturing stub, not a live database — live-database behavior is integration-tested per 22.4).

Naming and placement: unit tests are co-located beside the module they test and named <module>_test.ts — the Deno runtime's own convention, so deno test core/ discovers them with no configuration (e.g. core/hue/apply.tscore/hue/apply_test.ts). They never live in a parallel __tests__ tree and never in a mirrored tests/unit/ tree, so a reader browsing core/hue/ sees the test immediately next to the code. Integration and end-to-end suites are the opposite: they are not tied to one module, so they live under tests/ (tests/integration/, tests/e2e/, tests/golden/, tests/load/), and every committed fixture lives under tests/fixtures/. Section 5.1 defines these directories; this section states the testing-specific application of them.

Structure: Deno.test() with a descriptive name string in the form "<unit> — <behavior under test>", e.g. Deno.test("applyHue — partial hue only remaps grey pixels"). Related cases group under Deno.test.step inside one top-level Deno.test when they share expensive setup (e.g. a decoded fixture sprite); independent cases are independent top-level tests so failures report individually.

Fixtures: small, hand-constructed, committed binary/JSON fixtures under tests/fixtures/units/ (e.g. a 4×4 known-pixel gump, a 3-entry hue table) — never derived from real game files (Section 22.10's "no real game files in tests" rule) and never randomly generated without a fixed seed (determinism requirement, 22.2 applies to every layer that touches fixtures).

High-risk modules and their specific required tests:

Module Required test cases
Hue math (core/hue/apply.ts) Partial hue remaps a pixel only when r8 === g8 === b8 (exact equality, no tolerance window); a partial hue on a non-grey pixel leaves it unchanged; full hue remaps every pixel using table index r8 >> 3 (the pixel's 5-bit red channel, range 0-31), per Section 8.3; hue index 0 passes every pixel through untouched; a hue table with fewer than 32 entries is rejected at load time, not at apply time
ARGB1555 unpacking (core/hue/unpack.ts) unpackArgb1555 round-trips a known 16-bit value to {r,g,b,a} using the 5-to-8-bit channel expansion in Section 8.3.1; color === 0x0000 decodes to a = 0; every other value, including 0x8000, decodes to a = 255 since bit 15 is never consulted (Section 8.3.1); boundary values 0x0001 and 0xFFFF round-trip correctly
MUL/IDX reader (core/formats/mul.ts) A well-formed 12-byte index entry resolves to the correct offset/length; a length of -1 (removed entry) is skipped, not read; an index entry pointing past end-of-file raises a decode error rather than an out-of-bounds read
UOP reader (core/formats/uop.ts) Magic bytes MYP\0 validated before any further parsing; block-chain traversal terminates on a self-referential or cyclic next-block pointer (fuzz-adjacent unit case) rather than looping forever; 64-bit path hash lookup matches a known fixture hash
Canonical JSON (core/design/canonicalize.ts) Two structurally-equal design objects with keys in different insertion order produce byte-identical canonical output; an empty slots array serializes as [] not omitted; unknown top-level keys are rejected before canonicalization is attempted
Short-code encoding (core/design/short-code.ts) A known SHA-256 input produces the documented 10-character Crockford-Base32-minus-ambiguous output; decoding rejects any character outside the restricted alphabet; the salt-increment collision path produces a different code on the second attempt for the same input
Cursor pagination (core/api/cursor.ts) Encode-then-decode round-trips to the identical position; a tampered (bit-flipped) cursor fails HMAC verification and returns the malformed-cursor error rather than an incorrect page; the final page returns nextCursor: null
Search grammar parsing (core/search/tsvector.ts) Each supported token type (bare term, slot:, hue:, quoted phrase) parses to the correct AST node; an unterminated quote is a graceful parse error, not a thrown exception that reaches the HTTP layer unhandled; a 200-character input at the cap parses without truncation mid-token
Boolean config parser (core/config.ts) The exact string "false" parses to boolean false, never true; the exact string "true" parses to true; any value other than the exact strings true/false fails validation at boot rather than silently coercing (Section 24.3)

22.3 Golden-image tests #

Fixture set: a synthetic mini client committed to the repository under tests/fixtures/synthetic-client/, containing hand-authored MUL/IDX pairs and a small hues.mul-format file covering: 6 synthetic sprites (one per major slot family: body, hair, torso_inner, footwear, torso_outer, head) at known, deliberately non-photographic pixel patterns (solid colors, a checkerboard, a single-pixel-wide diagonal line) chosen so any compositing or hue-math regression produces an immediately visible, easily diffed pixel change; 8 synthetic hues covering unhued (0), a partial-hue grey-ramp hue, a full-hue saturated hue, and boundary hue indexes. This fixture set is also the input to the pipeline tests in 22.9 and the E2E synthetic-import scenario in 22.5.

Expected artifacts: for each of the 40+ meaningful combinations (a subset of body × slot-asset × hue × scale × format sufficient to exercise every composite z-order boundary in Section 3's slot table and every hue-math branch), a pre-rendered PNG is committed under tests/golden/fixtures/<combination-name>.png (the directory Section 5.1's deno.json excludes from deno fmt/deno lint, since it holds binary art rather than source). The comparison test itself is tests/golden/composite_test.ts. Goldens are regenerated deliberately (never automatically) via deno task test:golden:update, which requires an explicit flag (--confirm-visual-review) and is a manual step a developer runs only after visually confirming the new output is correct — CI never regenerates goldens itself.

Comparison method: exact byte equality after decode, zero tolerance. The test decodes both the freshly rendered PNG and the committed golden PNG to raw RGBA buffers (never comparing compressed bytes directly, since two correct encodes of identical pixels are not guaranteed to be byte-identical at the container level) and asserts every pixel's RGBA channel values match exactly. Zero tolerance is deliberate: this is pixel art with hard edges and a hue table that is a discrete lookup, so there is no legitimate source of small numerical drift — any difference at all indicates a real regression in compositing, hue math, or encoding.

Failure artifact: on mismatch, the test writes three files to a CI artifact directory: <name>.actual.png (what was rendered), <name>.expected.png (the committed golden), and <name>.diff.png (a per-pixel visual diff — mismatched pixels rendered in solid magenta against a 50%-dimmed copy of the expected image, generated by a small diff utility at tests/golden/pixel-diff.ts), written to /tmp/skinforge-golden-diffs/. CI uploads this artifact directory (Section 22.11) so a developer can inspect the failure without reproducing it locally first.

Parity test (browser compositor vs. server compositor): required by the render contract in Section 8.12. The test runs the identical composite algorithm twice against the identical fixture inputs — once through the server-side TypeScript compositor (core/composite/render-design.ts, run directly under Deno) and once through the client-side compositor bundle (islands/preview-canvas.ts's compositing logic, run headlessly under Playwright against a blank test page that loads the bundle and exposes a test hook to call it directly with fixture inputs and read back the canvas pixel buffer via getImageData). Both raw RGBA outputs are compared with the same zero-tolerance byte-equality rule above. This test is the single most important regression guard in the suite: it is the only test that would catch the two compositors silently drifting apart, which per Section 8.12 must never happen since permalinks, downloads, and OG images all depend on the server compositor being the authoritative match for what the visitor saw live in their browser.

22.4 Integration tests #

HTTP-level tests that start the actual Fresh application (in-process, via Deno's fetch-compatible test server helper) against a real PostgreSQL instance — never a mock database and never SQLite — running in a Docker container spun up by the test harness (docker compose -f docker/docker-compose.test.yml up -d postgres invoked by the CI job, Section 22.11) or, for local development, a container the developer starts once and reuses. Integration suites live under tests/integration/ and are named <area>_test.ts; the two subtrees that get their own CI job are tests/integration/pipeline/ (22.9) and tests/integration/security/ (22.8).

Fixture build: before the integration suite runs, a seed script (tests/fixtures/seed-test.ts, shared with the E2E setup in 22.5) applies every migration (Section 6) then inserts a minimal but complete fixture data set: one published game_builds row, the 19-row slots table exactly as specified in Section 3, ~20 assets rows covering every slot family with at least one variant, ~15 hues rows covering the unhued/partial/full/skin-group cases, and 5 pre-created designs rows (including one referencing a retired asset, to exercise the immutable-render-from-archived-build path).

Transaction rollback per test: each test runs inside a Postgres transaction opened in a beforeEach-equivalent (Deno's Deno.test with a t.step-per-case pattern wrapping a helper withTestTransaction(fn)) and rolled back after the test completes, regardless of pass/fail. This gives every test a clean, fixture-only database state without re-running the full seed script per test, keeping the integration suite inside its 4-minute budget (22.1). Tests that specifically need to verify cross-transaction behavior (e.g. the FOR UPDATE SKIP LOCKED job-claiming race in Section 19.7) are the documented exception and use two real, separately committed connections instead of the rollback wrapper — flagged with a comment explaining why.

Build-scoped resolution test (required): with two published builds present in the fixture data, a design pinned to the older build's build_id is rendered and asserted to resolve its assets and hue colours from that older build only, never the newer build's — directly exercising the build_id filter that every asset and hue lookup carries (Section 8.9, Section 13.7, Section 19.7).

Endpoint coverage requirement: every route listed in Section 10's page list and every endpoint in Section 16's API list has at least one integration test exercising its success path and at least one exercising its primary failure path (validation error, not-found, or auth failure as applicable). This is enforced by a CI check (deno task test:coverage:routes) that diffs the route list against a generated manifest of which routes have at least one integration test referencing them by path constant, failing the build if any route is untested — this prevents route-list drift going stale against the actual test suite.

22.5 End-to-end tests with Playwright #

Run against a fully assembled application (real Fresh server, real Postgres, real object storage using the fs driver pointed at a disposable temp directory) started fresh for the E2E job in CI.

Scenarios:

  1. Design a skin and share it. Land on /, change body to female, pick a hair asset and hue, pick a torso_outer asset, click Share, land on /d/:code, assert the rendered composite is visually present (via the <picture> markup, Section 19.6) and the URL is the expected short-code shape.
  2. Open a permalink cold. Navigate directly to a pre-seeded /d/:code with no prior visit, network throttled to Slow 4G. Assert the page renders correctly and completely with zero console errors. The numeric time-to-first-preview budget (Section 19.1) is not asserted here — that is 22.7's job.
  3. Browse the catalog and hand off to the designer. Land on /catalog, filter by slot, open an asset detail page, click "Try this in the designer," assert / loads with that asset pre-selected in the correct slot via the query-string transient state (Section 13). A companion assertion follows the legacy path: requesting /design returns a permanent redirect to / (Section 10.5) and the transient state survives it.
  4. No-JS permalink rendering. With JavaScript disabled at the browser-context level (context.route blocking script requests, or Playwright's javaScriptEnabled: false context option), navigate to /d/:code, assert the composite image is present and correctly sourced — proves the no-blocking-JS rule (Section 19.9) holds in practice, not just in markup review.
  5. Mobile bottom sheet. At a mobile viewport (390×844), open /, assert the slot picker presents as a bottom sheet (Section 11's responsive behavior, referenced not restated), interact with it via touch-equivalent taps, assert selection updates the live preview.
  6. Admin login with TOTP. Navigate to /admin, submit valid credentials, submit a TOTP code computed in-test from a known seeded TOTP secret (using a TOTP-generation helper, never a real authenticator app), assert redirect to /admin/dashboard and a valid sf_admin session cookie is set with the correct flags (Section 20.5).
  7. Candidate review. As an authenticated admin, navigate to /admin/candidates, open a pre-seeded needs_operator_input candidate, assign it to a slot/asset, approve it, assert it moves out of the pending queue and an audit_log row is created.
  8. Publish a build. As an authenticated admin, navigate to /admin/builds, publish a pre-seeded draft build, assert the catalog reflects the new build's assets after publish and the CDN-purge call is invoked (mocked in the E2E environment, asserted via a call-capture, since no real CDN exists in CI).
  9. CSP against the real client workload. Load / in a real browser context with the production security headers applied (Section 20.4), and assert the client-side WASM compositor (Section 8.12) actually initializes and produces a composite, and that the inline theme script (Section 18.10) actually executes with no CSP violation report — a string-level header assertion (Section 22.8) cannot prove 'wasm-unsafe-eval' and the per-response nonce work in a real browser, only that the header text is well-formed.

Visual regression on three viewports: scenarios 1 and 3 additionally run a full-page screenshot comparison (Playwright's built-in toHaveScreenshot) at three viewports — 390×844 (mobile), 768×1024 (tablet), 1440×900 (desktop) — with a near-zero pixel-diff threshold, committed baseline screenshots under tests/e2e/__screenshots__/ (Playwright specs live at tests/e2e/), and the same manual-regeneration discipline as the golden-image tests in 22.3 (never auto-updated by CI).

22.6 Accessibility testing #

Automated axe checks on every route: every route in Section 10.1's site map (13 routes) plus the four error pages in Section 10.9 (17 total) has an automated axe-core scan run via Playwright's axe integration, asserting zero violations at the WCAG 2.2 AA level (Section 18 owns the accessibility standard; this section owns the testing of conformance to it). Admin routes are included in a separate, equally-required scan set. A violation of any severity fails the check — there is no "moderate is acceptable" carve-out. Automation alone is not a sufficient accessibility gate: axe detects only a subset of WCAG 2.2 AA success criteria and, in particular, cannot evaluate any of steps 6-8 of the manual script below — the launch gate (Sections 26, 27) requires both the automated scan and a passing manual script, never the automated scan alone.

Manual keyboard-only script, run quarterly and before any major release, and required at every launch gate (tied to the definition of done in 22.12):

  1. From a fresh tab focus, Tab through / and confirm every interactive element (body toggle, every slot swatch, every hue swatch, Share button) is reachable in a logical order and has a visible focus ring.
  2. Operate the entire design flow — select a body, select an asset for at least 3 slots, change hues, trigger Share — using only Tab, Shift+Tab, Enter, Space, and arrow keys (for swatch grids, arrow keys move selection per Section 11's keyboard behavior spec).
  3. Confirm Escape closes any open picker/bottom-sheet without losing prior selections.
  4. Repeat steps 1-3 on /catalog and /admin (post-login).
  5. 2.1.4 Character Key Shortcuts: confirm every single-key keyboard shortcut defined in Section 11.11 is either remappable from a settings control, requires a modifier key (Ctrl/Cmd/Alt), or is active only while a specific component has focus (2.1.4's activation-on-focus exception — this is the answer that covers the admin candidate-queue shortcuts in Section 17.6.6, which fire only once a candidate tile is focused). A bare, non-remappable, globally-active single-character shortcut fails this check.
  6. 2.5.7 Dragging Movements: on the mobile bottom sheet (Section 11's responsive slot picker), confirm every action reachable by a drag/swipe gesture has a non-drag alternative — a tap target, an explicit button, or a keyboard equivalent.
  7. 2.4.11 Focus Not Obscured: tab through every route and confirm that whenever an element receives keyboard focus, at least part of it remains visible and is not fully hidden behind a sticky header, the bottom sheet, or any open modal/overlay.
  8. Record pass/fail per step in the release checklist (22.12).

Screen-reader spot checks, quarterly, using VoiceOver (macOS/Safari) and NVDA (Windows/Firefox) as the two reference combinations:

  1. Confirm the body toggle, every slot control, and every hue control announce a clear name and current state (e.g. "Hair, Long Wavy, hue 1102, button").
  2. Confirm the live preview image has appropriate alt text that updates as the design changes (or is correctly marked decorative if a redundant text description is provided elsewhere per Section 18).
  3. Confirm form validation errors on any admin form are announced (via aria-live or equivalent) when they appear.
  4. Confirm the permalink page's content is fully readable start to finish without encountering an unlabeled control.

Contrast verification procedure: every color pair defined in the design system (Section 18 owns the token values) is checked programmatically in a unit test co-located beside the token file (web/styles/theme_test.ts, reading the committed custom properties in web/styles/theme.css) using the WCAG contrast ratio formula against the AA thresholds (4.5:1 normal text, 3:1 large text and UI components), run as part of the standard unit suite (22.2) rather than as a separate manual step, since it is a pure computation over committed token values.

22.7 Performance testing #

Load scenarios: the two scenarios defined in Section 19.10 — normal traffic (sustained ~0.5 req/s with realistic route-mix weighting: 40% permalink views, 25% designer (/), 20% render requests, 10% catalog, 5% API) and the permalink spike (ramp to ~7 req/s concentrated on one permalink and its render URLs over 5 minutes) — codified as k6 scenarios under tests/load/ (Section 5.1's location for load and performance scripts).

Tool: k6 for load/latency scenarios (chosen for scriptability in JS/TS, consistent with the project's language, and because it requires no JVM or additional runtime beyond what CI already provisions); Lighthouse CI for the page-level budgets in Section 19.1 (LCP/INP/CLS/page-weight), run against a production build served locally in the CI job.

Thresholds that fail the build: k6 scenario thresholds are set directly from Section 19.1's budget table (e.g. http_req_duration{endpoint:design_resolve}: p(95)<60) and Section 19.8's render back-pressure numbers; a threshold breach makes the k6 run itself exit non-zero, which fails the CI job. Lighthouse CI thresholds are set from the same LCP/INP/CLS/page-weight numbers in Section 19.1 using Lighthouse CI's assert configuration with error severity (not warn) for every budget in that table, so a regression fails the gate rather than merely appearing in a report.

22.8 Security testing #

Dependency audit: deno outdated plus an advisory check against resolved npm: specifiers (Section 20.10), run in CI on every PR against the main branch and on a weekly cron independent of code changes; a high/critical finding fails the PR-blocking job unless explicitly waived (Section 20.10's waiver process).

Header assertions: an integration test suite (tests/integration/security/headers_test.ts, covering core/http/security-headers.ts) makes a real request to each of the two header profiles (Section 20.4) and asserts every header listed there is present with the exact specified value — not a substring match, an exact match, so a weakened CSP directive is caught immediately.

CSP validation: in addition to the exact-match header assertion, a dedicated test splits each directive into tokens and asserts that no bare 'unsafe-eval' token appears in any directive — matched as a whole token, never by substring, because the required 'wasm-unsafe-eval' contains unsafe-eval as a substring and a substring check would fail on a correct policy — that unsafe-inline appears only in the style-src directive, never in script-src, and that script-src contains 'wasm-unsafe-eval' and a per-response 'nonce-...' token — this is the specific regression this test guards against even if someone edits the header string correctly elsewhere but reintroduces a bare inline-script allowance. Because a string-level assertion cannot prove the policy actually permits the client-side workload it is meant to allow, the E2E suite (Section 22.5, scenario 9) additionally exercises the CSP against a real browser loading the WASM compositor and the nonce-carrying theme script.

Authorization matrix tests: every admin route (Section 10's admin route list) is tested four ways — (1) no session cookie present: expect 401/redirect to /admin login; (2) an expired/invalidated session: expect the same; (3) a session cookie for a valid admin but with a tampered/invalid CSRF token on a state-changing request: expect 403 (SF-4003, Section 20.5); (4) a valid session for each role strictly below the route's minimum role in Section 17.3, on both the GET and the state-changing method: expect 403 (SF-4005, Section 20.5). This produces roughly 4 cases × the admin route count (~15 routes) × up to 2 lower roles ≈ 100 test cases; the matrix is generated from Section 17.3.1's permission-matrix table as data, so a new action added to that table without a corresponding server-side check fails CI. It lives at tests/integration/security/authz_test.ts and runs in the security CI job, which for that reason provisions the same postgres service as the integration job — the matrix exercises real sessions and real roles, not stubs.

Fuzzing of format readers: the MUL/IDX reader (core/formats/mul.ts), the UOP reader (core/formats/uop.ts), the ARGB1555 unpacker (core/hue/unpack.ts) and the hues.mul-format reader (core/formats/hues.ts) are each fuzzed nightly (not per-PR, to keep PR CI fast) by a co-located <module>_fuzz_test.ts beside each one, excluded by name from the per-PR unit job using Deno's fuzzing-friendly property-test approach — a seeded random byte-mutator that takes each committed fixture file (22.3's synthetic client fixtures) and flips/truncates/extends random byte ranges across 10,000 iterations per module, asserting the reader either succeeds or throws a typed decode error, and never panics with an unhandled exception, an infinite loop (bounded by a hard iteration/time cap inside each reader itself, not just the fuzz harness), or an out-of-bounds memory read (Deno's Uint8Array bounds-checks this at the runtime level, but the assertion additionally checks no RangeError escapes as an unhandled rejection). A fuzzing failure opens a tracked issue automatically (via the CI job's failure output) rather than blocking every PR, since fuzzing failures need triage, not a hard merge block, but are treated as SEV3-equivalent findings requiring a fix within the next release.

22.9 Pipeline testing #

Extraction against the synthetic fixture client: skinforge-cli extract is run in CI against the committed tests/fixtures/synthetic-client/ directory (22.3), asserting each of Section 7's stages D1-D6 produces the expected classification/extraction outputs — e.g. D2 correctly identifies the fixture's MUL/IDX pairs and hues-format file, D4 decodes the known sprite pixel patterns byte-correctly (reusing the golden-image comparison method from 22.3), D5 classifies each synthetic sprite into its intended slot via the fixture's deliberately unambiguous gump-id ranges.

Diffing: a second synthetic-client fixture variant (tests/fixtures/synthetic-client-v2/) with one sprite changed, one added, and one removed is extracted and diffed against the first import run's results, asserting the diff correctly reports one modified, one added, one removed candidate and leaves every unchanged asset alone (no spurious re-flagging).

Publish/rollback: a full cycle — import v1, publish it, import v2, publish it, then roll back to v1 — is run as one integration test, asserting after rollback that the active build is v1's again, that v2's assets are not deleted (soft-retire only, per the soft-delete rule in Section 9.7), and that any design created while v2 was active still renders correctly from v2's archived assets (immutability, Section 13) even though v1 is now the publicly active build.

Idempotency tests that re-run a stage twice (required): the identical synthetic-client fixture is imported once, then every stage D1–D6 is re-run against the same build_id, asserting the second execution of each stage produces zero net row changes (Section 9.9's ON CONFLICT guarantees) and that asset_variants.sha256 values are unchanged. A separate test imports the identical fixture as a second build and asserts the diff (Section 9.3) reports every asset unchanged and every hue unchanged, with new hues rows scoped to the new build_id as Section 6.13 requires — directly testing the re-sync-after-patch code path described in Section 9 for the common case where a patch changes nothing about the assets SkinForge tracks.

22.10 Test data and factories #

Fixture creation: all test fixtures are hand-authored and committed to the repository — the synthetic client (22.3), the integration seed data (22.4), and the E2E seed data (22.5, which reuses the same seed-test.ts script as integration tests to avoid drift between what integration tests and E2E tests assume about the database state). Factory functions at tests/fixtures/factories.ts provide typed builders for each table (buildDesign(), buildAsset(), etc.) with sensible defaults and override parameters, used by both unit tests (for in-memory objects) and integration tests (for rows to insert), so a test author never hand-writes a full row literal.

Seed script: tests/fixtures/seed-test.ts is the single source of truth for baseline test data, invoked by the integration suite setup, the E2E suite setup, and available as a deno task db:seed:test for local development — never three separate, drifting seed implementations.

No real game files in tests: it is a hard rule, enforced by a CI check (deno task test:no-real-assets grepping tests/fixtures/ for disallowed binary signatures/extensions and failing if anything outside tests/fixtures/synthetic-client*/'s deliberately tiny, hand-authored files appears) that no test — unit, integration, golden-image, E2E, or pipeline — ever depends on real UO Outlands client files. This keeps the repository legally clean (Section 20.8's "no raw game files are ever distributed" extends to the test suite) and keeps tests fast and deterministic.

22.11 CI #

GitHub Actions, workflow file .github/workflows/ci.yml. Every job that boots the application or the CLI carries the five variables Section 24.2 marks Required: yes plus SKINFORGE_METRICS_ENABLED, because Section 24.3's validator calls Deno.exit(1) on the first missing one and a half-configured job would hang on wait-on rather than fail with a readable message. GitHub Actions does not support YAML anchors, so the block is written out per job deliberately rather than shared:

name: CI
on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * *"   # nightly: fuzzing (Section 22.8)
    - cron: "0 4 * * 1"   # weekly: dependency advisory re-check with no new commits (Section 20.10)

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno lint
      - run: deno fmt --check
      - run: deno check main.ts cli/main.ts

  unit:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      # Co-located *_test.ts beside every core/, cli/ and web/ module (22.2). The nightly fuzz
      # suites are excluded by name so a PR never pays for 10k-iteration fuzzing (22.8).
      - run: deno test --allow-read --allow-env --ignore="core/**/*_fuzz_test.ts" core/ cli/ web/

  golden-image:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      # tests/golden/composite_test.ts renders each combination and byte-compares it against the
      # committed PNGs in tests/golden/fixtures/ (22.3). Pointing this at the image directory
      # instead would find no test modules and pass vacuously.
      - run: deno test --allow-read --allow-write=/tmp/skinforge-golden-diffs tests/golden/
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: golden-image-diffs
          path: /tmp/skinforge-golden-diffs/

  integration:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: skinforge_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      SKINFORGE_ENV: test
      SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
      SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
      SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
      SKINFORGE_CONTACT_EMAIL: ci@example.com
      SKINFORGE_METRICS_ENABLED: "false"
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno task db:migrate
      - run: deno task db:seed:test
      - run: deno test --allow-net --allow-env --allow-read --ignore="tests/integration/pipeline,tests/integration/security" tests/integration/

  pipeline:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: skinforge_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      SKINFORGE_ENV: test
      SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
      SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
      SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
      SKINFORGE_CONTACT_EMAIL: ci@example.com
      SKINFORGE_METRICS_ENABLED: "false"
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno task db:migrate
      - run: deno test --allow-net --allow-env --allow-read --allow-write tests/integration/pipeline/

  e2e:
    runs-on: ubuntu-latest
    needs: [unit, integration]
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: skinforge_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      SKINFORGE_ENV: test
      SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
      SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
      SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
      SKINFORGE_CONTACT_EMAIL: ci@example.com
      SKINFORGE_METRICS_ENABLED: "false"
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno task db:migrate && deno task db:seed:test
      - run: npx playwright install --with-deps chromium
      - run: deno task build
      - run: deno task start:test & npx wait-on http://localhost:8000/health && npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

  security:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: skinforge_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      SKINFORGE_ENV: test
      SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
      SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
      SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
      SKINFORGE_CONTACT_EMAIL: ci@example.com
      SKINFORGE_METRICS_ENABLED: "false"
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno install --frozen
      - run: deno outdated --lock-only
      # No event filter: 20.10 and 22.8 both require the advisory check on every PR and push, and
      # the weekly cron above re-runs it when no commit has landed.
      - run: deno task audit:npm  # fails non-zero on any high/critical advisory (Section 20.10)
      - run: deno task db:migrate && deno task db:seed:test
      - run: deno test --allow-net --allow-env --allow-read tests/integration/security/

  fuzz:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    if: github.event.schedule == '0 3 * * *'
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno test --allow-read core/formats/*_fuzz_test.ts core/hue/unpack_fuzz_test.ts

  accessibility:
    runs-on: ubuntu-latest
    needs: [unit, integration]
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: skinforge_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      SKINFORGE_ENV: test
      SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
      SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
      SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
      SKINFORGE_CONTACT_EMAIL: ci@example.com
      SKINFORGE_METRICS_ENABLED: "false"
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno task db:migrate && deno task db:seed:test
      - run: deno task build
      - run: deno task start:test & npx wait-on http://localhost:8000/health && npx playwright test tests/e2e/a11y.spec.ts

Why those environment values specifically. SKINFORGE_ADMIN_SESSION_SECRET must satisfy Section 24.3's /^[A-Za-z0-9+/]{43,}={0,2}$/ regex: the literal above is 72 base64 characters decoding to 54 bytes, comfortably over the 32-byte floor, and contains no hyphen (a hyphen is not in the base64 alphabet, so a readable "test-secret-…" string fails validation and the app never starts). SKINFORGE_METRICS_ENABLED: "false" is set explicitly because the variable defaults to true and 24.3's second superRefine then makes SKINFORGE_METRICS_TOKEN required — no CI job scrapes /metrics, so disabling collection is the honest way to satisfy the validator rather than inventing a token. SKINFORGE_ENV: test is what exempts SKINFORGE_PUBLIC_BASE_URL from the https refine, which is why a http://localhost:8000 base URL is legal here and nowhere else. The pipeline job needs the same set because cli/main.ts runs the identical loadCliConfig validator (24.3).

Required checks to merge: lint-and-typecheck, unit, golden-image, integration, pipeline, security, accessibility are all required status checks on the main branch protection rule. e2e is required on PRs targeting main but permitted to be skipped (with an explicit maintainer override) on documentation-only changes, detected by a path filter.

Release gate: a separate release.yml workflow, triggered manually or on a version tag push, runs the full suite above plus the performance job (k6 + Lighthouse CI, Section 22.7) and the nightly fuzzing job (Section 22.8) on demand rather than waiting for the nightly schedule, and requires all of them green plus the manual pre-launch security checklist (Section 20.12) and the manual QA checklist (22.12) signed off before a release is tagged as deployable.

22.12 Definition of done and manual QA checklist #

Definition of done per milestone (milestones defined in Section 26): a milestone is done when every acceptance criterion listed for it in Section 26 is met, every new code path has unit and/or integration coverage per 22.1's pyramid expectations, no required CI check is failing, no new deno lint or deno fmt exceptions were introduced, and any new route is added to the endpoint coverage manifest (22.4).

Manual QA checklist before each release, performed by the operator against a staging deployment (Section 23.3) before promoting to production:

  • Full design-and-share flow completed manually on a real mobile device (not just an emulated viewport) and a real desktop browser.
  • At least one full admin workflow (candidate review through publish) completed manually.
  • Manual keyboard-only script (22.6) passed, pass/fail recorded per step.
  • Screen-reader spot checks (22.6) passed on at least one of the two reference combinations (VoiceOver/Safari or NVDA/Firefox), and both before a major release.
  • Contrast verification (22.6) green in the unit suite — the automated axe scan does not evaluate it, and neither the axe scan nor this test can substitute for the keyboard and screen-reader scripts above.
  • Visual spot-check of the OG image for a freshly created design, viewed via an actual social platform unfurl preview or an unfurl-preview debugging tool.
  • /legal, /about, /faq, /support reviewed for accurate, current content including the disclaimer text (Section 10.4) and privacy statement (Section 20.7).
  • Release notes drafted for /changelog.
  • Pre-launch security checklist (Section 20.12) re-confirmed if this release touches auth, headers, or the extraction sandbox.
  • Backup restore drill freshness confirmed within 90 days (Section 23.6).

23. Deployment, Infrastructure & Operations #

23.1 Target environment #

A single Linux VPS running Docker Compose. Recommended concrete sizing: 4 vCPU, 8 GB RAM, 100 GB NVMe SSD, Ubuntu 24.04 LTS or later. This is sufficient for the capacity model in Section 19.10 with headroom for the render concurrency setting in Section 19.8 and Postgres running co-located.

Topology:

                              ┌─────────────────────────┐
  Internet ──HTTPS──▶ CDN ────▶  Caddy (TLS termination) │
                              │        :443, :80         │
                              └────────────┬─────────────┘
                                           │ HTTP, localhost
                              ┌────────────▼─────────────┐
                              │   app container (Deno)    │
                              │  Fresh web + API + render  │
                              │        :8000               │
                              └───┬──────────────────┬────┘
                                  │                   │
                       ┌──────────▼───────┐  ┌────────▼────────┐
                       │  postgres:18       │  │  minio (optional) │
                       │  container          │  │  S3-compatible     │
                       └─────────────────────┘  └────────────────────┘

The CDN (Cloudflare or any CDN meeting the caching contract in Section 19.2) sits in front of Caddy, caching per the matrix in Section 19.3. Caddy terminates TLS and reverse-proxies to the app container's internal HTTP port. Postgres and MinIO are separate containers on the same Docker network, not exposed to the public internet — only Caddy's :443/:80 are published on the host. The skinforge-cli process (Section 4) runs as a one-off container invocation or a host-installed Deno binary against the same Postgres and storage, used interactively by staff — it is not a long-running service.

23.2 docker-compose.yml, Dockerfile, Caddyfile #

The three deployment files live in docker/ exactly as Section 5.1's repository tree places them — docker/docker-compose.yml, docker/Dockerfile, docker/Caddyfile — and every docker compose command in this section is run from the repository root with COMPOSE_FILE=docker/docker-compose.yml exported (23.4 step 3 sets it in the deploy user's profile) or with an explicit -f docker/docker-compose.yml. Relative paths inside the file are therefore resolved against docker/, which is why the build context and the host mounts below point one level up.

docker/docker-compose.yml:

name: skinforge

services:
  app:
    # Both keys are present deliberately: `image:` is what `docker compose pull` resolves during a
    # normal release (23.5), and `build:` is the bootstrap path for the very first deploy, before any
    # image has been published. Compose uses the pulled image when one exists for the tag.
    image: ghcr.io/<owner>/skinforge:${SKINFORGE_IMAGE_TAG:-latest}
    build:
      context: ..
      dockerfile: docker/Dockerfile
    restart: unless-stopped
    env_file: ../.env
    environment:
      SKINFORGE_PORT: "8000"
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - render_cache:/data/render-cache
      - storage_fs:/data/storage
      - import_work:/data/import-work
    networks: [internal]
    healthcheck:
      test: ["CMD", "deno", "run", "--allow-net=localhost:8000", "--allow-env=SKINFORGE_PORT", "healthcheck.ts"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"

  postgres:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_DB: skinforge
      POSTGRES_USER: skinforge
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets: [postgres_password]
    command:
      - postgres
      - -c
      - config_file=/etc/postgresql/postgresql.conf
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ../db/backups:/backups
      - ../db/wal-archive:/backups/wal
      # archive_mode = on and archive_command writing to /backups/wal/ — this mount is what makes
      # the 5-minute-RPO PITR window in 23.6 real rather than aspirational.
      - ../db/postgresql.conf:/etc/postgresql/postgresql.conf:ro
    networks: [internal]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U skinforge"]
      interval: 10s
      timeout: 5s
      retries: 5

  minio:
    image: minio/minio:RELEASE.2026-08-01T00-00-00Z
    restart: unless-stopped
    profiles: ["s3-storage"]
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${SKINFORGE_S3_ACCESS_KEY_ID}
      MINIO_ROOT_PASSWORD: ${SKINFORGE_S3_SECRET_ACCESS_KEY}
    volumes:
      - minio_data:/data
    networks: [internal]

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    # Without env_file the `{$SKINFORGE_PUBLIC_BASE_URL}` site address in the Caddyfile expands to an
    # empty string and Caddy refuses to start, so this line is load-bearing, not decoration.
    env_file: ../.env
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on: [app]
    networks: [internal]

networks:
  internal:
    driver: bridge

volumes:
  postgres_data:
  render_cache:
  storage_fs:
  import_work:
  minio_data:
  caddy_data:
  caddy_config:

secrets:
  postgres_password:
    file: ../secrets/postgres_password.txt

healthcheck.ts (repository root, beside main.ts) — referenced by both the Compose healthcheck above and the Dockerfile HEALTHCHECK below, and small enough to state in full so neither reference dangles:

// healthcheck.ts — container liveness probe. Exits 0 when the process serves /health (Section 21.7),
// 1 otherwise. Deliberately dependency-free and dependency-blind: it must not fail because Postgres
// is down, or Docker would restart a healthy app during a database outage. Readiness (which does
// consider dependencies) is /health/ready, polled by the external monitor, never by Docker.
const port = Deno.env.get("SKINFORGE_PORT") ?? "8000";
try {
  const res = await fetch(`http://localhost:${port}/health`, {
    signal: AbortSignal.timeout(3000),
  });
  Deno.exit(res.ok ? 0 : 1);
} catch {
  Deno.exit(1);
}

It is run with --allow-net=localhost:8000 and --allow-env=SKINFORGE_PORT only; it reads no file and writes nothing, so a compromised dependency cannot reach anything through the probe.

The minio service uses a Compose profile (s3-storage) so it starts only when SKINFORGE_STORAGE_DRIVER=s3 and the operator explicitly runs docker compose --profile s3-storage up -d; the default fs driver deployment never starts it, keeping the default footprint smaller.

docker/Dockerfile (multi-stage, non-root user, Deno cache warmed at build time; built with the repository root as context, per the build.context: .. above):

# syntax=docker/dockerfile:1

FROM denoland/deno:2.9 AS builder
WORKDIR /app
COPY deno.json deno.lock ./
COPY . .
RUN deno install --frozen
RUN deno task build

FROM denoland/deno:2.9 AS runtime
RUN addgroup --system skinforge && adduser --system --ingroup skinforge skinforge
WORKDIR /app
COPY --from=builder --chown=skinforge:skinforge /app /app
RUN mkdir -p /data/render-cache /data/storage /data/import-work \
    && chown -R skinforge:skinforge /data
USER skinforge
ENV SKINFORGE_RENDER_CACHE_DIR=/data/render-cache
ENV SKINFORGE_STORAGE_FS_ROOT=/data/storage
ENV SKINFORGE_IMPORT_WORKDIR=/data/import-work
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
  CMD deno run --allow-net=localhost:8000 --allow-env=SKINFORGE_PORT healthcheck.ts
CMD ["run", \
     "--allow-net=0.0.0.0:8000,postgres:5432,minio:9000,api.cdn.example:443", \
     "--allow-read=/app,/data/storage,/data/render-cache,/data/import-work", \
     "--allow-write=/data/render-cache,/data/storage,/data/import-work", \
     "--allow-env", \
     "--no-prompt", \
     "main.ts"]

postgres and minio are the Compose service DNS names defined above; api.cdn.example is a placeholder the operator replaces with the host of their real SKINFORGE_CDN_PURGE_URL (Section 24.2). Image tags follow Section 4's major-line policy (2.9, never a pinned patch and never latest); the same rule applies to minio/minio below.

This is the same permission set specified in Section 20.10, written differently for a different reader: 20.10 uses ${SKINFORGE_*} variable names for readability, while an exec-form CMD is a JSON array in which Docker performs no variable expansion, so the Dockerfile must carry the literal container paths those variables resolve to. --allow-read covers /data/storage and /data/render-cache because the fs driver reads stored renders and catalog objects back out on every cache hit, not only writes them; --allow-write covers /data/import-work because the admin ZIP-upload source mode (Section 17.5.1) has the web process stream an uploaded archive into it, and with --no-prompt a missing write scope is a hard failure on the first byte rather than a prompt. The container's CMD is the authoritative, minimal permission set, not merely documentation of it.

Caddyfile:

{
	servers {
		trusted_proxies static <CDN-edge-CIDRs>
	}
}

{$SKINFORGE_PUBLIC_BASE_URL} {
	encode zstd gzip

	# Cache-Control for /render/*, /og/* and every HTML route is set by the app per response status
	# (Sections 8.9, 19.3, 25.3) — several of them are status-dependent (a taken-down design's 410
	# must never be cached the same way as a healthy render). Caddy must not override it.

	@admin path /admin*
	header @admin X-Frame-Options "DENY"

	header {
		Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
	}

	reverse_proxy app:8000 {
		header_up X-Forwarded-For {http.request.header.X-Forwarded-For}
		# CF-Connecting-IP is Cloudflare's header name; adjust or delete this line for another CDN.
		header_up X-Real-IP {http.request.header.CF-Connecting-IP}
		header_up X-Request-Start {time.now.unix_ms}
	}

	log {
		output file /data/access.log {
			roll_size 50MiB
			roll_keep 5
			roll_keep_for 720h
		}
		format json
	}
}

trusted_proxies must list the operator's actual CDN edge IP ranges; without it, Caddy's own remote_host (not used above) would be the only IP the app ever saw, and forwarding the CDN's already-present X-Forwarded-For chain unmodified — rather than overwriting it with Caddy's immediate peer — combined with SKINFORGE_TRUSTED_PROXY_HOPS (Section 24.2) is what lets the app recover the real client IP for per-IP rate limiting (Section 16.7, Section 20.1) instead of collapsing every visitor behind the CDN into one bucket. Caddy's own access log at /data/access.log contains full, untruncated client IPs and is operator-managed infrastructure logging, explicitly outside the application-log rotation policy in Section 21.1 (which governs only the app's own structured JSON logs); roll_keep_for 720h (30 days) bounds it for disk-space reasons only, not as a privacy commitment.

The X-Real-IP line names CF-Connecting-IP, which is Cloudflare-specific. Decision D-10 permits any CDN that honours Cache-Control and offers a purge API, so an operator on another provider replaces that header name with their provider's equivalent or deletes the line outright — nothing in the application reads X-Real-IP. The IP the app actually uses comes from the X-Forwarded-For chain interpreted through SKINFORGE_TRUSTED_PROXY_HOPS (Section 20.7), which is provider-neutral.

Application-level security headers from Section 20.4 (the full CSP, Permissions-Policy, Cross-Origin-* headers) are set by the app process itself, not Caddy, so the two profiles (public vs. admin) stay driven by one source of truth in core/http/security-headers.ts; the Caddyfile only adds HSTS and the access-log layer that is infrastructure-appropriate to set at the edge, plus the X-Frame-Options legacy fallback on admin routes.

23.3 Environments #

Environment SKINFORGE_ENV Differs from production Configuration source
Development development Runs via deno task dev (Fresh's dev server with hot reload), local Postgres (Docker or native install), fs storage driver pointed at a local .data/ directory, SKINFORGE_LOG_LEVEL=debug, admin TOTP issuer suffixed (dev) to avoid confusing authenticator app entries with production .env (git-ignored, from .env.example, Section 23.4)
Staging staging Full Docker Compose stack, separate Postgres database and storage bucket/root from production, a separate subdomain (e.g. staging.skinforge.example), real TOTP but a distinct admin user set, rate limits relaxed 5x for easier manual QA (Section 22.12) .env on the staging host, values distinct from production per variable
Production production As specified throughout this section; SKINFORGE_LOG_LEVEL=info, full rate limits, real CDN in front, backups enabled (23.6) .env on the production host, secrets generated per Section 24.5, never copied from staging

All three environments run the identical container images (built once per release, Section 23.5) — environment differences are entirely configuration (Section 24), never a code branch on SKINFORGE_ENV beyond cosmetic labeling (e.g. the TOTP issuer suffix above) and enabling verbose logging.

23.4 First-time provisioning runbook #

  1. Provision the VPS. Create a host matching Section 23.1's sizing. Create a non-root deploy user with Docker permissions (usermod -aG docker <user>). Install Docker Engine and Docker Compose plugin.

  2. DNS. Point the production domain's A/AAAA records at the VPS IP (or at the CDN if the CDN provider requires a CNAME/proxy setup instead — follow the CDN provider's standard pattern). Allow time for propagation before step 4.

  3. Clone and configure. git clone the repository to the VPS. Export COMPOSE_FILE=docker/docker-compose.yml in the deploy user's shell profile so every command below resolves the right file. Copy .env.example (defined fully in Section 24.4) to .env and fill every value; generate secrets per Section 24.5's commands. Create secrets/postgres_password.txt with a generated password (openssl rand -base64 32), and set SKINFORGE_IMAGE_TAG to the release tag being deployed. On a host that can reach the image registry, docker compose pull fetches the published image and no build happens locally; building on the host is the bootstrap fallback for the very first deploy, before CI has published an image for any tag.

  4. TLS. No manual step required — Caddy obtains and renews a Let's Encrypt certificate automatically for the domain in SKINFORGE_PUBLIC_BASE_URL on first request, provided DNS (step 2) already resolves to this host and ports 80/443 are reachable from the internet.

  5. Start the data tier. docker compose up -d postgres (and minio with --profile s3-storage if using S3-compatible storage). Wait for the healthcheck to pass (docker compose ps).

  6. Run migrations. docker compose run --rm app deno task db:migrate. Confirm schema_migrations reflects every migration in db/migrations/.

  7. Create the first admin user. docker compose run --rm app deno task cli -- admin create-user --email <staff-email> --role owner--role is mandatory (Section 17.1), and the first account must be owner or no one can reach the owner-only screens. The CLI prompts for a password (entered interactively, never passed as a CLI argument to avoid shell-history leakage) and prints a TOTP enrollment QR code / secret to the terminal for the operator to add to an authenticator app immediately.

  8. Storage bucket setup. For fs (default): the storage_fs volume is created automatically by Compose; no further action. For s3: create the bucket named in SKINFORGE_S3_BUCKET via the provider's console or mc mb if using MinIO, and confirm the configured access key has read/write permission on it.

  9. Start the full stack. docker compose up -d. Confirm GET https://<domain>/health returns 200 and GET https://<domain>/health/ready shows every check "ok".

    Step 9a — enable backups, before any real data exists to lose. Install the host cron entries invoking scripts/backup-postgres.sh (02:00 UTC) and scripts/backup-storage.sh (02:30 UTC), both of which encrypt with age and upload to the off-site bucket per 23.6. WAL archiving is already configured by the db/postgresql.conf mount in 23.2 (archive_mode = on, and an archive_command writing to /backups/wal/); confirm segments are landing there. Verify one complete dump and at least one archived WAL segment have reached the off-site bucket before continuing — an unverified backup is not a backup, and the 5-minute RPO promised in 23.6 depends entirely on this step having actually been run.

  10. First asset import. Run the extractor (Section 20.6, step 1) against the operator's local client copy to produce a manifest in SKINFORGE_IMPORT_WORKDIR (this step happens on whatever machine holds the client files, which may or may not be the VPS — the manifest and normalized output are then present on the server, or copied there, outside any web-served directory), then run the import command (Section 20.6, step 2) to ingest that manifest into the database. Follow Section 7's staged pipeline through D1-D6, then review candidates at /admin/candidates (Section 17) and publish the first build at /admin/builds.

  11. Smoke test. Load /, create a test design, confirm the permalink and render URLs work, confirm the OG image generates. The test design does not need to be removed (designs are immutable per Section 13); note its short code for reuse as the release smoke-test permalink (23.5).

23.5 Release process #

Build: CI builds a single container image per release (Section 22.11's release.yml), tagged with the git short SHA and, on a version tag push, additionally tagged with the semantic version (e.g. 2026.09.1). The same image is later promoted from staging to production — never rebuilt per environment, eliminating "works in staging, different in production" drift.

Tag: version tags follow YYYY.MM.N (e.g. 2026.09.1 for the first release cut in September 2026), reflecting a small-team, non-strict-semver release cadence appropriate to a single-operator product; N increments within the month.

Migrate: migrations (Section 6) are additive-first — a release that adds a column ships and deploys before any release that requires the column to be non-null or that removes an old column, across two separate releases, so that the previous release's running containers (during a rolling restart) never encounter a schema shape they were not built against. deno task db:migrate runs as a one-off docker compose run --rm app invocation before the app container is restarted with the new image.

Deploy: set SKINFORGE_IMAGE_TAG in .env to the release tag, then docker compose pull && docker compose up -d --no-deps app — the app service carries an image: key alongside build: (23.2) precisely so this pull resolves a published image rather than failing on a build-only service. Only the app service is replaced; postgres, minio, and caddy are untouched by a normal release. Docker Compose performs a stop-then-start of the single app container (Compose does not natively do rolling replacement with a single replica); the brief gap is covered by Caddy's automatic retry-on-connection-refused behavior for a few seconds, which in practice is invisible to users given typical request rates (Section 19.10) — this is the accepted trade-off of the single-VPS, single-replica v1 topology (Section 23.12 covers the multi-replica path where true zero-downtime deploys become available).

Smoke test: immediately after deploy, an automated script (scripts/smoke-test.sh, also runnable manually) hits /health, /health/ready, loads /, resolves the known smoke-test permalink from provisioning (23.4 step 11) or a dedicated permanent smoke-test design, and confirms a 200 with the expected content markers on each. A failure triggers immediate rollback.

Rollback: set SKINFORGE_IMAGE_TAG back to the previous release tag, docker compose pull, and up -d --no-deps app again — since migrations are additive-first, the previous image tag's code is guaranteed compatible with the current (possibly newly migrated) schema. A rollback never reverses a migration; if a migration itself is the defect, a forward-fix migration is written and released rather than a destructive down-migration (down-migrations are not maintained in this project — db/migrations/ is forward-only, consistent with the additive-first rule making down-migrations largely unnecessary).

Zero-downtime approach: as described under Deploy above, v1 accepts a brief (sub-few-second) connection gap rather than true zero-downtime, which is an explicit, documented trade-off for the single-replica topology; Section 23.12's first scaling step introduces a second app replica specifically to close this gap when traffic level justifies the added operational complexity.

23.6 Backups #

What Schedule Retention Encryption Off-site copy
Postgres (pg_dump custom format, scripts/backup-postgres.sh from the host cron installed in 23.4 step 9a) Daily at 02:00 UTC, plus continuous WAL archiving configured by the db/postgresql.conf mounted in 23.2 14 daily, 8 weekly, 6 monthly; WAL segments retained 7 days, giving the 5-minute-granularity, 7-day PITR window required by Section 6.34 Encrypted at rest with age (public-key encryption, private key never stored on the VPS) before upload Yes — pushed to a separate cloud storage bucket immediately after each dump completes
Object storage: catalog/ (the normalized per-variant .webp and .png art written by Stage D6 — Section 7.8, not regenerable without the operator's original client copy for that build; there is no mask artifact and no raw-buffer artifact in this prefix, only encoded images) and og/ (Open Graph images). renders/ and imports/ are excluded as regenerable/scratch — renders/ is a pure function of catalog/ plus the database Daily at 02:30 UTC (after the Postgres dump), via scripts/backup-storage.sh from the same host cron Same rotation as Postgres Same age encryption Same off-site bucket, separate prefix
Extraction work directory (SKINFORGE_IMPORT_WORKDIR) Not backed up N/A — is scratch space, regenerable by re-running extraction against the operator's client files (which are the actual source of truth and are the operator's own responsibility to retain) N/A N/A
Configuration (.env, secrets/) Manual, on every change, to the operator's own password manager / secret store — never to the same off-site bucket as data backups Operator-managed Operator-managed Operator-managed

RESTORE DRILL procedure, performed at minimum every 90 days (tracked as a checklist item in Section 22.12's release checklist and Section 20.12's pre-launch checklist):

  1. Provision a disposable VPS or local Docker environment separate from production.
  2. Download the most recent Postgres backup from off-site storage, decrypt with the age private key (kept only in the operator's secret store, never on any server).
  3. Restore: pg_restore into a fresh postgres:18 container.
  4. Restore the object-storage backup into a fresh fs root or MinIO instance.
  5. Point a freshly built app container at the restored Postgres and storage, start it, and run the smoke test script (23.5).
  6. Confirm a known pre-backup design's permalink resolves and renders correctly, confirm the admin catalog looks complete, confirm the migration table matches what is expected for that backup's age.
  7. Record the wall-clock time taken for steps 2-6 as the drill's measured RTO, and the backup's timestamp gap from "now" as the drill's measured RPO. Compare against the targets below.
  8. Tear down the disposable environment.

Expected RTO (Recovery Time Objective): 2 hours from decision-to-restore to a fully serving production replacement, dominated by VPS provisioning and backup download time on a typical broadband connection. RPO for Postgres: ≤ 5 minutes within the last 7 days via WAL replay (Section 6.34); 24 hours for object storage (daily backup).

23.7 Disaster recovery scenarios #

Scenario Detection Steps Expected recovery time
Lost VPS (hardware failure, provider outage) /health alert (Section 21.5) fires and stays red; provider status page confirms Provision a replacement VPS (23.1 sizing), follow the provisioning runbook (23.4) steps 1-9 using the restore drill's restore steps (23.6) in place of a fresh empty database, update DNS if the IP changed ~2-3 hours (RTO from 23.6 plus DNS propagation if the IP changed)
Corrupt database Integrity errors in application logs (error level, Section 21.1), or /health/ready reporting the database check failing with a non-connectivity error Stop the app container to prevent further writes; assess whether point-in-time WAL recovery (to just before corruption) or the last-good daily dump is needed; restore per 23.6 into a fresh Postgres container; verify via the same checks as the restore drill before resuming traffic 1-3 hours depending on how far back a clean recovery point is
Lost object storage /health/ready storage check fails, or render/OG requests start returning SF-5003 (storage error) If using s3: contact the provider, most S3-compatible providers have their own durability guarantees making total loss rare; if it does occur, restore catalog/ and og/ from the off-site backup (23.6) and regenerate all renders/ content by re-running the render pipeline against every existing design (a bulk job iterating designs, safe and idempotent since renders are pure functions of design + build) 2-4 hours, dominated by bulk render regeneration for the existing design set
Lost catalog/ prefix with no backup verify (Section 7.9) fails for every asset Restore from the off-site catalog/ backup; if none exists, every design pinned to that build is permanently unrenderable and only a fresh import of the identical client build can repair it Not recoverable without a backup
Bad publish (a build published with broken/wrong assets) Admin or user reports incorrect renders; admin.publish.purge_failed or a spike in render.failed after a build.published event Immediate rollback via /admin/builds (Section 9's rollback workflow) to the previous build; this is a metadata-only operation (flips which build is active) so it completes in seconds, not a restore Minutes
Compromised admin account Unusual audit_log entries, an admin.login_succeeded from an unrecognized pattern, or a report from the legitimate account holder Immediately invalidate all sessions for that account: UPDATE admin_sessions SET revoked_at = now() WHERE admin_user_id = ... AND revoked_at IS NULL;; force a password reset and TOTP re-enrollment via CLI (skinforge-cli admin reset-credentials --email ... — the same two command names used in 23.4 step 7 and Section 17.1, admin create-user and admin reset-credentials, which invalidates the old TOTP secret and issues a new enrollment); review audit_log for that account's actions since the earliest suspicious activity and reverse any malicious publish/takedown actions. Rotating SKINFORGE_ADMIN_SESSION_SECRET (Section 24.5) does not invalidate sessions (session tokens are random and hash-referenced, not derived from that secret) — it invalidates outstanding pagination cursors and CSRF tokens, and is only relevant here if cursor/CSRF forgery specifically is suspected 30-60 minutes for containment; audit review time varies

23.8 Routine operations runbooks #

Re-import after a game patch (mechanics fully specified in Section 9; this is the operational summary): run the extractor against the operator's freshly patched client copy, review flagged candidates at /admin/candidates, publish the new build once review is complete. Typical cadence: within a few days of an observed UO Outlands client patch.

Purge the render cache: docker compose run --rm app deno task cli -- cache purge-renders [--older-than 90d] — deletes object-storage entries under renders/ (never catalog/ or og/) optionally filtered by age; safe at any time since every render is regenerable on demand from the design + build data. Used to reclaim disk space (23.11's cost model assumes periodic purging of renders older than the traffic long-tail).

Rebuild OG images: docker compose run --rm app deno task cli -- og rebuild [--build-id <id>] — regenerates every og_images row for designs on the given build (or all builds if omitted); used after a change to the OG layout template (Section 14) so previously generated images pick up the new design.

Prune old designs: not a supported operation — designs are never auto-deleted; they are immutable and kept indefinitely (Section 13.7). There is no zero-view or age-based pruning rule anywhere in this system. The only removal path is the takedown process (Section 20.8), which marks a design taken-down and serves 410 without deleting the row; it is a legal/moderation action, not a routine storage-management task.

Rotate secrets: follow Section 24.5's per-secret rotation procedure; for each secret, generate a new value, update .env, and restart the affected container(s) (docker compose up -d --no-deps app for app-level secrets, or a full docker compose restart postgres for the database password, which requires updating secrets/postgres_password.txt and briefly pausing the app during the restart).

Expand storage: for the fs driver, resize the VPS's block storage volume via the provider's console/CLI, then grow the filesystem (resize2fs or provider-equivalent) — no application downtime required. For s3, storage capacity is provider-managed and requires no operator action beyond monitoring the cost implications (23.11).

Investigate a slow page: check the traffic and health dashboards (Section 21.4) for the affected route's p95 latency and the database pool utilization; check skinforge_render_queue_depth if the slow route involves a render; check recent build.published events for a correlated timing; consult structured logs filtered by route and sorted by durationMs descending for the affected window.

Drain and restart: docker compose stop app allows in-flight requests to complete (Deno's default graceful shutdown on SIGTERM, honored by the app's own shutdown handler which stops accepting new connections but lets existing ones finish up to a 30-second grace period) before the container stops; docker compose up -d --no-deps app starts a fresh instance. Used before any manual maintenance that does not warrant full maintenance mode (23.9).

23.9 Maintenance mode #

There are two ways in, and they exist for two different situations. The boot-time kill switch is SKINFORGE_MAINTENANCE_MODE=true plus a restart of the app container (a config change, not a code deploy) — used when the app must come up already in maintenance, for instance ahead of a risky migration or when the operator wants the state to survive a restart. The runtime toggle is the audited switch on /admin/settings (Section 17.14), which writes the maintenance_mode row in app_settings and takes effect within the cached settings accessor's refresh window (Section 24.1) with no restart — used during an incident, when a restart is the last thing wanted. Either being on produces the behavior below; the environment variable wins while it is true, so an operator cannot accidentally clear a deliberate kill switch from the console. When enabled by either route:

  • Every public HTML route returns a static 503 maintenance page (styled consistently with the design system, Section 18) with Retry-After: 300 and no attempt to reach the database.
  • Every public API route (/api/v1/* excluding admin) returns 503 with error SF-9003 (MAINTENANCE_MODE) in the standard error envelope (Section 16).
  • Render and OG endpoints (/render/..., /og/...) continue to serve from cache (CDN and object storage) without hitting the app process at all for already-cached content, since the CDN layer sits in front and maintenance mode only affects origin behavior — a visitor viewing an already- cached permalink during a maintenance window is unaffected for content the CDN already has.
  • /health continues to return 200 (the process is alive) but /health/ready returns 503 with a "maintenance" status field, so external monitors correctly distinguish "intentionally paused" from "actually down" (avoids spurious SEV1 alerts per Section 21.5 during planned maintenance — the alert condition for site-down should be paired with a maintenance-mode check that suppresses the page during a deliberate window, configured as an alert silence tied to the same toggle).
  • /admin/* remains fully functional regardless of maintenance mode, so the operator can complete the work that necessitated maintenance mode (e.g. a risky migration or a manual database repair) without needing to disable it first — and, in the runtime-toggle case, can switch it back off from the same screen that switched it on. Every toggle writes an audit_log row (Section 20.5).

23.10 Monitoring hookup and on-call expectations #

The operator is a single person; there is no on-call rotation. Alerts (Section 21.5) are configured to notify the operator directly, delivered through whatever alerting channel the operator configures against /metrics (Section 21.3) — commonly a self-hosted Prometheus Alertmanager routing to email, a webhook, or a push notification the operator already has set up. This is entirely the operator's own monitoring-stack configuration; SkinForge itself sends no outbound notification of any kind (Section 17.12). The alert conditions and severities are fixed by Section 21.5 regardless of delivery channel.

Expectations, calibrated to a one-person operation:

  • SEV1 alerts are expected to be acknowledged and worked within a best-effort target of 1 hour during waking hours; there is no guaranteed overnight response, which is disclosed nowhere publicly (this is not a paid service with an SLA) but is stated here as the honest operational expectation.
  • SEV2 alerts are worked within 24 hours.
  • SEV3 alerts are triaged within a week, batched with the routine operations schedule (weekly dependency review, 23.11's cost review, etc.).
  • The monthly dependency review (Section 20.10) and the 90-day restore drill (23.6) are the two recurring, calendar-driven tasks that do not wait for an alert to trigger — the operator schedules them proactively.

23.11 Cost model #

Monthly costs at three traffic levels, in USD, using representative 2026 pricing for a VPS provider, a CDN with a free/low tier, and optional managed extras. Assumptions stated per row.

Traffic level VPS CDN Object storage (if s3, beyond VPS disk) Backups off-site storage Domain/TLS Total (approx.)
Low (the "normal traffic" baseline, Section 19.10: ~5k visitors/day) $24/mo (4 vCPU/8GB reference size, 23.1) $0 (free tier covers this volume on any major CDN) $0 (fs driver, fits on VPS disk; skip MinIO) $2/mo (a few GB of encrypted dumps) $1/mo (amortized annual domain cost); TLS is free via Caddy/Let's Encrypt ~$27/mo
Medium (5x baseline, sustained community growth) $24/mo (same size still sufficient per the capacity model, 19.10) $0-5/mo (still within most free tiers; small overage possible) $5/mo (moves to S3-compatible storage for easier backup/durability at this scale) $3/mo $1/mo ~$33-38/mo
High (a sustained viral period, 20-30x baseline) $48/mo (upgrade to 8 vCPU/16GB per the scaling steps in 23.12) $10-20/mo (CDN overage at high image-serving volume) $10/mo $5/mo $1/mo ~$74-84/mo

The architecture's cache-heavy, content-addressed design (Section 19.4) keeps CDN egress — usually the dominant variable cost for an image-serving app — low relative to raw traffic volume, since the large majority of repeat image requests are edge cache hits that many CDN providers do not bill as origin bandwidth. Donation revenue (an optional donation link the product may display, SKINFORGE_DONATION_URL, Section 24.2) is the intended offset for these costs; the spec makes no assumption about donation income covering costs and treats the operator as fully responsible for hosting costs regardless.

23.12 Scaling steps in order #

Step Trigger metric Action
1. Vertical VPS upgrade Sustained skinforge_db_pool_in_use near saturation, or render queue depth (19.8) regularly exceeding 100 during normal (non-spike) traffic Resize the VPS to 8 vCPU/16GB (provider-supported live resize where available); bump SKINFORGE_DB_POOL_SIZE and SKINFORGE_RENDER_MAX_CONCURRENCY proportionally
2. Move to managed/external S3-compatible storage Disk usage on the VPS approaching 70% capacity, or backup times (23.6) growing uncomfortably long Switch SKINFORGE_STORAGE_DRIVER to s3, set SKINFORGE_S3_ENDPOINT, SKINFORGE_S3_REGION, SKINFORGE_S3_BUCKET and SKINFORGE_S3_PUBLIC_BASE_URL (Section 24.2) to point at a managed provider (Cloudflare R2, Backblaze B2) instead of self-hosted MinIO, migrate existing storage_fs contents with a one-off deno task cli -- storage migrate-to-s3 script
3. Move Postgres to a managed database service Database becomes the bottleneck independent of app-tier scaling (step 1 no longer helps), or the operator wants managed backups/HA beyond what 23.6 self-manages Point SKINFORGE_DATABASE_URL at a managed Postgres 18 instance; retire the postgres Compose service; this is a configuration change plus a pg_dump/pg_restore migration, not a code change
4. Add a second app replica behind Caddy Sustained request rate approaching the point where a single Deno process's event loop plus render concurrency (19.8) becomes the ceiling, or true zero-downtime deploys (23.5) become a hard requirement Add a second app service instance (or use Docker Compose's --scale app=2) with Caddy load-balancing across both; requires the in-process LRU caches (19.2) to be accepted as per-replica (already designed for this) and rate-limit buckets to move fully onto the Postgres-backed path (already the documented fallback in 19.2)
5. Introduce a CDN-level image resizing/edge-compute tier Egress costs (23.11) becoming the dominant cost driver at very high traffic Offload scale/format negotiation (19.6) to CDN edge compute where the provider supports it, reducing origin render load further; this is an optimization on top of the existing content-addressed scheme, not an architecture change

Each step is deliberately incremental and reversible, consistent with the scaling posture in Section 4.8, which keeps the canonical deployment target a single-VPS Docker Compose topology for as long as it serves the traffic level — these steps are triggered by measured metrics, never adopted speculatively.

24. Configuration & Environment Variables #

24.1 Configuration philosophy #

Environment variables configure the process at boot and are immutable at runtime. There is no config file read at runtime, no layered override, and no code path that mutates a value in Section 24.2 after loadConfig() has returned — changing any of them means editing .env and restarting the container.

That covers everything about how the process runs. It does not cover a handful of operational behaviours the operator needs to change without a restart, which is why a small app_settings table (Section 6) exists, is edited on /admin/settings (Section 17.14), and is read everywhere through one cached accessor (getSetting(key), a 60-second in-process TTL, so a change is live within a minute across the app without a per-request query). The split is exact:

Kind Where it lives How it changes Contents
Process configuration Environment variables, Section 24.2 Edit .env, restart the container Every variable in 24.2 — ports, credentials, driver selection, pool sizes, rate limits, timeouts, feature flags
Operator settings app_settings rows (Section 6), one row per key Admin console, audited (audit_log, Section 20.5), effective within the accessor's 60-second TTL takedown_response_template, admin_ip_allowlist, donation_link_text, designs_like_this_threshold (default 2, Section 13.8), featured_designs, and maintenance_mode (the soft toggle in Section 23.9)

Nothing appears in both columns. The one place they touch is maintenance mode: the environment variable SKINFORGE_MAINTENANCE_MODE is a boot-time kill switch that wins while it is true, and the maintenance_mode setting is the runtime toggle used during an incident (Section 23.9 owns the resulting behaviour). Secrets are never operator settings — an app_settings row is never a credential, and no value in that table is read before the boot validator has run.

Every variable is validated at process boot by a single Zod 4.x schema (core/config.ts); a missing required variable or a value that fails its type/format check makes the process exit immediately with a non-zero code and a readable error listing every failing variable at once (not just the first one found), so an operator fixes every problem in one pass rather than one restart per typo. Boolean variables accept true/false (exact strings) only; any other value is a boot-time validation failure — a boolean is never coerced from truthiness. No secret is ever committed to the repository (Section 20.10); .gitignore excludes .env* except .env.example.

24.2 The canonical variable table #

Every name below is the canonical name; no other section may introduce a SKINFORGE_ variable that is absent from this table. Consumed by lists the owning section(s) for the behavior the variable controls. A variable with a Default is never Required; Required: yes means the boot validator rejects an unset value.

Variable Type Required Default Example Consumed by Secret
SKINFORGE_ENV enum: development|staging|production|test yes production 23.3 no
SKINFORGE_PUBLIC_BASE_URL URL (https in staging/production) yes https://skinforge.example 10, 13, 14, 23.2 no
SKINFORGE_PORT integer, 1-65535 no 8000 8000 23.1, 23.2 no
SKINFORGE_DATABASE_URL Postgres connection string yes postgres://skinforge:***@postgres:5432/skinforge 6, 19.7 yes
SKINFORGE_DB_POOL_SIZE integer, 1-100 no 10 (the CLI process overrides this to 4 in code, Section 24.3's loadCliConfig) 10 19.7 no
SKINFORGE_STORAGE_DRIVER enum: fs|s3 no fs fs 4.7, 6, 23.2, 23.12 no
SKINFORGE_STORAGE_FS_ROOT absolute path conditional (fs driver) /data/storage /data/storage 23.2 no
SKINFORGE_S3_ENDPOINT URL conditional (s3 driver) https://<accountid>.r2.cloudflarestorage.com 23.12 no
SKINFORGE_S3_REGION string conditional (s3 driver) auto auto 23.12 no
SKINFORGE_S3_BUCKET string conditional (s3 driver) skinforge-prod 23.12 no
SKINFORGE_S3_ACCESS_KEY_ID string conditional (s3 driver) AKIA... 23.12 yes
SKINFORGE_S3_SECRET_ACCESS_KEY string conditional (s3 driver) *** 23.12 yes
SKINFORGE_S3_PUBLIC_BASE_URL URL conditional (s3 driver) https://cdn.skinforge.example 19.4 no
SKINFORGE_ADMIN_SESSION_SECRET base64 string decoding to ≥ 32 bytes (≥ 44 characters) yes *** 20.5 yes
SKINFORGE_ADMIN_SESSION_TTL_HOURS integer, 1-168 no 12 12 17.1 no
SKINFORGE_ADMIN_TOTP_ISSUER string (URL-encoded by the caller before being embedded in the otpauth:// URI, Section 17.2) no SkinForge SkinForge 20.5, 17 no
SKINFORGE_RENDER_CACHE_DIR absolute path no /data/render-cache /data/render-cache 8, 19.2 no
SKINFORGE_RENDER_MAX_CONCURRENCY integer, 1-32 no 4 4 19.8 no
SKINFORGE_RENDER_TIMEOUT_MS integer, 100-60000 no 8000 8000 8.10, 19.8 no
SKINFORGE_OG_ENABLED boolean no true true 14, 24.7 no
SKINFORGE_OG_TIMEOUT_MS integer, 100-60000 no 3000 3000 14.8 no
SKINFORGE_RATE_LIMIT_API_PER_MINUTE integer, 1-10000 no 120 120 20.1 no
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE integer, 1-10000 no 60 60 19.8, 20.1 no
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR integer, 1-1000 no 30 30 20.1 no
SKINFORGE_LOG_LEVEL enum: debug|info|warn|error no info info 21.1 no
SKINFORGE_METRICS_ENABLED boolean no true true 21.3, 24.7 no
SKINFORGE_METRICS_TOKEN base64 string, ≥ 32 characters (≥ 24 bytes) conditional (METRICS_ENABLED=true) *** 21.3 yes
SKINFORGE_IMPORT_WORKDIR absolute path no /data/import-work /data/import-work 7, 20.6 no
SKINFORGE_IMPORT_MAX_UPLOAD_MB integer, 1-10000 no 2048 2048 20.6 no
SKINFORGE_DONATION_URL URL no — (donation link hidden if unset) https://ko-fi.com/example 10 no
SKINFORGE_CONTACT_EMAIL email address yes contact@skinforge.example 20.8, 21.9 no
SKINFORGE_CDN_PURGE_URL URL conditional (CDN purge configured) https://api.cdn.example/purge 19.5 no
SKINFORGE_CDN_PURGE_TOKEN string conditional (CDN purge configured) *** 19.5 yes
SKINFORGE_TRUSTED_PROXY_HOPS integer, 0-10 no 1 1 20.7 no
SKINFORGE_MAINTENANCE_MODE boolean no false false 23.9, 24.7 no
SKINFORGE_JOB_RUNNER_ENABLED boolean no true true 4.7, 24.7 no
SKINFORGE_IMAGE_TAG string (container image tag) no latest 2026.09.1 23.2, 23.5 no

One variable is read by Docker Compose, not by the application: SKINFORGE_IMAGE_TAG substitutes into the app service's image: key (Section 23.2) at docker compose up/pull time and is what makes a release and a rollback a tag change rather than a rebuild (Section 23.5). It is listed here because Section 24.2 is the canonical list of every SKINFORGE_ name in the system, but it is deliberately absent from the boot schema in 24.3 — the application never reads it, and loadConfig() ignores environment entries it does not declare rather than rejecting them.

Conditional-required semantics: a variable marked conditional becomes required exactly when the condition named in its Required cell holds (e.g. every SKINFORGE_S3_* variable becomes required only when SKINFORGE_STORAGE_DRIVER=s3); the boot validator (24.3) encodes this with Zod's discriminated-union/.refine() pattern rather than making every variable unconditionally required, so a fs-driver deployment's .env does not need placeholder S3 values.

SKINFORGE_TRUSTED_PROXY_HOPS: the number of trusted reverse-proxy hops (CDN + Caddy) between the client and the app process, used to correctly parse the real originating IP from X-Forwarded-For for rate limiting and truncated-IP logging (Section 20.7) without trusting a client-forged header value beyond the configured hop count.

24.3 Boot-time validation module #

// core/config.ts
import { z } from "zod";

// Strict boolean parsing: the exact string "true" or "false" only. z.coerce.boolean() is never used
// anywhere in this schema — Boolean("false") === true in JavaScript, which would silently pin
// SKINFORGE_MAINTENANCE_MODE=false into permanent maintenance mode.
const strictBoolean = (defaultValue: "true" | "false") =>
  z.enum(["true", "false"]).default(defaultValue).transform((v) => v === "true");

const absPath = z.string().min(1).refine((p) => p.startsWith("/"), "must be an absolute path");

const baseSchema = z.object({
  SKINFORGE_ENV: z.enum(["development", "staging", "production", "test"]),
  SKINFORGE_PUBLIC_BASE_URL: z.string().url(),
  SKINFORGE_PORT: z.coerce.number().int().min(1).max(65535).default(8000),
  SKINFORGE_DATABASE_URL: z.string().min(1),
  SKINFORGE_DB_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
  SKINFORGE_STORAGE_DRIVER: z.enum(["fs", "s3"]).default("fs"),
  SKINFORGE_STORAGE_FS_ROOT: absPath.default("/data/storage"),
  SKINFORGE_S3_ENDPOINT: z.string().url().optional(),
  SKINFORGE_S3_REGION: z.string().default("auto"),
  SKINFORGE_S3_BUCKET: z.string().optional(),
  SKINFORGE_S3_ACCESS_KEY_ID: z.string().optional(),
  SKINFORGE_S3_SECRET_ACCESS_KEY: z.string().optional(),
  SKINFORGE_S3_PUBLIC_BASE_URL: z.string().url().optional(),
  SKINFORGE_ADMIN_SESSION_SECRET: z.string().regex(
    /^[A-Za-z0-9+/]{43,}={0,2}$/,
    "must be base64 encoding at least 32 bytes",
  ),
  SKINFORGE_ADMIN_SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(12),
  SKINFORGE_ADMIN_TOTP_ISSUER: z.string().default("SkinForge"),
  SKINFORGE_RENDER_CACHE_DIR: absPath.default("/data/render-cache"),
  SKINFORGE_RENDER_MAX_CONCURRENCY: z.coerce.number().int().min(1).max(32).default(4),
  SKINFORGE_RENDER_TIMEOUT_MS: z.coerce.number().int().min(100).max(60000).default(8000),
  SKINFORGE_OG_ENABLED: strictBoolean("true"),
  SKINFORGE_OG_TIMEOUT_MS: z.coerce.number().int().min(100).max(60000).default(3000),
  SKINFORGE_RATE_LIMIT_API_PER_MINUTE: z.coerce.number().int().min(1).max(10000).default(120),
  SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE: z.coerce.number().int().min(1).max(10000).default(60),
  SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR: z.coerce.number().int().min(1).max(1000).default(30),
  SKINFORGE_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  SKINFORGE_METRICS_ENABLED: strictBoolean("true"),
  SKINFORGE_METRICS_TOKEN: z.string().min(32).optional(),
  SKINFORGE_IMPORT_WORKDIR: absPath.default("/data/import-work"),
  SKINFORGE_IMPORT_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10000).default(2048),
  SKINFORGE_DONATION_URL: z.string().url().optional(),
  SKINFORGE_CONTACT_EMAIL: z.string().email(),
  SKINFORGE_CDN_PURGE_URL: z.string().url().optional(),
  SKINFORGE_CDN_PURGE_TOKEN: z.string().optional(),
  SKINFORGE_TRUSTED_PROXY_HOPS: z.coerce.number().int().min(0).max(10).default(1),
  SKINFORGE_MAINTENANCE_MODE: strictBoolean("false"),
  SKINFORGE_JOB_RUNNER_ENABLED: strictBoolean("true"),
});

const configSchema = baseSchema
  .superRefine((c, ctx) => {
    if (
      c.SKINFORGE_STORAGE_DRIVER === "s3" && !(
        c.SKINFORGE_S3_ENDPOINT && c.SKINFORGE_S3_BUCKET &&
        c.SKINFORGE_S3_ACCESS_KEY_ID && c.SKINFORGE_S3_SECRET_ACCESS_KEY &&
        c.SKINFORGE_S3_PUBLIC_BASE_URL
      )
    ) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["SKINFORGE_S3_ENDPOINT"],
        message: "SKINFORGE_S3_* variables are required when SKINFORGE_STORAGE_DRIVER=s3",
      });
    }
  })
  .superRefine((c, ctx) => {
    if (c.SKINFORGE_METRICS_ENABLED && !c.SKINFORGE_METRICS_TOKEN) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["SKINFORGE_METRICS_TOKEN"],
        message: "SKINFORGE_METRICS_TOKEN is required when SKINFORGE_METRICS_ENABLED=true",
      });
    }
  })
  .refine(
    (c) => c.SKINFORGE_ENV === "development" || c.SKINFORGE_ENV === "test" ||
      c.SKINFORGE_PUBLIC_BASE_URL.startsWith("https://"),
    {
      message: "SKINFORGE_PUBLIC_BASE_URL must use https in staging and production",
      path: ["SKINFORGE_PUBLIC_BASE_URL"],
    },
  );

export type Config = z.infer<typeof configSchema>;

export function loadConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
  const result = configSchema.safeParse(env);
  if (!result.success) {
    const lines = result.error.issues.map((issue) =>
      `  - ${issue.path.join(".")}: ${issue.message}`
    );
    console.error(
      `Configuration validation failed with ${lines.length} error(s):\n${lines.join("\n")}\n` +
        `Fix the listed environment variables and restart.`,
    );
    Deno.exit(1);
  }
  return result.data;
}

// The CLI process shares every rule above but runs a smaller worker pool by default; it overrides
// SKINFORGE_DB_POOL_SIZE in code rather than via a second schema.
export function loadCliConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
  const config = loadConfig(env);
  return {
    ...config,
    SKINFORGE_DB_POOL_SIZE: env.SKINFORGE_DB_POOL_SIZE
      ? config.SKINFORGE_DB_POOL_SIZE
      : 4,
  };
}

Exact error output format (printed to stderr, process exits with code 1):

Configuration validation failed with 2 error(s):
  - SKINFORGE_DATABASE_URL: Invalid input: expected string, received undefined
  - SKINFORGE_ADMIN_SESSION_SECRET: must be base64 encoding at least 32 bytes
Fix the listed environment variables and restart.

Message text after the variable name comes from Zod (Section 4) or from a superRefine/refine custom message above; tests assert on the variable names and the exit code, never on Zod's own wording, since upgrading Zod can change its built-in message text without changing behavior.

loadConfig() (or loadCliConfig() for the CLI process) is called exactly once, at the top of main.ts and cli/main.ts, before any other module that depends on configuration is imported side-effectfully; the resulting Config object is passed explicitly through the application's dependency graph (constructor/factory parameters), never read again from Deno.env deeper in the call stack — this makes every module's configuration dependency visible in its function signature and keeps loadConfig the single point of truth.

24.4 Per-environment example files #

.env.example (development, committed to the repository):

SKINFORGE_ENV=development
SKINFORGE_PUBLIC_BASE_URL=http://localhost:8000
SKINFORGE_PORT=8000
SKINFORGE_DATABASE_URL=postgres://skinforge:devpassword@localhost:5432/skinforge_dev
SKINFORGE_DB_POOL_SIZE=5
SKINFORGE_STORAGE_DRIVER=fs
SKINFORGE_STORAGE_FS_ROOT=/tmp/skinforge-dev/storage
SKINFORGE_ADMIN_SESSION_SECRET=ZGV2LW9ubHktc2VjcmV0LW5vdC1mb3ItcHJvZHVjdGlvbi11c2UtcGxlYXNlLXJvdGF0ZS10aGlzLXZhbHVl
SKINFORGE_ADMIN_SESSION_TTL_HOURS=12
SKINFORGE_ADMIN_TOTP_ISSUER=SkinForge (dev)
SKINFORGE_RENDER_CACHE_DIR=/tmp/skinforge-dev/render-cache
SKINFORGE_RENDER_MAX_CONCURRENCY=2
SKINFORGE_RENDER_TIMEOUT_MS=8000
SKINFORGE_OG_ENABLED=true
SKINFORGE_OG_TIMEOUT_MS=3000
SKINFORGE_RATE_LIMIT_API_PER_MINUTE=1000
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE=1000
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR=1000
SKINFORGE_LOG_LEVEL=debug
SKINFORGE_METRICS_ENABLED=false
SKINFORGE_IMPORT_WORKDIR=/tmp/skinforge-dev/import-work
SKINFORGE_IMPORT_MAX_UPLOAD_MB=2048
SKINFORGE_CONTACT_EMAIL=dev@example.com
SKINFORGE_TRUSTED_PROXY_HOPS=0
SKINFORGE_MAINTENANCE_MODE=false
SKINFORGE_JOB_RUNNER_ENABLED=true

.env.staging.example:

SKINFORGE_ENV=staging
SKINFORGE_PUBLIC_BASE_URL=https://staging.skinforge.example
SKINFORGE_PORT=8000
SKINFORGE_DATABASE_URL=postgres://skinforge:__GENERATED__@postgres:5432/skinforge_staging
SKINFORGE_DB_POOL_SIZE=10
SKINFORGE_STORAGE_DRIVER=fs
SKINFORGE_STORAGE_FS_ROOT=/data/storage
SKINFORGE_ADMIN_SESSION_SECRET=__GENERATED__
SKINFORGE_ADMIN_SESSION_TTL_HOURS=12
SKINFORGE_ADMIN_TOTP_ISSUER=SkinForge (staging)
SKINFORGE_RENDER_CACHE_DIR=/data/render-cache
SKINFORGE_RENDER_MAX_CONCURRENCY=4
SKINFORGE_RENDER_TIMEOUT_MS=8000
SKINFORGE_OG_ENABLED=true
SKINFORGE_OG_TIMEOUT_MS=3000
SKINFORGE_RATE_LIMIT_API_PER_MINUTE=600
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE=300
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR=150
SKINFORGE_LOG_LEVEL=info
SKINFORGE_METRICS_ENABLED=true
SKINFORGE_METRICS_TOKEN=__GENERATED__
SKINFORGE_IMPORT_WORKDIR=/data/import-work
SKINFORGE_IMPORT_MAX_UPLOAD_MB=2048
SKINFORGE_CONTACT_EMAIL=staff@skinforge.example
SKINFORGE_CDN_PURGE_URL=https://api.cdn.example/purge
SKINFORGE_CDN_PURGE_TOKEN=__GENERATED__
SKINFORGE_TRUSTED_PROXY_HOPS=1
SKINFORGE_MAINTENANCE_MODE=false
SKINFORGE_JOB_RUNNER_ENABLED=true
SKINFORGE_IMAGE_TAG=latest

.env.production.example:

SKINFORGE_ENV=production
SKINFORGE_PUBLIC_BASE_URL=https://skinforge.example
SKINFORGE_PORT=8000
SKINFORGE_DATABASE_URL=postgres://skinforge:__GENERATED__@postgres:5432/skinforge
SKINFORGE_DB_POOL_SIZE=10
SKINFORGE_STORAGE_DRIVER=fs
SKINFORGE_STORAGE_FS_ROOT=/data/storage
SKINFORGE_ADMIN_SESSION_SECRET=__GENERATED__
SKINFORGE_ADMIN_SESSION_TTL_HOURS=12
SKINFORGE_ADMIN_TOTP_ISSUER=SkinForge
SKINFORGE_RENDER_CACHE_DIR=/data/render-cache
SKINFORGE_RENDER_MAX_CONCURRENCY=4
SKINFORGE_RENDER_TIMEOUT_MS=8000
SKINFORGE_OG_ENABLED=true
SKINFORGE_OG_TIMEOUT_MS=3000
SKINFORGE_RATE_LIMIT_API_PER_MINUTE=120
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE=60
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR=30
SKINFORGE_LOG_LEVEL=info
SKINFORGE_METRICS_ENABLED=true
SKINFORGE_METRICS_TOKEN=__GENERATED__
SKINFORGE_IMPORT_WORKDIR=/data/import-work
SKINFORGE_IMPORT_MAX_UPLOAD_MB=2048
SKINFORGE_DONATION_URL=https://ko-fi.com/example
SKINFORGE_CONTACT_EMAIL=contact@skinforge.example
SKINFORGE_CDN_PURGE_URL=https://api.cdn.example/purge
SKINFORGE_CDN_PURGE_TOKEN=__GENERATED__
SKINFORGE_TRUSTED_PROXY_HOPS=1
SKINFORGE_MAINTENANCE_MODE=false
SKINFORGE_JOB_RUNNER_ENABLED=true
SKINFORGE_IMAGE_TAG=2026.09.1

Every __GENERATED__ placeholder must be replaced using the commands in Section 24.5 before the file is used; committing a real .env.* file (as opposed to the .example templates above, which contain no real secrets) is prevented by .gitignore.

24.5 Secret management #

Secret Generation command Rotation procedure Blast radius if leaked
SKINFORGE_DATABASE_URL password component openssl rand -base64 32 Update the Postgres role password (ALTER ROLE skinforge WITH PASSWORD '...'), update .env and secrets/postgres_password.txt, restart postgres then app Full database read/write access
SKINFORGE_ADMIN_SESSION_SECRET openssl rand -base64 48 Update .env, restart app. This invalidates every outstanding pagination cursor (Section 16.5.1) and every CSRF token (Section 20.5), both of which are derived from this secret. It does not invalidate sessions — session tokens are random and hash-referenced (Section 17.1), not derived from this secret; to invalidate sessions run UPDATE admin_sessions SET revoked_at = now() WHERE revoked_at IS NULL; Ability to forge pagination cursors and CSRF tokens
SKINFORGE_METRICS_TOKEN openssl rand -base64 32 Update .env, restart app, update the scraping monitoring system's configured token Read access to internal metrics (traffic patterns, queue depths — not sensitive data, but useful for an attacker planning abuse)
SKINFORGE_S3_SECRET_ACCESS_KEY Generated by the S3 provider's console/CLI, not locally Rotate via the provider's console (issue a new key pair, update .env, restart app, then revoke the old key pair once confirmed working) Read/write access to object storage (rendered images are not sensitive, but write access could allow defacement)
SKINFORGE_CDN_PURGE_TOKEN Generated by the CDN provider's console Rotate via the provider's console, update .env, restart app Ability to trigger cache purges (denial-of-service-adjacent nuisance, not data exposure)

Storage on the host: the .env file lives at the repository root on the VPS with filesystem permissions 600, owned by the deploy user, never world- or group-readable. secrets/postgres_password.txt (referenced by Compose's secrets: block, Section 23.2) has the same permission discipline and is mounted into the postgres container via Docker's secrets mechanism rather than passed as a plain environment variable, so it does not appear in docker inspect output or process listings inside that container.

Rule against committing secrets: .gitignore excludes .env, .env.local, .env.staging, .env.production, and secrets/* except a secrets/.gitkeep; only the three .example/template files in Section 24.4 (which contain no real values) are committed. CI additionally runs a secret-scanning step (part of the security job in Section 22.11's dependency audit stage) that fails the build if a high-entropy string matching common secret patterns appears in a diff.

24.6 Configuration precedence, boot log line, and post-deploy verification #

Precedence: environment variables set in the container's runtime environment (via Compose's env_file: ../.env directive, Section 23.2) are the only source of process configuration — there is no layered override (no command-line flags overriding env vars, no separate "local overrides" file beyond the developer's own .env in development). This single-source model is deliberate: Section 24.1's philosophy rules out multiple configuration sources so that "what value is actually in effect" is always answerable by reading one file. The app_settings rows in 24.1 are not part of this precedence chain and never shadow a variable: they are operational content with their own screen, their own audit trail and their own cached accessor, and the two sets of keys are disjoint.

Boot log line: on successful validation, loadConfig()'s caller emits one app.started structured log event (Section 21.2) containing a non-secret summary of effective configuration — every variable in Section 24.2 marked Secret: no, with every Secret: yes variable replaced by a fixed "[redacted]" marker (present/absent only, confirming it was set, never its value or even its length):

{
  "timestamp": "2026-09-03T12:00:01.234Z",
  "level": "info",
  "event": "app.started",
  "version": "2026.09.1",
  "env": "production",
  "effectiveConfigSummary": {
    "SKINFORGE_PORT": 8000,
    "SKINFORGE_STORAGE_DRIVER": "fs",
    "SKINFORGE_RENDER_MAX_CONCURRENCY": 4,
    "SKINFORGE_RATE_LIMIT_API_PER_MINUTE": 120,
    "SKINFORGE_LOG_LEVEL": "info",
    "SKINFORGE_METRICS_ENABLED": true,
    "SKINFORGE_MAINTENANCE_MODE": false,
    "SKINFORGE_ADMIN_SESSION_SECRET": "[redacted]",
    "SKINFORGE_DATABASE_URL": "[redacted]"
  }
}

Verifying configuration after deploy: the operator runs docker compose logs app --tail 50 | grep app.started immediately after a deploy and visually confirms the printed summary matches the intended environment (correct env, correct SKINFORGE_STORAGE_DRIVER, correct rate limits per Section 23.3's per-environment table) — this is included as a step in the smoke test script (Section 23.5). Additionally, GET /health/ready (Section 21.7) confirms the runtime dependencies configuration resolved to are actually reachable, which catches a syntactically valid but pointed-at-the-wrong-place SKINFORGE_DATABASE_URL that boot-time schema validation alone cannot catch (schema validation checks shape, not reachability).

24.7 Feature flags via environment #

Feature flags are plain environment variables, per Section 4.6, which rules out a separate runtime flag service. Each flag's effect:

Flag When false/disabled When true/enabled (default)
SKINFORGE_OG_ENABLED /og/d/:code.png returns a static, generic placeholder OG image instead of a per-design generated one (Section 14); no satori/resvg work is performed Full per-design OG generation per Section 14
SKINFORGE_JOB_RUNNER_ENABLED The in-process job runner (Section 4.7, Section 19.8) does not claim or process jobs from the jobs table; jobs still enqueue normally but sit queued — used to pause background work during a maintenance operation without stopping the whole app Jobs are claimed and processed continuously
SKINFORGE_MAINTENANCE_MODE Normal operation Full maintenance-mode behavior per Section 23.9
SKINFORGE_METRICS_ENABLED GET /metrics returns 404; no metrics are collected in memory at all (not just hidden — the registry itself is a no-op), avoiding any overhead in environments that do not want it (e.g. a lightweight local development run) Full metrics collection and exposition per Section 21.3

Every flag defaults to the production-safe, fully-enabled behavior except SKINFORGE_MAINTENANCE_MODE (defaults off, obviously) — the philosophy is that disabling a feature is always an explicit, deliberate operator action, never an accidental default.

25. Error Catalog & Edge Case Register #

25.1 Error taxonomy #

Every error code has the shape SF- followed by exactly 4 digits, grouped into family ranges by leading digit:

Range Family
1000-1999 Validation
2000-2999 Not found / gone
3000-3999 Rate limit
4000-4999 Auth
5000-5999 Render
6000-6999 Import
9000-9999 Internal

The leading digit is the only part of a code with reserved meaning; the remaining three digits are assigned sequentially within a family as errors are added, with no further sub-grouping implied by digit position. A client integration may safely branch on the leading digit alone (for example, treating any 4xxx code as an auth problem worth redirecting to /admin for) without maintaining its own copy of the full registry, while still being able to display the exact registry message for any code it does recognize.

The JSON error envelope shape ({"error": {"code", "message", "field", "details", "requestId"}}) is defined in Section 16.4; this section owns only the registry of codes and messages, never the envelope structure itself.

Section 25.2 is the sole registry of SF- codes in this document: no other section may use a code with a meaning or HTTP status different from the one assigned to it below, and no other section may mint a new code on its own — a section that needs a new condition covered points here for the number to be added. SF-x999 values (e.g. SF-1999) are family upper bounds, not assignable codes; every assignable code is listed in the registry below.

A code may also be reserved: listed in the registry, permanently unassignable, and used by no section. A reserved code is either a number that was minted for a mechanism this version does not specify, or one whose condition turned out to be covered by another code. Reserved rows stay in the table rather than being deleted, so the numeric sequence never implies that a missing number is free to reuse.

Codes are permanent once shipped: a code is never reassigned to a different meaning, and a retired error path's code is never reused for something unrelated — client integrations and support history may reference a code long after the situation that produced it has changed shape. When a new error condition is added, it receives the next unused number within its family's range rather than reusing a gap left by a removed one, so the numeric sequence within a family also serves as a rough chronological record of when each check was introduced.

25.2 The error registry #

Code HTTP status Internal meaning User-facing message Retryable Log event
SF-1000 400 Generic schema validation failure "That request isn't valid. Check the highlighted field and try again." no http.request (warn)
SF-1001 400 Missing required field "This field is required." no http.request (warn)
SF-1002 400 Field exceeds max length "That value is too long." no http.request (warn)
SF-1003 400 Invalid body code (not m/f) "Choose a valid body type." no http.request (warn)
SF-1004 400 Invalid hue index format "That hue value isn't valid." no http.request (warn)
SF-1005 400 Hue index outside 0–65535 "That hue is out of range." no http.request (warn)
SF-1006 400 Invalid asset key format "That asset reference isn't valid." no http.request (warn)
SF-1007 400 Invalid slot key "That slot isn't recognized." no http.request (warn)
SF-1008 400 Design JSON contains unknown top-level key "That design data isn't valid." no http.request (warn)
SF-1009 400 Design JSON slot appears more than once "Each slot can only be set once." no http.request (warn)
SF-1010 400 Malformed pagination cursor "That page reference isn't valid; start from the first page." no http.request (warn)
SF-1012 400 Invalid scale parameter (not 1/2/3) "That image size isn't supported." no http.request (warn)
SF-1013 400 Invalid format parameter (not webp/png) "That image format isn't supported." no http.request (warn)
SF-1014 400 Search query exceeds 200 characters "Shorten your search." no http.request (warn)
SF-1015 415 Unsupported request content type "That request format isn't supported." no http.request (warn)
SF-1016 406 No acceptable response representation for the request's Accept header — JSON API surfaces only; the image namespaces /render/* and /og/* never negotiate on Accept and can never return this code, because the extension in the path is authoritative (Sections 8.8, 19.6) "We can't return that format." no http.request (warn)
SF-1017 400 Gender-restricted slot/asset mismatch (e.g. beard on female body) "That item isn't available for the selected body." no http.request (warn)
SF-1019 422 Well-formed but semantically invalid combination (asset does not belong to declared slot) "That item doesn't belong in that slot." no http.request (warn)
SF-1020 400 Search query has unterminated quote "Check the quotes in your search." no http.request (warn)
SF-1021 400 Query string has too many parameters (> 200) "That link has too many parameters." no http.request (warn)
SF-2000 404 Design short code not found, or the supplied code is malformed (wrong length/alphabet) — the two are deliberately indistinguishable so a client probing for valid codes cannot tell "malformed" from "not found" (Section 13.4.1) "We couldn't find that design. The link may be mistyped." no design.resolved (info, cacheHit: false, found: false)
SF-2001 404 Asset key not found "That item isn't in our catalog." no http.request (warn)
SF-2002 404 Hue index not found "That hue isn't in our catalog." no http.request (warn)
SF-2004 404 Slot key not found in registry "That slot isn't recognized." no http.request (warn)
SF-2005 410 Design removed via takedown "This design is no longer available." no design.takedown_served (info)
SF-2006 404 Route not found "That page doesn't exist." no http.request (warn)
SF-2007 404 Asset exists but has no row in the requested build (not merely retired — a retired asset still resolves normally from the build it belongs to) "That item is no longer available in this build." no http.request (warn)
SF-2008 404 Import run not found (admin) "That import run doesn't exist." no http.request (warn)
SF-2009 404 Build not found (admin) "That build doesn't exist." no http.request (warn)
SF-2010 404 Extraction candidate not found (admin) "That candidate doesn't exist, it may already be resolved." no http.request (warn)
SF-3000 429 Public API rate limit exceeded "You're sending requests too quickly. Please wait a moment." yes rate_limit.exceeded (warn)
SF-3001 429 Render rate limit exceeded "Too many image requests right now. Please wait a moment." yes rate_limit.exceeded (warn)
SF-3002 429 Design creation rate limit exceeded "You've created a lot of designs recently. Please wait before creating another." yes rate_limit.exceeded (warn)
SF-3003 429 Admin login rate limit exceeded — the single lockout code, covering password and TOTP failures alike, since Section 17.2.3 counts both in one per-account budget (5 combined failures in a rolling 15 minutes → 15-minute cooldown) "Too many login attempts. Try again in 15 minutes." yes admin.login_failed (warn)
SF-3004 Reserved (was a per-IP login limit; Section 17.2.3 rules out per-IP login lockout, because it would let an attacker lock a legitimate admin out from a shared address) n/a
SF-3005 Reserved (was a separate TOTP-attempt limit; TOTP failures count against the same SF-3003 budget) n/a
SF-4000 401 No admin session present "Please sign in." no http.request (warn)
SF-4001 401 Admin session expired or invalid "Your session has expired. Please sign in again." no admin.session_revoked (info)
SF-4002 401 Invalid email/password on login "That email or password isn't correct." no admin.login_failed (warn)
SF-4003 403 CSRF token missing or mismatched "Your session looks out of date. Please refresh and try again." no http.request (warn)
SF-4004 401 Invalid TOTP code. The code is distinct so logs and support can tell the second factor from the first, but the message deliberately matches SF-4002's: an attacker who reaches the TOTP step already knows the password was right, and a distinct message would confirm it. Section 17.2.1's login flow returns SF-4004 with this generic text "That email or password isn't correct." no admin.login_failed (warn)
SF-4005 403 Permission denied — session valid but the acting role lacks the permission this action requires (Section 17.3) "You don't have permission to do that." no http.request (warn)
SF-4006 Reserved (was a second, 401-status spelling of the lockout condition SF-3003 already covers at 429; one meaning, one status, so this number is retired rather than reused) n/a
SF-4007 401 Recovery code invalid or already used "That recovery code isn't valid." no admin.login_failed (warn)
SF-4008 Reserved (step-up re-authentication; no route in Section 17 requires a fresh session, so nothing raises it in v1) n/a
SF-4010 Reserved (TOTP replay detection; Section 17.2 does not store consumed codes in v1, so nothing raises it) n/a
SF-5000 500 Referenced sprite missing for the requested asset/body combination — a server-side data fault, logged and served from a never-cached placeholder, not a client error "We couldn't render that combination." no render.failed (error)
SF-5001 500 Encoder error (WASM PNG/WebP encode failed) "We couldn't generate that image. Please try again." yes render.failed (error)
SF-5002 504 Render timed out "That image is taking too long to generate. Please try again." yes render.failed (error)
SF-5003 502 Object storage read/write error during render "We couldn't save or load that image right now." yes render.failed (error)
SF-5004 503 Render queue full (back-pressure) "We're generating a lot of images right now. Please try again shortly." yes render.queued (warn)
SF-5005 500 Hue table missing or corrupted for a hue that should exist "We couldn't apply that hue right now." yes render.failed (error)
SF-5006 500 Composite canvas geometry mismatch (sprite offset outside canvas bounds) "We couldn't render that combination." no render.failed (error)
SF-5007 500 OG image layout generation failed "We couldn't generate a preview image for this design." yes og.generated (error variant)
SF-5008 500 Corrupt cache entry detected on read (render or OG object storage entry fails integrity check) "We couldn't load that image. Please try again." yes render.failed (error)
SF-6000 422 Unreadable source file during extraction "This file couldn't be read; it may be corrupted." no import.candidate_flagged (warn)
SF-6001 422 Unknown container format "This file's format isn't recognized." no import.candidate_flagged (warn)
SF-6002 422 Checksum mismatch on a previously recorded source file "This file has changed unexpectedly since the last import." no import.candidate_flagged (warn)
SF-6003 — (CLI/admin only; not an HTTP response — SF-6xxx codes are import-pipeline outcomes recorded on import_runs, never returned to a public HTTP client) Partial extraction (run stopped before completion, resumable) "This import is incomplete and can be resumed." yes import.run_completed (warn, status: cancelled)
SF-6004 409 Publish conflict (two staff publishing concurrently) "Someone else just published a build. Please refresh and try again." yes build.published (warn, conflict variant)
SF-6005 422 Decode timeout on a single file during extraction "This file took too long to process and was skipped." no import.candidate_flagged (warn)
SF-6006 422 File exceeds the per-file size cap "This file is too large to process." no import.candidate_flagged (warn)
SF-6007 400 Import work directory path escapes the configured root "That file path isn't allowed." no http.request (error)
SF-6008 409 Asset key collision across two different source sprites in the same build "Two items in this import share the same identifier and need manual review." no import.candidate_flagged (warn)
SF-6009 422 Build published with zero approved assets "This build has no approved assets and cannot be published." no http.request (warn)
SF-6010 422 Candidate assigned to a slot incompatible with its detected gender restriction "This item can't be assigned to that slot." no http.request (warn)
SF-6012 409 No published build exists yet (pre-launch, or every build has been retired) "The catalog isn't available yet. Please check back soon." yes http.request (warn)
SF-9000 500 Unhandled exception "Something went wrong on our end. Please try again." yes http.request (error)
SF-9001 500 Database statement timeout or connection failure "We're having trouble reaching our database. Please try again shortly." yes http.request (error)
SF-9002 500 CDN purge failed after all retries (not user-facing; surfaces only in /admin/dashboard) n/a admin.publish.purge_failed (error)
SF-9003 503 Maintenance mode active "SkinForge is temporarily down for maintenance. Please check back soon." yes http.request (info)
SF-9004 500 Job runner exhausted retries for a critical job (surfaces to admin dashboard, not public) n/a job.exhausted (error)
SF-9005 502 Object storage unreachable (non-render path, e.g. admin asset browsing) "We couldn't reach storage right now. Please try again." yes http.request (error)
SF-9006 500 Configuration inconsistency detected at runtime (should be caught at boot; defensive fallback) "Something went wrong on our end. Please try again." yes http.request (error)
SF-9007 500 Short-code salt space exhausted — every salt increment for one canonical design collided (Section 13.4.2). A detected, handled condition, which is why it is not SF-9000 ("unhandled exception") "Something went wrong on our end. Please try again." no design.salt_exhausted (error)

The table above assigns 70 codes and reserves 5 more (SF-3004, SF-3005, SF-4006, SF-4008, SF-4010), which are listed so their numbers are never reused and are raised by nothing. No section is required to reach a code count; the registry is exactly as large as the set of conditions the rest of this document actually distinguishes.

25.3 Error presentation across surfaces #

HTML pages: a validation or not-found error on a page-rendering route (e.g. /d/:code with an unknown code) renders a full styled HTML error page (using the site's normal layout shell, Section 10) with the user-facing message from the registry, the HTTP status set correctly, and no JSON in the body. A 500-family error on an HTML route renders a generic "something went wrong" page — never a stack trace, never the internal errorMessage, regardless of environment (development mode uses a separate local-only debug overlay that never ships in the production build).

JSON API: every error returns the envelope from Section 16.4: {"error": {"code": "SF-XXXX", "message": "<user-facing message>", "field": "...", "details": [], "requestId": "..."}}, with Content-Type: application/json and the HTTP status from the registry. field is populated only for validation-family (1000-1999) errors where the error is attributable to one input; omitted (not null, absent from the object) otherwise.

Image endpoints: /render/... and /og/... cannot return JSON — a client requesting an image expects image bytes or a redirect, and many consumers (an <img> tag, a social-media unfurl bot) do not parse JSON error bodies at all. Instead:

Situation Response
Missing sprite / invalid combination (SF-5000, SF-5006) 500, Content-Type: image/png, body is a pre-generated static "image unavailable" placeholder PNG at the requested scale, Cache-Control: no-store (never cached — both are server-side data faults, logged, and a future retry after the underlying data is fixed should not be shadowed by a cached failure)
Render still generating (the single claim/poll path owned by Section 8.10; Section 19.8 owns the concurrency and timeout numbers) 200, Content-Type: image/png, body is a lightweight "generating…" placeholder at the requested scale, Cache-Control: no-store, Retry-After: 1, X-SF-Render-Pending: 1 — this keeps a plain <img>/<picture> tag (Section 13.8, 19.6) working, since an <img> receiving a bodyless 202 renders the browser's broken-image icon and never retries. A JSON/API client that prefers to poll explicitly opts in by sending Accept: application/json, in which case a 202 Accepted, Retry-After: 1, empty-body response is returned instead
Render queue full (SF-5004) 503, Content-Type: image/png, body is the same static placeholder, Retry-After: 2, Cache-Control: no-store
Rate limited (SF-3001) 429, Content-Type: image/png, static placeholder, Retry-After per the limit window
Malformed request (bad scale/format/code) 400, Content-Type: image/png, static placeholder
Takedown (SF-2005) 410, Content-Type: image/png, a distinct static "removed" placeholder PNG (visually distinguishable from the generic "unavailable" placeholder so a crawler screenshot or manual check can tell the two apart), Cache-Control: public, max-age=86400 (this one is intentionally cacheable, since a takedown is permanent and re-requesting it repeatedly serves no purpose)
Internal error (SF-9xxx) 500, Content-Type: image/png, generic placeholder, Cache-Control: no-store

Every image-endpoint error additionally sets a custom response header X-SF-Error-Code: SF-XXXX so that automated monitoring and browser devtools debugging can identify the error code without parsing image bytes, even though the body itself is never JSON.

25.4 Edge case register #

# Situation Expected behavior Owning section
1 A design whose every slot references a since-retired asset Renders normally using the archived build's art (designs.build_id pins the build); retirement never affects existing designs 13, 9
2 A hue that lost its colour table (corrupted/missing hues row data) The composite succeeds: the affected layer is drawn unhued (as-drawn pixels, exactly as hue 0 would render it) and the rest of the design renders normally, so one bad hue never blanks a whole design. SF-5005 is logged, never returned — the request is a 200 carrying a correct image with one wrong-coloured layer. The swatch is hidden from /hues and the picker until repaired 25.2, 8
3 A build published with zero assets Rejected at publish time with SF-6009; publish workflow blocks the action in the admin UI before the request is even sent 25.2, 17, 9
4 Two staff publishing at once The second publish attempt's transaction detects the conflict (optimistic check on the build's current status) and returns SF-6004; the admin UI prompts a refresh 25.2, 17
5 A permalink shared before its render finished The HTML page renders immediately (design data exists on create); the image uses the async polling path (25.3) until the render completes, typically sub-second, so the visitor sees a brief loading state, never a broken image 19.8, 12
6 A crawler hitting an OG image for a design created one second ago Synchronous render-on-first-request path (19.8) generates it within the render budget (19.1); if it somehow exceeds the crawler's timeout, the crawler retries and the second request hits the now-warm cache 14, 19.8
7 Clock skew on cursors Cursors are opaque and HMAC-signed over the sort-key payload, which may include a timestamp — the signature is what makes forgery impossible, not the payload's content (20.2), so clock skew between client and server has no effect on cursor validity 20.2, 16
8 A client file that is a symlink (during extraction) Symlinks are resolved and the resolved target is re-checked against the permitted read scope (20.6); a symlink escaping the scope is rejected, never silently followed outside the sandbox 20.6, 7
9 An asset key that URL-decodes to a path traversal string Rejected by the asset-key validation pattern (20.2) before any filesystem or lookup logic runs; the pattern has no /, \, or % in its allowed character set, so a decoded traversal sequence cannot match it 20.2, 20.1
10 A design URL with 500 query parameters Rejected with SF-1021 before parsing individual parameters, since the 200-parameter cap is checked first as a cheap guard against parameter-bombing 25.2, 20.2
11 A design created with a skin hue but zero optional slots (naked base body) Valid; renders the body layer only, per Section 3's "one required body slot" rule 3, 13
12 Two different designs canonicalizing to the same short-code before salting Handled by the collision/salt-increment rule in Section 13; the second design gets a salted re-hash and a distinct code 13
13 A request for /render/d/:code@4.webp (unsupported scale) Rejected with SF-1012 — only scales 1/2/3 are valid 25.2, 8
14 A catalog page requested for a slot key that does not exist 404 with SF-2004 25.2, 15
15 An admin publishing a build while an import for the next build is still running Allowed — imports and publishes operate on independent game_builds rows; the running import does not lock the publish path 9
16 A design referencing a slot/asset pair where the asset belongs to a different slot than declared Rejected at creation with SF-1019; never silently coerced to the asset's actual slot 25.2, 20.2
17 A visitor's browser has JavaScript disabled entirely /d/:code fully renders and functions (19.9); / shows a functional but non-interactive fallback with a message directing to a JS-enabled browser for full editing, since live compositing requires client-side canvas work 19.9, 11
18 An extraction candidate that cannot be confidently classified into any slot Flagged needs_operator_input and surfaced in /admin/candidates, never silently dropped or guessed 7, 17
19 A hue index request just above the discovered Outlands custom range's actual upper bound 404 with SF-2002 — the numeric format may pass validation (0-65535) but existence is checked against the live hues table for the design's pinned build_id, not just the range 25.2, 3
20 An admin session cookie presented after the admin user was deleted via CLI Session lookup joins to admin_users; a session for a deleted user is treated as invalid, returns SF-4001 20.5, 6
21 A design create request during active maintenance mode Returns SF-9003 (503) immediately, before touching the database 23.9, 25.2
22 A render request for an asset/hue combination that is individually valid but whose composite canvas position would place the sprite fully outside the 260×330 canvas SF-5006; treated as a data integrity issue in the source asset's recorded offset, flagged for operator review, not served as a silently cropped or garbled image 25.2, 8
23 A visitor rapidly toggling many hues in the live designer (client-side) No server impact — the client-side compositor (8.12) handles this entirely in-browser using cached sprite/hue data already fetched; no additional requests fire per toggle beyond the initial asset/hue data fetch 8.12, 19.9
24 A design JSON with hue: 0 explicitly set on a slot that has no hue-able variation Accepted; hue: 0 always means "as-drawn" regardless of whether the asset has meaningful hue variation (13) 13
25 An OG crawler bot identified by user-agent making requests far above the human rate limit Bot requests are excluded from analytics (21.6) but not exempted from rate limiting — if a "bot" exceeds the render rate limit it still receives SF-3001, since OG image requests are typically one-per-crawl and should never approach the limit legitimately 21.6, 20.1
26 A takedown request submitted for a design that was already actioned Idempotent — the second action is a no-op that still returns success, and the takedown_requests table records both requests for audit purposes 20.8, 17
27 An admin attempting to reverse a takedown Supported via the admin console reversal workflow (17); the design's takedown flag is cleared, the permalink resumes serving normally, and the action is audit-logged 20.8, 17
28 A design created against a build that is later fully deleted (hypothetically) Cannot happen — game_builds rows are never hard-deleted, only retired, consistent with the soft-delete convention (Section 3); a design's build_id foreign key is always resolvable 3, 6
29 Simultaneous requests for the same never-before-rendered design/scale/format triggering duplicate render work The render job claim uses FOR UPDATE SKIP LOCKED (19.8); a second concurrent request for the identical cache key either joins the in-flight synchronous render (in-process de-duplication keyed by the render hash) or, if already queued, does not enqueue a duplicate job 19.8, 8
30 A visitor manually editing the query-string transient design state to reference a retired asset The designer UI resolves it at load time against the live catalog; a retired asset is dropped from that slot with a visible "no longer available" indicator, never a silent substitution and never a crash 13, 11
31 An import run interrupted by a process crash mid-way Recorded as partial (SF-6003) with the last completed stage recorded; re-running the import resumes from the next unprocessed file rather than restarting from D1, using the per-file checksum/fingerprint already recorded for completed items 7, 9
32 A hue table with fewer than the expected 32 entries for a group Rejected at import validation time as malformed (SF-6000-family), never imported partially 22.2, 7
33 An asset with images at scale 1 and 3 but missing scale 2 Import validation requires all three scales (1/2/3) to be generated before an asset can be approved (17); an incomplete asset cannot be published 9, 8
34 A public visitor requesting /api/v1/admin/* directly 404, not 401/403 — the admin API is not under the public API namespace at all, it lives at /admin/api/* (Section 17.17), so an unauthenticated probe against the /api/v1/admin/* path gets the same "route not found" response as any nonexistent path, avoiding confirming the real namespace's existence 16, 20.1
35 A robots.txt request during maintenance mode Still served normally (static, cheap, does not hit the database) — maintenance mode does not need to hide crawling instructions 23.9, 10
36 A design's canonical JSON containing a slot hue value the asset does not actually support hueing for (Hueable: yes/no is per-slot per Section 3's table, but an individual asset could theoretically be marked non-hueable) Rejected at creation with SF-1019-family validation against the asset's actual hueable flag, not just the slot's general hueable-ness 25.2, 6
37 A request with an Accept-Encoding that excludes both gzip and br Caddy/the app serve uncompressed; this is a graceful degradation, not an error 23.2
38 An admin TOTP enrollment interrupted before confirmation (browser closed mid-setup) The pending TOTP secret is not activated until a valid code is confirmed; the admin user remains without TOTP (and therefore cannot complete login, per the mandatory-TOTP rule) until enrollment is retried from /admin 17, 20.5
39 A visitor bookmarking a /?... transient-state URL with a very old, now-retired combination of assets Same resolution as edge case 30 — retired assets in the transient state are dropped gracefully at load 13, 11
40 An attempt to create a design with body missing from the JSON Rejected with SF-1001 — body is always required per Section 3 25.2, 13
41 A slot swatch strip request (/render/s/...) for a slot with zero approved assets Returns a valid but empty strip image (not an error) — an empty catalog for one slot is a legitimate transient state during early catalog population 8, 15
42 Two extraction candidates that decode to byte-identical sprite content but different source offsets Both are kept as distinct candidates during review (provenance differs) but the importer's fingerprinting (sha256 of decoded pixels) flags them as likely duplicates for the reviewer's attention, never auto-merged silently 7, 9
43 A request for a render at scale 1 immediately followed by scale 3 for the same never-cached design Each scale is an independent cache key (8.4) and independent render; requesting one does not pre-warm the other, though both share decoded sprite data from the in-process LRU (19.2) so the second render is cheaper than the first 8, 19.2
44 An admin CSV/bulk action on candidates where one row in the batch is invalid The entire batch is validated up front; any invalid row aborts the whole batch with the specific row and reason reported, never a partial apply that leaves catalog state inconsistent 17, 20.2
45 A visitor on a browser that supports WebP but explicitly requests .png via a download link Served PNG exactly as requested — explicit extension always wins over content negotiation (19.6) 19.6, 8
46 A design creation request that is a byte-for-byte duplicate of an existing design's canonical JSON Returns the existing design's short code rather than creating a duplicate row — the INSERT ... ON CONFLICT DO NOTHING RETURNING pattern (19.7) falls through to a SELECT on conflict, giving idempotent creation 19.7, 13
47 An admin audit log query spanning a very large date range Paginated per the standard cursor rules (Section 16); no unbounded query is ever issued, even from the admin console 16, 20.5
48 A CDN edge serving a stale HTML page past its stale-while-revalidate window due to an extended origin outage The CDN's own stale-if-error behavior (provider-dependent, documented as the expected fallback) may extend serving stale content further; this is accepted as better than a hard error during an origin outage and is not a defect 19.3, 19.5
49 A request to /hues/:hueIndex for hue 0 (unhued) Valid; renders a dedicated "unhued / as-drawn" detail page rather than a 404, since 0 is a legitimate, documented hue value 3, 15
50 An operator running the extractor against a directory containing zero recognizable UO files D2 (Identify) classifies everything as unknown; the run completes with zero candidates and a clear "no recognizable client files found" summary, never a crash or a misleading partial success 7
51 A design short code that collides with a reserved word used elsewhere in routing (e.g. resembles catalog) Structurally impossible — permalinks live under the /d/ prefix (13), which is disjoint from every other top-level route (10), so no collision can occur regardless of the code's characters 13, 10
52 A visitor navigating to /catalog/:slotKey/:assetKey for an asset that exists but belongs to a different slot than the one in the URL 404 with SF-2001 — the lookup is scoped to the (slotKey, assetKey) pair, not the asset key alone, so a mismatched slot is treated as not found rather than redirecting 25.2, 15
53 A backup restore drill run against a database with in-flight (uncommitted) transactions at dump time pg_dump in custom format with the default consistent-snapshot behavior excludes uncommitted data automatically; the restored database reflects only committed state as of the dump's snapshot start 23.6
54 An admin user's TOTP device is lost Recovery codes (admin_recovery_codes, single-use) provide login access; if those are also exhausted or lost, only CLI-based credential reset (23.7's compromised-account runbook, reused for this non-adversarial case) restores access — there is no self-service TOTP bypass 20.5, 23.7
55 A public API client sends Content-Type: application/xml on a JSON endpoint Rejected with SF-1015 (415) before body parsing is attempted — Content-Type is checked as part of request validation 25.2, 16
56 A render request during a period where SKINFORGE_JOB_RUNNER_ENABLED=false and the request requires the async queue path (scale 3, high concurrency) The synchronous first-attempt path (19.8) still works since it does not depend on the job runner; only already-enqueued jobs stay in queued status until the flag is re-enabled — a degraded but non-broken state, surfaced via skinforge_job_queue_depth growing 24.7, 19.8
57 A design with a slot referencing an asset key that is syntactically valid but was never actually imported 404 with SF-2001 at creation time — asset existence is checked against the live catalog, not just key-format validity 25.2, 6
58 An attempt to publish the same build twice in a row with no changes Allowed as a no-op-equivalent republish (re-runs the invalidation steps in 19.5 harmlessly); not blocked, since an operator might legitimately want to force a cache purge this way 9, 19.5
59 A crawler requesting /sitemap.xml during a period of very high catalog churn (mid-import, pre-publish) The sitemap reflects only the currently published build's catalog, never in-progress import data, so it is always internally consistent even if generated mid-import 10, 9
60 A visitor's request carrying an X-Forwarded-For header with more hops than SKINFORGE_TRUSTED_PROXY_HOPS Only the configured number of trusted hops is consulted from the right; extra, further-left entries (potentially client-forged) are ignored for both rate-limiting and logging purposes 24.2, 20.1
61 A design permalink page requested with an If-None-Match conditional header matching the current ETag Returns 304 Not Modified with no body, consistent with standard HTTP caching semantics layered on top of the Cache-Control values in Section 19.3 19.3, 16
62 An admin bulk-retiring every asset in a slot, leaving that slot with zero active assets Allowed (the slot itself is never deleted, only its assets), and edge case 41's empty-swatch-strip behavior applies to public rendering of that slot going forward 17, 41 (this table)

The register above is exhaustive for v1: every situation the rest of this document raises as "what happens if…" resolves to one of these rows or to a code in 25.2.

25.5 Retry and idempotency rules #

Operation Client retry guidance Server-side idempotency mechanism
Design creation (POST /api/v1/designs) Safe to retry on network failure/timeout INSERT ... ON CONFLICT (short_code) DO NOTHING RETURNING, falling back to a SELECT for an identical canonical JSON (edge case 46); a retried identical submission always resolves to the same short code, never a duplicate
Render request (any GET /render/...) Safe to retry always Pure function of content-addressed inputs (19.4); retrying is indistinguishable from a fresh request and always converges on the same bytes
Admin publish (POST /admin/builds/:id/publish) Retry only after confirming the prior attempt's outcome via the build's status Guarded by the optimistic conflict check (edge case 4); a blind retry after a conflict response is safe (it will either succeed or report the same conflict, never corrupt state)
Extraction/import run Safe to re-run the identical source directory at any time Per-file fingerprinting (sha256 + offset) makes a re-run a no-op for unchanged files (22.9's idempotency test); only genuinely changed/new files produce new candidates
CDN purge call (19.5) Retried automatically by the server (3 attempts, exponential backoff) Purge is inherently idempotent — purging an already-purged path has no additional effect
Job runner claim/execute Jobs retry automatically per their configured max-attempts (job-type-specific, defined alongside each job's registration) FOR UPDATE SKIP LOCKED claiming (19.7) guarantees exactly one worker executes a given job attempt at a time; a job handler that fails mid-execution is retried from the start of its handler, so every job handler is written to be safely re-executable from scratch (e.g. a render job re-checks whether its target cache key already exists before doing the work again)
Rate-limited request that received SF-3000/3001/3002 Retry after the duration in the Retry-After header Token-bucket refill (Section 4.7's architecture decision, Section 16.7's buckets) is naturally idempotent to retries — no special handling needed beyond honoring Retry-After
Takedown action Retry-safe (edge case 26) Actioning an already-actioned design is a no-op that still reports success

25.6 User-facing copy principles for errors #

  • Plain language. No error codes, stack traces, or technical jargon in any user-facing message (the registry's message column in 25.2 is exactly what a visitor sees; the code is present in the JSON envelope for API consumers and support correspondence, never surfaced as the primary text on an HTML page).
  • State what happened, in one short sentence. "We couldn't find that design" rather than a description of the lookup mechanism that failed.
  • State what to do next, when there is a useful next step. "Check the highlighted field and try again," "Please wait a moment," "The link may be mistyped" — every message in the registry that has a meaningful recovery action states it; messages for internal errors (9xxx family) instead offer a generic "please try again" since no more specific guidance would be honest.
  • Never blame the user. Messages describe the situation neutrally ("That value is too long") rather than accusatorially ("You entered a value that is too long"); this is a deliberate, consistent tone choice applied across every message in 25.2.
  • Never leak internals. No message references table names, internal error classes, stack frames, file paths, or the specific validation library in use. The requestId field exists precisely so a visitor can report a problem referencing something traceable without the message itself needing to expose anything sensitive — support correspondence can look up the requestId against structured logs (Section 21.1) to get the full internal detail out of band.
  • Consistency across surfaces. The same SF-XXXX code always maps to the same underlying message text regardless of whether it is rendered on an HTML page, returned in a JSON envelope, or represented by an image-endpoint placeholder's X-SF-Error-Code header — a support conversation referencing a code means the same thing everywhere.
  • No dead ends. Every error page and every inline validation message keeps a path back to something useful — the homepage, the catalog, or the previous valid state of a form — rather than stranding the visitor on a page with no way forward except the browser's back button. This applies to the styled HTML error pages in Section 25.3 specifically: each one reuses the standard site layout shell (navigation and footer intact) rather than rendering as a bare, unstyled error dump.
  • Translation-ready, even though v1 ships English only. Every user-facing message in the registry lives in core/i18n/en.ts as a keyed string (Section 5.8), never inlined as a literal at the call site, so the message text itself — not the error-handling logic around it — is the only thing a future locale would need to change.

26. Milestones & Execution Plan #

26.1 Execution principles #

  1. Vertical slices. Every milestone produces a running application. At the end of any milestone the app boots, serves at least one real route, and passes its own tests. No milestone leaves the tree in a state where deno task dev fails to start.
  2. Database-first within a slice. Inside a milestone, write the migration and the repository module (core/db/*) before the route or CLI command that uses it. Data shape is decided before the code that consumes it.
  3. Tests written with the code. A task is not complete until its test exists and passes. Tests are not a separate later milestone; each milestone carries its own share of the pyramid defined in Section 22.
  4. No milestone depends on a later one. Dependencies only point backward. If a task feels like it needs something from a later milestone, the ordering is wrong and the plan is corrected, not the code.
  5. Small tasks. Every task in a milestone's task list is scoped to be completed, tested and committed in one sitting (under roughly two agent-hours). Larger units are split.
  6. Exit criteria are executable. Every milestone ends with checks that can literally be run — a shell command with an expected result, or a precisely described observable behaviour (HTTP status, response body shape, rendered pixel value, log line). "Works correctly" is never an exit criterion.
  7. Golden images are law. From M2 onward, any change to hue math, compositing or encoding must keep the golden-image tests in Section 22 green. A milestone that touches pixels is not done until pixel parity is proven, not asserted.

26.2 Milestone list #

26.2.1 M0 — Repository and toolchain skeleton #

  • Goal: a bootable, empty Fresh application with linting, formatting, CI, and the CLI entry point wired up, and nothing else.
  • Dependencies: none.
  • Scope: repository root files (deno.json, .gitignore, README.md), main.ts, dev.ts, web/routes/index.tsx, web/routes/_app.tsx, web/static/, cli/main.ts, core/ (empty module folders per Section 5.1: core/db/, core/schema/, core/i18n/, core/hue/, core/composite/, core/codec/, core/storage/), CI workflow file, docs/adr/ with a 0001-initial-stack.md record of the Section 4 stack decision.
  • Tasks:
    1. Initialize the Deno 2.x project with deno.json declaring the Fresh 2.x, Preact 10.x and Tailwind CSS 4.x dependencies at the version lines in Section 4.
    2. Scaffold the Fresh 2.x app skeleton (main.ts, dev.ts, fresh.config.ts, web/routes/_app.tsx).
    3. Add deno fmt, deno lint, and a deno task check that runs both plus deno check.
    4. Add cli/main.ts as a Deno CLI entry point with a single working skinforge-cli --version command, sharing nothing yet from core/ but importing it to prove the module boundary works.
    5. Add a CI workflow that runs deno task check and deno task test (empty test suite passes trivially) on every push.
    6. Write README.md in the developer's own repository with setup instructions (this file is an output of the build, not part of this specification).
    7. Record the stack choice as docs/adr/0001-initial-stack.md.
  • Exit criteria:
    1. deno task dev starts the server and GET / returns HTTP 200.
    2. deno task check exits 0 on a clean checkout.
    3. deno run -A cli/main.ts --version prints a semantic version string.
    4. CI workflow passes on the initial commit.
  • Effort: 4 agent-hours. Risk: low.

26.2.2 M1 — Database schema and migrations #

  • Goal: every table in Section 6 exists in a fresh PostgreSQL 18 database via ordered migrations, with seed data for reference tables.
  • Dependencies: M0.
  • Scope: db/migrations/0001_init.sql through the final numbered migration covering all tables listed in Section 6 (including schema_migrations, game_builds, slots, hues, hue_groups, assets, asset_variants, designs, jobs, admin_users, audit_log, and the rest of the table list), the migration runner (cli/commands/db-migrate.ts), core/db/client.ts (postgres.js connection pool), and db/seed/dev-fixtures.ts seeding the 19-row slot table from Section 3.
  • Tasks:
    1. Write cli/commands/db-migrate.ts: reads db/migrations/*.sql in filename order, applies any not recorded in schema_migrations, wraps each file in a transaction.
    2. Write migration 0001 creating schema_migrations.
    3. Write one migration per logical group of tables (catalog tables, design tables, admin/auth tables, operational tables), each a plain .sql file, matching column/type/index/constraint definitions in Section 6 exactly, including the UNIQUE (asset_id, body, build_id) constraint on asset_variants and the (hue_index, build_id) uniqueness on hues (Section 6.11, Section 6.13) — every asset and hue variant is scoped to the build it was extracted from, never shared across builds.
    4. Seed the slots table with the 19 rows from Section 3's slot registry.
    5. Seed hue_groups with the fixed curated taxonomy rows (skin, hair, tattoo, clothing, event) from Section 6.14 — this is the 5-row curated grouping table, a different concept from the 375 raw 8-hue blocks read out of hues.mul by the Stage D6 decoder in milestone M4; the decoder's file-format blocks are never written to this table directly.
    6. Add deno task db:migrate and deno task db:seed.
    7. Write an integration test that runs migrations against a disposable database and asserts every table in Section 6's table list exists with the documented primary key, and that inserting two asset_variants rows for the same (asset_id, body) but different build_id values succeeds while a duplicate (asset_id, body, build_id) insert is rejected by the constraint.
  • Exit criteria:
    1. deno task db:migrate against an empty PostgreSQL 18 database exits 0 and leaves schema_migrations with one row per migration file.
    2. SELECT count(*) FROM slots; returns 19.
    3. Running deno task db:migrate a second time is a no-op (idempotent, no errors).
    4. The integration test in task 7 passes in CI against a PostgreSQL 18 service container, including the build-scoped uniqueness assertions on asset_variants and hues.
  • Effort: 10 agent-hours. Risk: low.

26.2.3 M2 — Core domain library: formats, hue math, compositing, encoding #

  • Goal: the pure-TypeScript rendering core (hue application, compositing, encoding) works against a synthetic fixture client and is proven pixel-exact by golden-image tests, before any real game file has been touched.
  • Dependencies: M1 (hue rows need a schema, even if fixture data is inserted directly; the fixture hue table and fixture asset_variants rows carry a fixture build_id like any other row, since both are build-scoped per Section 6.11/6.13).
  • Scope: core/hue/unpack.ts, core/hue/apply.ts, core/composite/canvas.ts, core/composite/render-design.ts, core/codec/png.ts, core/codec/webp.ts, tests/fixtures/synthetic-client/ (hand-built minimal sprite set and hue table standing in for real game data), tests/golden/ (expected PNG outputs).
  • Tasks:
    1. Build a synthetic fixture: three hand-authored RGBA sprites (a body, a hair sprite, a torso sprite) and a small hue table with one full-hue entry and one partial-hue entry, committed as test fixtures.
    2. Implement full-hue and partial-hue pixel transforms per the algorithm owned by Section 8.3 — never the appendix — unit-tested against the hand-computed values in Appendix B (Section 28.2), which is a lookup card that reproduces Section 8.3's numbers exactly and is not an independent source.
    3. Implement the compositor: alpha-over blit of N layers onto a 260×330 Uint8ClampedArray canvas in the z-order from Section 3, with nearest-neighbour integer upscaling for scales 2 and 3.
    4. Implement WebP and PNG encoding via @jsquash/webp and @jsquash/png at the version lines in Section 4.
    5. Write a golden-image test: render the synthetic fixture design at scales 1–3 in both formats, compare byte-for-byte (lossless WebP, PNG) against committed golden files.
    6. Benchmark a single composite-and-encode call; record the number for the performance budget in Section 19.
  • Exit criteria:
    1. deno test core/hue core/composite tests/golden passes, including the golden-image comparisons.
    2. Rendering the fixture design twice produces byte-identical output (determinism check).
    3. Nearest-neighbour upscaling is verified by asserting that the scale-n bitmap equals the scale-1 bitmap with every pixel replicated into an exact n×n block, checked pixel-for-pixel — not merely that no unexpected colour values appear.
    4. The benchmark in task 6 completes and its result is recorded in docs/adr/ for later comparison against the Section 19 budget.
  • Effort: 16 agent-hours. Risk: medium — hue math correctness is foundational; errors here propagate to every later milestone.

26.2.4 M3 — Extraction CLI, stages D1–D3 #

  • Goal: skinforge-cli import discover walks a real or synthetic client directory and produces an inventory and a best-effort classification of every file, without extracting any art yet.
  • Dependencies: M0 (CLI skeleton), M1 (source_files and extraction_candidates tables).
  • Scope: cli/commands/import-discover.ts, core/import/discover.ts, core/import/identify.ts, core/import/probe.ts, tests/fixtures/synthetic-client-dir/.
  • Tasks:
    1. Implement Stage D1 (inventory): recursive directory walk, per-file size, extension, first-256-byte magic read, Shannon entropy calculation; write one source_files row per file.
    2. Implement Stage D2 (identify): MUL/IDX pair detection (matching *.mul to *idx.mul/*.idx by name convention and validating the 12-byte index record stride), UOP MYP\0 magic detection, hues.mul detection by fixed record size, .def/.txt detection by extension plus content sniffing.
    3. Implement Stage D3 (probe unknown containers): compression sniffing (zlib/deflate magic bytes, LZ4 frame magic), repeated-record-stride detection via byte-histogram autocorrelation, and a --note CLI flag letting the operator attach a free-text format note to any file, stored on the source_files row.
    4. Build a synthetic client directory fixture containing one valid MUL/IDX pair, one valid UOP stub, one hues.mul stub, and one genuinely unknown binary file, for use in CLI integration tests.
    5. Wire skinforge-cli import discover --path <dir> --build <label> to create a game_builds row and populate source_files and extraction_candidates (the latter with status = 'pending' for every plausible sprite container).
    6. Ensure the unknown file is classified unknown and given an extraction_candidates row with status = 'needs_operator_input', and the run exits 0 (never a crash) per the degrade-gracefully rule in Section 7.
  • Exit criteria:
    1. skinforge-cli import discover --path tests/fixtures/synthetic-client-dir --build fixture-1 exits 0.
    2. source_files contains one row per fixture file with correct extension and format_guess values (mul, idx, uop, unknown as appropriate).
    3. The MUL/IDX pair fixture is classified with format_guess = 'mul_idx_pair'.
    4. The unknown fixture file is present in extraction_candidates with status = 'needs_operator_input' and does not abort the run.
    5. Re-running discovery on the same directory and build label is idempotent (no duplicate rows).
  • Effort: 14 agent-hours. Risk: high — this is the milestone most exposed to the unknown real file layout; see the de-risking spike in 26.4.

26.2.5 M4 — Extraction stages D4–D6, normalization and storage #

  • Goal: candidates identified in M3 are decoded into raw RGBA sprites, classified to slots/bodies, normalized to WebP/PNG at scales 1–3, and stored with full provenance.
  • Dependencies: M2 (compositor/encoder reused for normalization), M3 (candidates exist).
  • Scope: core/import/extract.ts (gump decoder), core/import/hues.ts (hues.mul decoder and curated hue-group assignment), core/import/classify.ts, core/import/normalize.ts, core/storage/fs-driver.ts, core/storage/s3-driver.ts, core/storage/types.ts, cli/commands/import-extract.ts.
  • Tasks:
    1. Implement the gump run-length decoder (RLE rows of ARGB1555 with a lookup table) into raw RGBA, per the byte layout in Appendix A (Section 28) — Stage D4.
    2. Implement Stage D5 classification: map decoded gump ids to slot_key and body using gump-id range tables and tiledata/body.def flags, writing low-confidence matches to a staff review queue (extraction_candidates.status = 'pending') instead of guessing. This stage maps sprites to slots and bodies only; it never writes hues or hue_group_members — both are Stage D6 responsibilities (task 3), since no hue row exists until Stage D6 decodes one.
    3. Implement Stage D6's hue decode: the hues.mul decoder into hues rows (the file format's 375 blocks of 8 fixed-size entries each, per Section 7.3.3 and Appendix A's Section 28.1.3), tagging source = 'base' for everything decoded from this stage and stamping every row with the current build_id (Section 6.13); then, in the same stage, assign each newly decoded hue to its curated hue_groups row (Section 6.14) by writing hue_group_members. hue_groups itself is the 5-row curated taxonomy seeded once in milestone M1 (Section 6.14) — a different concept from the file's raw 8-entry blocks — and this stage only links into it; it never inserts new hue_groups rows.
    4. Implement Stage D6's sprite normalization: trim transparent borders, generate WebP/PNG at scales 1–3 reusing core/codec/png.ts and core/codec/webp.ts from M2, compute sha256 and a perceptual hash (difference hash, 8×8), and write assets, asset_variants, asset_images rows — stamping asset_variants.build_id with the current build (Section 6.11) — with the source offset recorded for provenance.
    5. Implement the storage driver interface (core/storage/types.ts) and both the fs and s3 implementations, selected by SKINFORGE_STORAGE_DRIVER (Section 24).
    6. Wire skinforge-cli import extract --build <label> to run D4–D6 over all pending candidates for a build and report a summary (counts by outcome).
  • Exit criteria:
    1. Running skinforge-cli import extract --build fixture-1 against the M3 synthetic fixture decodes the stub sprite(s) into at least one assets row with non-null asset_images at scales 1–3, and at least one hues row with a matching hue_group_members row, both written by Stage D6 (task 3).
    2. The perceptual hash of the same fixture computed twice is identical (determinism check).
    3. Switching SKINFORGE_STORAGE_DRIVER from fs to s3 against a local MinIO instance and re-running extraction produces the same row counts (driver parity check).
    4. A deliberately ambiguous fixture sprite (added to the fixture set) lands in extraction_candidates with status = 'pending', not in assets.
  • Effort: 20 agent-hours. Risk: high — decoder correctness against the real client format is unverified until real files are available; see 26.4.

26.2.6 M5 — Ingestion, builds, diffing and publish #

  • Goal: staff can turn an extraction run into a published, versioned asset set, and re-run the whole pipeline against a patched client to diff and re-publish.
  • Dependencies: M4.
  • Scope: core/ingest/diff.ts, core/ingest/publish.ts, cli/commands/import-diff.ts, cli/commands/import-publish.ts, additions to core/db/ repositories for import_runs, import_run_items.
  • Tasks:
    1. Implement build diffing: compare the new build's extracted assets (by asset_key and content hash) against the currently published build, classifying each as added, unchanged, changed, or retired-candidate.
    2. Implement import_runs / import_run_items bookkeeping so every ingestion is auditable.
    3. Implement publish: set game_builds.status = 'published' and game_builds.published_at = now() for the reviewed build (demoting the previously published build's status to archived), and set retired_at on any previously published asset not present in the new build (soft delete, per Section 6's convention — never hard-deleted; assets itself carries no published_at column — an asset's live/retired state is retired_at IS NULL combined with belonging to a build whose game_builds.status = 'published').
    4. Implement rollback: republish a previous build's asset set without re-running extraction, by setting that earlier build's game_builds.status back to published and replaying its import_run_items.
    5. Wire skinforge-cli import diff --build <label> (loads extraction output into the diff/review pipeline) and skinforge-cli import publish --build <label> (staff-confirmed promotion).
    6. Write an integration test simulating two sequential builds (fixture v1 and a modified fixture v2) proving the diff correctly reports one changed and one unchanged asset.
  • Exit criteria:
    1. skinforge-cli import diff --build fixture-1 then skinforge-cli import publish --build fixture-1 results in game_builds.status = 'published' and game_builds.published_at set for the fixture build.
    2. Re-importing a second fixture build with one modified sprite reports exactly one changed item in the diff summary.
    3. Publishing the second build sets retired_at on any asset absent from it and leaves the design immutability guarantee intact (existing designs rows still resolve their referenced assets via their pinned build_id, Section 6.18, Section 13.7).
    4. skinforge-cli import publish --build fixture-0 --rollback (an earlier label) restores the original asset set without re-running D1–D6.
  • Effort: 14 agent-hours. Risk: medium.

26.2.7 M6 — Admin console: auth and candidate review queue #

  • Goal: staff can log in with TOTP and review/approve extraction candidates through a web UI — the first admin screen and the authentication backbone for all later admin screens.
  • Dependencies: M1 (admin_users, admin_sessions, admin_recovery_codes tables), M4 (candidates to review).
  • Scope: web/routes/admin/index.tsx (login), web/routes/admin/dashboard.tsx, web/routes/admin/candidates.tsx, web/islands/admin/candidate-review.tsx, core/auth/argon2.ts, core/auth/totp.ts, core/auth/session.ts, cli/commands/admin-user.ts.
  • Tasks:
    1. Implement Argon2id password hashing and verification (core/auth/argon2.ts).
    2. Implement TOTP (RFC 6238) enrollment and verification (core/auth/totp.ts), plus one-time recovery codes stored hashed in admin_recovery_codes.
    3. Implement server-side sessions in Postgres (admin_sessions) with the sf_admin cookie (HttpOnly; Secure; SameSite=Lax), TTL from SKINFORGE_ADMIN_SESSION_TTL_HOURS.
    4. Implement skinforge-cli admin create-user --email <email> --role <owner|curator|viewer> (interactive password + TOTP secret provisioning, Section 17.1) — the only way to create an admin user; there is no in-console user self-registration or password-reset-by-email flow.
    5. Build the login page and a session-required middleware for all /admin/* routes except login.
    6. Build the candidate review screen: list extraction_candidates with status = 'pending', preview the decoded sprite, allow approve (creates/updates assets) or reject (status = 'rejected') with a required reason, all recorded in audit_log.
  • Exit criteria:
    1. skinforge-cli admin create-user --email staff@example.test --role owner followed by logging in at /admin with the generated password and a valid TOTP code succeeds and sets sf_admin.
    2. An expired or tampered sf_admin cookie is rejected and redirects to /admin.
    3. Approving a candidate on /admin/candidates creates an assets row and an audit_log row referencing the admin user.
    4. Five consecutive failed TOTP attempts trigger the lockout behaviour defined in Section 20.
  • Effort: 16 agent-hours. Risk: medium.

26.2.8 M7 — Remaining admin screens #

  • Goal: every admin route in Section 10.1's site map and Section 17's screen specifications is implemented and functional.
  • Dependencies: M6 (auth), M5 (builds/imports), M4 (assets/hues).
  • Scope: web/routes/admin/imports.tsx, web/routes/admin/imports/[id].tsx, web/routes/admin/assets.tsx, web/routes/admin/assets/[assetKey].tsx, web/routes/admin/hues.tsx, web/routes/admin/slots.tsx, web/routes/admin/tags.tsx, web/routes/admin/builds.tsx, web/routes/admin/designs.tsx, web/routes/admin/takedowns.tsx, web/routes/admin/audit.tsx, web/routes/admin/settings.tsx, web/routes/admin/users.tsx, corresponding islands under web/islands/admin/.
  • Tasks:
    1. Build the imports list and detail screens (run status, diff summary, publish/rollback actions).
    2. Build the assets list and detail screens (metadata edit, tag assignment, retire/restore action).
    3. Build the hues browser (filter by group, preview swatch).
    4. Build the slots screen per Section 17.9: owner-editable display name, zOrder, gender scope and visible toggle, with the z-order blast-radius warning, the live affected-design count, and the type-to-confirm slot-key field required before a change to zOrder or gender scope commits. The 19-row registry itself (which slots exist) stays fixed per Section 3.3/Appendix C; only the per-row fields Section 17.9 names are editable.
    5. Build the tags management screen (create/rename/retire tags).
    6. Build the builds screen (list builds, current published build, diff history).
    7. Build the designs screen (search by short code, view canonical JSON, no edit — designs are immutable per Section 13).
    8. Build the takedown request screen implementing the workflow in Section 20.
    9. Build the audit log viewer (filter by actor, action, date range).
    10. Build the settings screen per Section 17.14: the read-only environment panel with secret masking for every SKINFORGE_* variable, the five editable app_settings rows, and the audited maintenance-mode toggle; and the users screen (list admin users, deactivate — creation remains CLI-only).
  • Exit criteria:
    1. Every route listed in this sub-section returns HTTP 200 for an authenticated admin session and HTTP 303 (redirect to login) for an unauthenticated request.
    2. Retiring an asset from /admin/assets/:assetKey sets retired_at and the asset stops appearing in public catalog listings while still resolving in existing designs' renders.
    3. Creating a takedown request and resolving it follows the state machine in Section 20 with no illegal transitions permitted by the UI.
    4. Editing a slot's zOrder on /admin/slots shows the live count of published designs the change would affect and refuses to commit until the operator types the slot key to confirm; the edit is recorded in audit_log and is restricted to the owner role per Section 17.3.1's permission matrix.
    5. The settings screen never renders a raw secret value (verified by a test asserting masked output for any env var name containing SECRET, PASSWORD, TOKEN, or KEY), and toggling maintenance mode from the screen writes an app_settings row and an audit_log row, taking effect for public requests without a redeploy.
  • Effort: 22 agent-hours. Risk: low.

26.2.9 M8 — Public API and catalog pages #

  • Goal: the public JSON API (Section 16) and the catalog browsing pages (Section 15) are live against published assets.
  • Dependencies: M5 (published assets exist), M2 (rendering for catalog thumbnails).
  • Scope: web/routes/api/v1/**, web/routes/catalog/index.tsx, web/routes/catalog/[slotKey]/index.tsx, web/routes/catalog/[slotKey]/[assetKey].tsx, web/routes/hues/index.tsx, web/routes/hues/[hueIndex].tsx, core/api/envelope.ts, core/api/pagination.ts, core/api/rate-limit.ts, core/search/ (query parsing and ranking, Section 15.6).
  • Tasks:
    1. Implement the success/error envelope helpers and error code table per Section 16 and Section 25.
    2. Implement cursor pagination helper per Section 16 (opaque cursor, limit 1..100 default 24).
    3. Implement the Postgres-backed token bucket rate limiter per Section 16.7's bucket definitions, wired to SKINFORGE_RATE_LIMIT_API_PER_MINUTE, SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE and SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR (Section 24); confirm the boot-time config validator (Section 24.3) rejects a malformed value for any of the three before this task is considered done, since a silently-defaulted limit is worse than a boot failure.
    4. Implement every /api/v1/* endpoint enumerated in Section 16 (slots, hues, hue-groups, assets, tags, search, designs read, design create, builds, stats).
    5. Implement the search_tsv column per Section 15.6 as a trigger-maintained plain tsvector column (populated by an AFTER INSERT OR UPDATE trigger on assets and on asset_tags, never a GENERATED ALWAYS AS expression, since PostgreSQL rejects a generated column whose expression contains a subquery), plus the pg_trgm-backed typo-tolerance fallback from Section 15.6. Wire GET /search (Section 16.8.9) and the catalog's q search parameter (Section 15.3) to it.
    6. Build the catalog index, per-slot, and per-asset pages with server-side rendering and facet filters per Section 15.
    7. Build the hues browser pages.
    8. Add an integration test suite hitting every documented endpoint with a valid and an invalid request, asserting envelope shape and status code, including a search_tsv test that inserts an assets row and confirms the trigger populates the column without any query-time computation.
  • Exit criteria:
    1. curl -s localhost:8000/api/v1/slots | jq '.data | length' returns 19.
    2. A request exceeding the configured rate limit returns HTTP 429 with error code SF-3000.
    3. /catalog/hair renders at least one published hair asset thumbnail with correct srcset scales.
    4. Every endpoint in Section 16 has at least one passing integration test for a success case and one for a documented error case.
    5. GET /api/v1/search?q=<term> returns ranked results sourced from the trigger-maintained search_tsv column; the migration that creates it (Section 6, task 5 above) applies cleanly to a fresh database with deno task db:migrate, proving the column is not a rejected generated expression.
  • Effort: 18 agent-hours. Risk: low.

26.2.9a M8.5 — Observability, Operational Reporting & API Contract Hardening #

  • Goal: every operational surface this document promises elsewhere but assigns to no milestone — structured logging, metrics, the health and version endpoints, the daily rollup job, the weekly operator report, the generated OpenAPI document, and the API's backward-compatibility contract fixtures — actually exists and is tested, closing the gap between what Sections 16, 21 and 24 describe and what M0–M8 build. Named M8.5 rather than renumbered into the main sequence so the M9–M12 labels below stay stable.
  • Dependencies: M8 (the public API and catalog surface must exist before it can be instrumented, health-checked and contract-tested), M7 (the admin dashboard exists to host the weekly report panel per Section 17.4).
  • Scope: core/obs/logger.ts, core/obs/metrics.ts, web/routes/api/v1/health.ts, web/routes/api/v1/version.ts, core/ops/daily-rollup.ts, cli/commands/ops-rollup.ts, core/ops/weekly-report.ts, scripts/generate-openapi.ts, docs/api/openapi.json (generated, not hand-written), tests/contract/api-v1/ (fixtures asserting the generated OpenAPI document matches the live envelope, pagination and error shapes).
  • Tasks:
    1. Implement structured logging per Section 21.1 as shared middleware wired into every route: one JSON line per request in the documented field set, with the pitfall in Section 27.7 enforced by test — no full canonical design JSON or full request body logged at info level.
    2. Implement metrics collection per Section 21.3 (the named counters and histograms, including request duration and the render pipeline metrics), gated behind SKINFORGE_METRICS_ENABLED / SKINFORGE_METRICS_TOKEN per Section 24.
    3. Implement GET /api/v1/health (Section 16.8.17) and GET /api/v1/version (Section 16.8.18), backed by the deeper dependency checks in Section 21.7 (database reachability, storage driver reachability, job queue depth).
    4. Implement the daily rollup job and the weekly operator report per Section 21.9, scheduled through the Postgres-backed job runner (Section 4's architecture), writing into the panel and storage location Sections 17.4 and 21.9 assign them.
    5. Implement the OpenAPI generator (scripts/generate-openapi.ts) producing the machine-readable contract per Section 16.10 from the same Zod schemas the route handlers already validate against — never a hand-maintained duplicate document — and add a deno task openapi:generate that fails CI when the checked-in document is stale relative to the schemas.
    6. Write the API contract fixture suite per Section 16.12: golden request/response pairs for every endpoint in Section 16.8, asserted against both the live server and the generated OpenAPI document, so an accidental breaking change to envelope shape, pagination shape, or a field name is caught by CI rather than by a community bug report.
  • Exit criteria:
    1. Every request to any route emits exactly one structured log line matching Section 21.1's schema; the logging test suite asserts no full canonical design JSON and no full request body appears at info level.
    2. curl -s localhost:8000/api/v1/health returns the success envelope with each dependency check reported individually, and returns a non-2xx status when the database is unreachable (verified in a test that points SKINFORGE_DATABASE_URL at a closed port).
    3. curl -s localhost:8000/api/v1/version returns the running build's version string.
    4. Running the daily rollup job against fixture data populates the aggregate counters it owns, and the resulting weekly report is viewable from the location Section 17.4 assigns it.
    5. deno task openapi:generate produces a document byte-identical to the one checked into docs/api/openapi.json on a clean checkout (no drift), and CI fails the build if they differ.
    6. The contract fixture suite in task 6 passes against the live server for every endpoint in Section 16.8, and deliberately renaming a response field on a throwaway branch makes the corresponding fixture fail — a canary test proving the suite actually catches drift, not merely that it runs.
  • Effort: 12 agent-hours. Risk: low.

26.2.10 M9 — Designer UI and live preview #

  • Goal: the interactive design tool at / lets a visitor build a full look with instant client-side preview (/design is a redirect to /, Section 10.1, and carries no UI of its own).
  • Dependencies: M8 (catalog API feeds the picker), M2 (client-side compositing reuses the same algorithm).
  • Scope: web/routes/index.tsx, web/routes/design.tsx (redirect handler only, Section 10.2), web/islands/designer/designer-root.tsx (the only mounted island, Section 11.2), web/components/designer/slot-rail.tsx, web/components/designer/preview-stage.tsx, web/components/designer/option-panel.tsx, web/components/designer/hue-picker.tsx, core/schema/design-state.ts, core/design/query-form.ts.
  • Tasks:
    1. Implement URL query-string state per Section 13.6's transient form (?b=m&s=1002&hair=...) in core/design/query-form.ts (Section 13.6.3's reference parser), and the permanent-form schema from Section 13.2.5 for the Share action's submission.
    2. Implement the Preact Signals state described in Section 11.3 (designSig, uiSig, and their derived computed() signals) created inside DesignerRoot itself — there is no separate standalone signals module; DesignerRoot is the only Fresh island in the designer tree (Section 11.2), and SlotRail, PreviewStage, OptionPanel and HuePicker are plain server-shaped components hydrated within its single island boundary, not independent islands.
    3. Implement PreviewStage's live composite canvas: fetches per-asset sprites and the hue table as JSON, composites on an HTML <canvas> client-side using the exact algorithm from Section 8.3, reusing shared logic with core/composite/render-design.ts and core/hue/apply.ts where the runtime allows (browser-safe subset), per Section 12.3.
    4. Implement SlotRail (thumbnail-badged rail across all 19 slots, keyboard navigable, per Section 11.5) and OptionPanel's asset grid (Section 11.6).
    5. Implement HuePicker (swatch grid, keyboard navigable, per Section 11.6).
    6. Implement undo/redo over uiSig.value.history, per Section 11.7.
    7. Implement the no-JS fallback exactly as Section 10.7 specifies: when DesignerRoot does not hydrate, the server renders a static, read-only <noscript> view (the same summary strip and composite image used on /d/:code) with a message directing the visitor to enable JavaScript or browse the catalog. There is no form-based slot/hue selector for the live designer — Section 10.7 makes the no-JS designer read-only by design; only the catalog's filters (Section 15.3) and the Share action degrade to plain forms.
    8. Write a Playwright end-to-end test that builds a design by clicking through slots and hues and asserts the canvas pixel data matches a server-rendered reference at the same design state.
  • Exit criteria:
    1. Loading /?b=m&s=1002 renders body m with skin hue 1002 on the canvas within the performance budget in Section 19; loading /design?b=m&s=1002 issues a 301 redirect to / with the query string intact (Section 10.5).
    2. Changing a slot's asset or hue updates the canvas without a full page reload.
    3. The Playwright test in task 8 passes, proving client/server rendering parity for the interactive flow (extending the golden-image guarantee from Section 22).
    4. With JavaScript disabled, loading / renders the read-only <noscript> fallback from Section 10.7 (summary strip, composite image, and the "enable JavaScript" message) instead of an empty page; no interactive slot/hue selection is expected or required without JavaScript.
  • Effort: 24 agent-hours. Risk: medium.
  • Goal: a design can be shared as a permanent, immutable link with a generated social preview image.
  • Dependencies: M9 (a design exists to share), M2 (compositor reused for the embedded preview).
  • Scope: web/routes/d/[code].tsx, web/routes/og/d/[code].png.ts, core/design/short-code.ts, core/design/canonicalize.ts, core/og/layout.tsx, core/og/render.ts.
  • Tasks:
    1. Implement canonical JSON serialization (fixed key order v, body, skinHue, slots; no whitespace; empty slots omitted) per Section 13.3.
    2. Implement the short-code algorithm: SHA-256 of canonical JSON, leading 50 bits, Crockford Base32 (ambiguity-collapsing alphabet), 10-character lowercase code, with the salt-collision scheme from Section 13.
    3. Implement POST /api/v1/designs (Section 16) to persist a design and return its code, called by the designer UI's Share action.
    4. Build /d/:code: server-rendered page showing the composite preview, metadata, and Open Graph tags pointing at the OG image route.
    5. Implement the OG image pipeline: satori layout embedding a data-URL of the server-side composite, rasterized by @resvg/resvg-wasm, cached in object storage per Section 14's cache key scheme.
    6. Implement crawler-friendly handling: OG image generation is synchronous on first request and cached thereafter, per Section 14.
    7. Write a test proving two different canonical JSON payloads that collide on the leading 50 bits receive different codes (salt path exercised with a forced collision fixture).
  • Exit criteria:
    1. Sharing a design in the UI navigates to /d/:code where <code> is a 10-character lowercase string.
    2. Requesting the same design twice returns the same code (determinism).
    3. curl -sI localhost:8000/og/d/:code.png returns Content-Type: image/png and a Cache-Control header matching Section 19.
    4. The forced-collision test in task 7 passes.
    5. Visiting /d/:code for a code whose design references a retired asset still renders correctly using archived art (immutability check per Section 9).
  • Effort: 16 agent-hours. Risk: medium.

26.2.12 M11 — Performance, caching, CDN and hardening #

  • Goal: the application meets the performance budgets in Section 19 and the security controls in Section 20 under realistic load.
  • Dependencies: M8, M9, M10 (everything user-facing must exist to be hardened), M8.5 (the load test in task 5 reads the metrics this milestone adds, and the security review in task 6 checks the logging discipline M8.5 establishes).
  • Scope: cache header middleware, core/security/headers.ts, CDN purge integration (core/ops/cdn-purge.ts), load-test scripts under tests/load/.
  • Tasks:
    1. Apply the immutable-asset and HTML cache headers from Section 19 to every route.
    2. Implement security headers as shared middleware, per Section 20.4: Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval' 'nonce-<per-response>'; img-src 'self' data: blob: <configured public storage origin>; connect-src 'self' <that origin> plus the rest of Section 20's header list (X-Content-Type-Options, Referrer-Policy, etc.); generate a fresh nonce per response and inject it into both the CSP header and the inline theme script's nonce attribute. wasm-unsafe-eval is required — without it the browser compositor (Section 8.12, Section 11, Section 12) cannot run.
    3. Implement CDN purge calls on publish (SKINFORGE_CDN_PURGE_URL / _TOKEN) per Section 19; the purge/caching layer must not force immutable caching on all image routes — the application sets Cache-Control per response and the proxy only fills it in when absent, and 4xx/5xx responses never receive a long TTL, so a taken-down image (Section 20.8) does not linger in the CDN.
    4. Bound render concurrency using SKINFORGE_RENDER_MAX_CONCURRENCY with a queue and a documented rejection behaviour (HTTP 503 with Retry-After) beyond the bound.
    5. Run a load test against the render endpoints (Section 8.8) and the catalog grid, tuning N+1 query paths flagged in Section 27.7.
    6. Run the security review checklist from Section 20 end to end and fix findings, including verifying the boot-time config validator (Section 24.3) rejects every malformed value in .env.example and that no boolean-typed variable (e.g. SKINFORGE_MAINTENANCE_MODE) can be coerced true by an arbitrary non-empty string — a strict z.enum(["true","false"]).transform(v => v === "true") parse, never z.coerce.boolean(), since the latter turns the string "false" into true and would permanently pin maintenance mode on.
  • Exit criteria:
    1. Lighthouse (or equivalent) performance score on / meets the budget defined in Section 19.
    2. Response headers on every page include the full security header set from Section 20, including a CSP whose nonce differs on every response and whose script-src includes wasm-unsafe-eval, verified by loading / and confirming the client-side compositor boots without a CSP violation.
    3. The load test in task 5 sustains the target request rate from Section 19 without 5xx responses beyond the documented concurrency-limit 503s.
    4. The catalog grid issues a bounded, constant number of queries regardless of page size (no N+1).
    5. Starting the app with a deliberately malformed boolean env var (e.g. SKINFORGE_MAINTENANCE_MODE=yes) fails boot-time validation with a readable error rather than silently coercing to true, per task 6.
  • Effort: 18 agent-hours. Risk: medium.

26.2.13 M12 — Launch readiness #

  • Goal: v1 is deployable, backed up, documented for operations, and accessible.
  • Dependencies: all prior milestones.
  • Scope: docker-compose.yml, Caddyfile, backup scripts, runbooks in the developer's own docs/runbooks/, accessibility fixes, seeded launch content.
  • Tasks:
    1. Write the Docker Compose topology from Section 23 (app, Postgres, optional MinIO, Caddy).
    2. Write backup and restore scripts and rehearse a full restore per Section 23.
    3. Write the runbooks required by Section 23 (deploy, rollback, re-import after a patch, incident response).
    4. Run both accessibility gates per Section 22.6: an automated audit (axe or equivalent) against every public page, and the manual keyboard-only script covering SC 2.1.4 (Character Key Shortcuts), SC 2.5.7 (Dragging Movements) and SC 2.4.11 (Focus Not Obscured) — criteria the automated tool does not evaluate. Fix all WCAG 2.2 AA violations found by either gate, per Section 18.
    5. Run the security checklist in Section 20 a final time.
    6. Seed the production database with an initial published build from a real client extraction (or the best available fixture if real files are still pending — see 26.4) and verify the full user journey end to end.
    7. Publish the unofficial fan-project disclaimer on every public page footer per Section 20.
    8. Run the acceptance test script in Section 27.10.
  • Exit criteria:
    1. docker compose up on a clean host brings up a working stack reachable over HTTPS via Caddy.
    2. A rehearsed restore from backup succeeds and the restored instance serves a known permalink correctly.
    3. The launch accessibility gate passes: zero automated axe violations and a passing run of the manual keyboard-only script from Section 22.6, including its SC 2.1.4, SC 2.5.7 and SC 2.4.11 checks — an automated-only pass is not sufficient to close this milestone.
    4. The acceptance test script in Section 27.10 passes in full.
  • Effort: 20 agent-hours. Risk: low, contingent on all prior milestones being closed.

Total estimated effort: 224 agent-hours across M0–M12 plus M8.5 (212 agent-hours across the 13 milestones M0–M12, plus M8.5's 12 agent-hours), excluding the de-risking spikes in 26.4.

26.3 Dependency graph and parallelization #

M0 ─▶ M1 ─┬─▶ M2 ─┬─▶ M9 ─▶ M10 ─┐
          │       │              ├─▶ M11 ─▶ M12
          ├─▶ M3 ─▶ M4 ─▶ M5 ─▶ M6 ─▶ M7 ─┘
          │                     │              ┌─▶ M9
          └─────────────────────┴─▶ M8 ─┬──────┘
                                         └─▶ M8.5 ─▶ M11

Read as: M0 gates everything. M1 gates M2 and M3. M2 (render core) is required by M4 (normalization reuses the encoder), M9 (client preview reuses the algorithm) and M10 (OG embeds a composite). M3→M4 →M5→M6→M7 is the asset/admin spine. M8 needs published assets from M5 and the render core from M2. M9 needs the catalog API from M8. M10 needs a working designer from M9. M8.5 branches from M8 (it instruments and contract-tests the surface M8 just built) and, together with M10, gates M11. M11 and M12 need the whole public surface plus M8.5's observability.

Parallelization plan for two or three agents:

  • Agents A and B fork after M1. Agent A takes the asset spine: M3 → M4 → M5 → M6 → M7. Agent B takes the render core and public surface: M2 → M8 (blocked until Agent A's M5 publishes at least fixture assets, so Agent B can develop M8 against the M2 fixture data and switch to real data when M5 lands).
  • A third agent (Agent C) can start M2's golden-image test infrastructure in parallel with M1, since the synthetic fixture in M2 does not depend on the real database schema beyond having somewhere to insert fixture hue rows — coordinate the schema dependency by having Agent C stub the fixture data as plain TypeScript objects until M1 lands, then swap to DB-backed fixtures.
  • M8.5 runs in parallel with M9 → M10. Once M8 closes, whichever agent is free (Agent B, having handed the designer/permalink chain to Agent A or a fourth agent, or Agent A once M7 closes) can pick up M8.5 without waiting on M9 or M10 — it depends only on M8 and M7 (for the dashboard panel), not on the designer or permalink surfaces. Both M8.5 and M10 must close before M11 starts.
  • M9 and M10 cannot start until both M2 and M8 are done — they are single-threaded from that point.
  • M11 and M12 are single-threaded and must follow M9/M10 and M8.5 because they harden against the whole public and operational surface.
  • Milestones that must NOT overlap: M5 and M6 (M6's review queue reads live extraction_candidates state that M5's publish step mutates — running both at once risks reviewing already-published candidates); M9 and M10 for the same reason applied to designs.

26.4 Critical path and risk register #

Critical path: M0 → M1 → M3 → M4 → M5 → M6 → M7 → M8 → M9 → M10 → M11 → M12. (M2 sits off the critical path in wall-clock terms if run in parallel per 26.3, but M8/M9/M10 cannot close without it, so treat it as effectively on the path for planning purposes. M8.5 branches from M8 and runs alongside M9 → M10; at 12 agent-hours it is shorter than that chain in every realistic staffing scenario, so it is not expected to extend the critical path — but M11 may not start until M8.5 closes, so if M8.5 is delayed it becomes the pacing item for M11.)

Risk 1 — the real client file format resists the readers in Section 7 (highest risk). The exact on-disk layout is unknown until real files are inspected; UO Outlands may deviate from stock UO in ways the format tables do not anticipate (custom UOP block ordering, non-standard hue ranges, patched tiledata). De-risking spike, scheduled in the first week alongside M0: run skinforge-cli discover (built early, even ahead of its formal M3 slot, as a throwaway script) against a real Outlands client directory obtained from the operator, and manually inspect the first 50 classified files. Contingency if automated extraction stalls on a specific container format: keep the staged pipeline's Stage D3 manual format note, add a manual asset upload path in the admin console (staff uploads pre-extracted PNG/BMP sprites with hand-entered slot/body/hue metadata, bypassing D4–D6 for that subset) so the product can launch with a partial catalog while the automated reader for the resistant format is finished post-launch.

Risk 2 — hue math or compositing has a subtle pixel-level bug that only shows up on real art. The synthetic fixture in M2 cannot cover every real sprite quirk (transparency edge cases, palette index reuse). De-risking spike: as soon as the first real sprites are extracted in M4, immediately run them through the golden-image harness from M2 and visually diff against a manually rendered reference image captured from the original game client by the operator. Contingency: expand the fixture corpus with the failing real sprite and fix the algorithm before proceeding past M4.

Risk 3 — render or catalog performance does not meet the Section 19 budget under concurrent load. Pure-TypeScript/WASM rendering with no native image library is a deliberate simplicity trade-off that could underperform. De-risking spike: run the M2 benchmark (task 6) early and extrapolate to the expected concurrency from SKINFORGE_RENDER_MAX_CONCURRENCY; if the extrapolation exceeds the budget, raise it before M11 rather than discovering it during hardening. Contingency: increase the in-process render cache hit rate (pre-render common slot/hue/scale combinations during publish in M5) so most public traffic never hits the live encoder.

26.5 Cutover checklist and definition of v1 complete #

Cutover checklist (run in order, immediately before flipping public DNS):

  1. All milestones M0–M12, plus M8.5, closed with their exit criteria passing in CI.
  2. Production database migrated and seeded from a real, staff-approved, published build (not the synthetic fixture).
  3. Backups configured and one full backup/restore cycle rehearsed successfully within the prior 7 days.
  4. TLS certificate issued and auto-renewal verified through Caddy.
  5. SKINFORGE_ENV=production and every required variable in Section 24 set with production values; no default/dev value remains for any secret-bearing variable.
  6. Rate limits, CDN purge integration, and security headers verified live against the production domain.
  7. Accessibility audit re-run against the production build with zero AA violations.
  8. Fan-project disclaimer visible on every page footer, verified by a crawl of the sitemap.
  9. Donation link (if configured via SKINFORGE_DONATION_URL) verified reachable; absent is also a valid state per Section 2.
  10. The acceptance test script in Section 27.10 executed against the production URL, all checks pass.

Definition of "v1 complete": every route in Section 10.1's site map resolves; a visitor can design a look with no login, share it as a permalink with a working OG image, and browse the catalog; staff can run discovery and extraction against the operator's real client files, review candidates, and publish a build; the fan-project disclaimer is present everywhere; no user account system, elf/gargoyle body, animated preview, or paid tier exists anywhere in the shipped product, matching the scope boundaries in Section 2.

26.6 Post-v1 backlog (explicitly deferred, not part of this build) #

Idea Reason deferred
Elf and gargoyle player bodies Out of scope per Section 2; UO Outlands has no such player bodies to spec against.
Animated or multi-directional (N/S/E/W) previews Out of scope per Section 2; static paperdoll pose only.
User accounts, login, saved galleries Out of scope per Section 2; permalinks fully replace the need.
Full clothing/armour/equipment catalog with stats Out of scope per Section 2; SkinForge renders appearance only.
In-game integration or purchase flow Out of scope per Section 2; SkinForge never touches a player account.
Native mobile apps Out of scope per Section 2; responsive web only.
Advertising or subscription tiers Out of scope per Section 2; free tool with an optional donation link.
Weapon, ring, bracelet, talisman layers Excluded cosmetic layers per Section 3; not part of the visible skin.
Additional locales beyond English Deferred because Section 5's repository layout already isolates strings in core/i18n/en.ts (Section 18.11), making it a pure data change later; not needed for launch.
Redis-backed job queue or message broker Deferred because the Postgres-backed job table in Section 4's architecture handles v1 load; revisit only if throughput data after launch shows contention.
Public API keys, per-consumer quotas and paid tiers Deferred. The v1 API is already open to third parties anonymously (Section 16.1, 16.11); what is deferred is per-consumer identity, which would be needed only to raise limits above the shared per-IP buckets in Section 16.7.
User-submitted custom hues or assets Deferred because the asset pipeline is staff-curated by design in Section 1; community submission needs a moderation model not yet specified.

27. Executor Instructions #

This section is written directly to the AI coding agent that will build Outlands SkinForge from this document. It assumes no prior conversation with the customer and no ability to ask one. Follow it literally.

27.1 How to read this document #

  1. This document has 28 top-level sections, each numbered ## <n>. <Title>. Each section OWNS one concern. Where a concept is used outside its owning section, it is referenced by number, never redefined. If any two sections appear to disagree, the OWNING section for that concern is correct and the other section is treated as informally paraphrasing it — this should not happen in a correctly drafted document, but if it does, resolve in favor of the owner.
  2. Section ownership at a glance:
    • Section 1 owns customization decisions and their defaults.
    • Section 2 owns goals, non-goals, and success metrics.
    • Section 3 owns the domain taxonomy (bodies, slots, layers, hues, glossary of game terms).
    • Section 4 owns every dependency name and version line.
    • Section 5 owns the repository layout and coding conventions.
    • Section 6 owns the database schema.
    • Section 7 owns asset discovery and extraction.
    • Section 8 owns rendering (hue math, compositing, encoding, render URLs, cache keys).
    • Section 9 owns ingestion, versioning, and patch re-sync.
    • Section 10 owns public page routing and IA.
    • Section 11 owns the designer UI's components and interaction model.
    • Section 12 owns preview surfaces (paperdoll and per-slot static previews).
    • Section 13 owns permalinks and the canonical design JSON.
    • Section 14 owns Open Graph image generation.
    • Section 15 owns catalog browse/search/filter.
    • Section 16 owns the public HTTP API, including the envelope and pagination.
    • Section 17 owns the admin console.
    • Section 18 owns the design system and accessibility rules.
    • Section 19 owns performance, caching, and CDN behaviour.
    • Section 20 owns security, privacy, and legal posture.
    • Section 21 owns observability.
    • Section 22 owns the testing strategy and quality gates.
    • Section 23 owns deployment and operations.
    • Section 24 owns the environment variable table.
    • Section 25 owns the error catalog.
    • Section 26 (this document's execution plan) owns milestone sequencing.
    • Section 27 (this section) owns how to execute the document.
    • Section 28 owns reference appendices.
  3. A section number cited in prose (for example, "per Section 8.2") is the source of truth over any restatement of the same fact elsewhere. When implementing, open the cited section and follow it exactly, even if a summary elsewhere in the document seems to say something slightly different.
  4. Read the whole document once, start to finish, before writing any code. Re-read Section 26 (the milestone plan) before starting each milestone; re-read the specific owning sections listed in that milestone's scope before writing the code for it.
  5. Worked example of the cross-referencing rule: while building the catalog grid (milestone M8 in Section 26), the task list says to reuse "the pagination helper per Section 16." The correct action is to open Section 16, find its pagination sub-section, and implement exactly that cursor shape — not to design a new pagination scheme because the catalog "feels different" from a typical API listing. The same discipline applies to every other cross-reference in this document: treat the citation as an instruction to go read and comply, not as decorative context.
  6. When a section describes a table, schema, or contract with a fenced code block (SQL DDL, a TypeScript interface, a JSON example), that code block is normative. Field names, types, and nullability in the code block are exact requirements, not illustrative sketches.
  7. Numbers in this document (line counts, byte offsets, table row counts, timeouts, limits) are exact values to implement, not rough guidance. Where a table in Section 3 says 19 rows, the seeded table must contain exactly 19 rows — not "approximately 19."

27.1.1 Reading order for a first pass #

For an executor starting completely cold, read the 28 sections in this order rather than strictly numerically, because later foundational sections are needed to understand earlier feature sections: Section 2 (what is being built and why), Section 3 (the domain vocabulary used everywhere else), Section 4 (the stack), Section 5 (repository shape), Section 6 (data model), Section 24 (configuration — short, and referenced constantly), Section 25 (error catalog — short, and referenced constantly), then Sections 7 through 23 in numeric order, then this section, then Section 28 as a standing reference to return to while implementing. This reading order is a study aid only; the authoritative execution order for writing code is the milestone order in Section 26, which already accounts for dependency direction.

27.2 Start-up sequence #

Run these steps in order on a clean machine, before milestone M0 in Section 26 begins:

  1. Verify the toolchain:
    deno --version   # expect a 2.x line, per Section 4
    psql --version   # expect a 18.x line, per Section 4 (or docker for a disposable instance)
    docker --version
    If any tool is missing or on a major line more than one behind the line in Section 4, install the current stable release for that major line.
  2. Install the current stable releases of every dependency listed in Section 4, using the Section 4 rule: confirm the resolved version's major line still matches Section 4's table, then let the lockfile record the exact patch version. Do not hand-pin patch versions in source.
  3. Create the repository skeleton exactly as laid out in Section 5 (folder tree, naming rules, module boundaries). Do not deviate from the folder names given there — every later section's file paths assume them.
  4. Start a PostgreSQL 18 instance (local install or docker run postgres:18) and set SKINFORGE_DATABASE_URL per Section 24's format.
  5. Run the first migration:
    deno task db:migrate
    This must create schema_migrations and every table in Section 6, per milestone M1 in Section 26.
  6. Confirm the app boots:
    deno task dev
    curl -sI http://localhost:8000/
    Expect HTTP 200. If it does not return 200, do not proceed to M1's remaining tasks — fix the boot failure first; a broken boot blocks every subsequent milestone's exit criteria.
  7. Run the empty test suite and lint/format checks:
    deno task check
    deno task test
    Both must exit 0 before starting M1's schema work.
  8. Verify object storage reachability before M4 needs it: if SKINFORGE_STORAGE_DRIVER=fs (the default), confirm SKINFORGE_STORAGE_FS_ROOT exists and is writable; if s3, confirm the bucket named by SKINFORGE_S3_BUCKET is reachable with the configured credentials using a simple put/get/delete of a throwaway key. Do this once at start-up time, not as a surprise discovered mid milestone M4.
  9. Confirm the CLI and the web app share the same core/ library rather than diverging copies: run deno check cli/main.ts and deno check main.ts and confirm both succeed against the same deno.json import map. Divergence here is a sign the repository skeleton in Section 5 was not followed exactly, and must be fixed before any further work.
  10. If working from a fresh clone rather than a brand-new repository (for example, resuming work started by a different agent or a previous session), run the entire start-up sequence again from step 1 rather than assuming a partially-configured environment is correct. A cheap re-verification is always less costly than debugging a stale environment assumption three milestones later.

27.3 The working loop per milestone #

For every milestone in Section 26, repeat this loop:

  1. Read the milestone. Read its goal, dependencies, scope, and task list in Section 26. Read every section referenced in its scope (for example, M2 requires re-reading Section 8 in full).
  2. Write the tests first for each task, following the test pyramid and golden-image rules in Section 22. A task's test encodes its exit criteria from Section 26 wherever the exit criterion is automatable; write it as an actual deno test or Playwright spec, not a manual checklist.
  3. Write the code to make the test pass, following the conventions in Section 5 (naming, module boundaries, lint rules) and the exact schemas/contracts owned by other sections (Section 6 for columns, Section 16 for API shapes, Section 8 for render behaviour, and so on).
  4. Run the quality gates from Section 22 (lint, type-check, unit tests, integration tests, and, for any milestone touching pixels, the golden-image suite) before considering the task done.
  5. Update the changelog. Append an entry to the developer's own CHANGELOG.md describing the user-visible or operator-visible effect of the change, dated in UTC.
  6. Commit with a Conventional Commit message (feat:, fix:, chore:, test:, docs:, refactor:, scoped where useful, e.g. feat(render): implement partial hue transform). One logical change per commit; do not batch unrelated tasks into one commit.
  7. Verify the milestone's exit criteria from Section 26 by literally running each check listed. Do not mark a milestone done from memory or inference — run the command or exercise the behaviour and observe the actual result.
  8. Move on to the next milestone per the dependency graph in Section 26.3. Do not start a milestone whose dependencies are not yet closed.

27.3.1 Worked example of one task through the loop #

To make the loop concrete, walk through one task from milestone M2 (Section 26.2.3): "Implement full-hue and partial-hue pixel transforms per the algorithm owned by Section 8.3."

  1. Read: re-open Section 8.3 (the hue algorithm — the only source of truth) and Appendix B (Section 28.2) for the worked numeric examples that reproduce it.
  2. Test first: write core/hue/apply_test.ts, co-located beside the module it tests per Deno's own convention, with three cases — full hue on a pixel (recolored by its red-channel index regardless of greyness), partial hue on a grey pixel (all three unpacked channels exactly equal), and partial hue on a non-grey pixel that must pass through unchanged — using the exact input/output values from Appendix B.
  3. Code: implement applyHue(pixel, hueEntry, mode) in core/hue/apply.ts, and unpackArgb1555 in core/hue/unpack.ts, against those tests.
  4. Gates: run deno test core/hue/apply_test.ts, deno fmt, deno lint.
  5. Changelog: add a line noting the hue transform implementation.
  6. Commit: feat(render): implement full and partial hue pixel transforms.
  7. Exit criteria: this task is one input to M2's overall exit criteria (Section 26.2.3), which are verified once all of M2's tasks are complete, via the golden-image suite.
  8. Move on to the next task in M2's list (the compositor).

This is the granularity every task in Section 26 is expected to be executed at.

27.4 Decision protocol #

This document is the decision authority. When executing:

  1. If the document states a decision, follow it exactly. Do not substitute a personally preferred pattern, library, or convention.
  2. If something is genuinely not covered by any section — a situation the document's authors did not anticipate — resolve it by finding the nearest analogous decision already made in the document and extending it in the same style. For example, if a new admin list screen needs a default page size and none is stated for that specific screen, use the pagination default in Section 16 rather than inventing a new one.
  3. Record every such extension as a short entry in the developer's own docs/adr/ directory (one file per decision, numbered sequentially, stating the situation, the decision, and the section it extends). This creates a durable trail without modifying this specification.
  4. Never stop work to ask a question. There is no one to answer it. A specification gap is resolved by the protocol above, not by pausing.
  5. Never invent a competing convention where one already exists (for example, do not introduce offset pagination anywhere because cursor pagination is already locked in Section 16; do not introduce a second error envelope shape because Section 16 already defines one).
  6. When two defaults from Section 1 interact in a way this document did not foresee, prefer the combination that keeps the public surface simplest for a first-time visitor and the admin surface safest for staff data integrity.
  7. Worked example: suppose, while building the admin tags screen (milestone M7), no section states whether a tag name is case-sensitive. The nearest analogous decision is the asset_key natural-key convention in Section 6.10 and the lowercase-kebab-case naming rule in Section 5.3. Extend that pattern: store tag names lowercase, reject mixed case at the validation layer defined by the Zod schema module convention in Section 5 / Section 16.9, and record the extension in docs/adr/0004-tag-name-casing.md.
  8. Worked example of the "keep going" rule: if, while implementing Stage D3 probing in milestone M3, the operator's client directory contains a file type with no analogous decision anywhere in Section 7 or Appendix A, do not halt the pipeline or the milestone. Classify the file as unknown and give it an extraction_candidates row with status = 'needs_operator_input' per Section 7's degrade-gracefully rule, continue processing the rest of the directory, and surface it in the admin candidate queue (Section 17) for a human to resolve later. The milestone is still considered on track; an unresolved candidate is an expected, handled outcome, not a blocker.

27.5 When reality disagrees with the document #

The single area most likely to surprise the executor is the real UO Outlands client file layout, because Section 7 explicitly documents discovery as staged and adaptive rather than assuming a fixed layout. When the real files differ from what the format descriptions in Section 7 anticipate:

  1. Keep the staged pipeline. Do not replace Stage D1–D6 with a one-off script for the specific files encountered. The staged structure is what lets the pipeline degrade gracefully and lets staff intervene through the admin console per Section 7's contract.
  2. Extend the format readers, not the pipeline shape. If a UOP block chain uses an undocumented compression flag, add a case to the Stage D3/D4 reader that recognizes and handles it; do not fork a parallel extraction path.
  3. Record findings in the developer's own notes (docs/format-notes.md or similar, in the developer's repository, not in this specification) — the exact byte offsets, flags, or quirks discovered, so a future re-import after a game patch benefits from the same knowledge.
  4. Preserve every interface that other sections depend on. Section 4's data flow, Section 6's table shapes, and Section 9's ingestion/publish contract must not change because one file format turned out to be unusual. The extraction layer absorbs the surprise; the rest of the system does not see it.
  5. If a container format genuinely cannot be parsed automatically (proprietary compression with no public documentation, for example), fall back to the manual-assist contingency defined in Section 26.4's Risk 1: a manual asset upload path in the admin console, so the product can still launch with a partial catalog while that one format's reader is finished later.
  6. If the hue range actually used by UO Outlands extends beyond the base 1..3000 space assumed in Section 3, do not hard-code a new upper bound anywhere. The hues table schema in Section 6 stores hue rows as data with a source column already anticipating outlands-sourced hues outside the base range; simply let the Stage D6 hue decoder in Section 7 write whatever rows it actually finds, tagged with the correct source value, and let downstream code read the range from the table rather than from a constant.
  7. If gump id ranges used for Stage D5 classification do not cleanly map to the slot registry in Section 3 for every sprite (some UO content historically reuses gump ids across contexts), leave every ambiguous mapping's extraction_candidates.status at 'pending' for staff review rather than guessing a slot. A wrong automatic classification that reaches the public catalog is worse than a slower manual review queue, because published assets are expected to remain stable per Section 9's retirement rules.
  8. If real client files reveal additional cosmetic layers not in the 19-row slot registry in Section 3.3 (one required body slot plus 18 choosable cosmetic slots, backpack included) — for example, a UO Outlands-specific cosmetic layer added after this document was written — do not silently add a slot. Record it as a candidate extension in docs/adr/, keep it out of the render pipeline and public UI until a deliberate decision is made, and flag it to the operator through the admin console's candidate queue. The 19-row slot registry in Section 3.3 is locked for this build; adding a slot is a scope change, not a routine extraction finding.

27.6 Non-negotiables #

The executor must never trade away any of the following, regardless of time pressure or a milestone running over its estimated effort:

  1. Pixel-exact rendering parity between the client-side canvas preview and the server-side composite, proven by the golden-image tests in Section 22. A visitor's shared permalink must always render exactly what they saw while designing.
  2. Permalink immutability. A design's canonical JSON and its rendered meaning never change after creation, per Section 13 and Section 9's retirement rules.
  3. No user accounts. No login, no password reset flow, no "my designs" list for public visitors, anywhere in the public surface.
  4. No raw game files served to users. End users receive only server-rendered PNG/WebP composites and pages; MUL/UOP/IDX files and any intermediate extraction artifact never leave the server.
  5. No third-party trackers. No third-party analytics scripts, no third-party cookies, no PII collection from public visitors, per Section 20.
  6. The fan-project disclaimer on every page. Every public page footer states the unofficial, fan-made nature of the project per Section 20.
  7. Accessibility conformance. WCAG 2.2 AA on every public page per Section 18, verified in milestone M12 by both the automated audit and the manual keyboard-only script from Section 22.6 — automated tooling alone does not exercise SC 2.1.4, SC 2.5.7 or SC 2.4.11, so an automated-only pass is not sufficient to satisfy this non-negotiable.
  8. Zero secrets in the repository. Every credential, token, and secret lives in an environment variable per Section 24, never committed, never logged in plaintext per Section 21.

Each non-negotiable exists because relaxing it silently breaks a promise made elsewhere in this document, often invisibly at first:

  • Relaxing pixel-exact parity means a visitor's shared link can show a different image than what they designed, which breaks the entire premise of Section 13's permalink contract and is very difficult to detect without the golden-image discipline in Section 22 — by the time it is noticed, many permalinks may already be wrong.
  • Relaxing permalink immutability breaks every previously shared link the moment a well-intentioned "fix" changes rendered output for existing codes; Section 9's retirement model exists specifically so catalog changes never do this.
  • Adding accounts, even as an "optional" feature, reintroduces PII handling, session management for the public surface, and a moderation surface that Section 20's threat model does not cover; it is a scope change, not an enhancement, and is explicitly out of scope per Section 2.
  • Serving raw game files, even briefly for debugging, risks distributing copyrighted client assets outside the fan-project's legal posture defined in Section 20.
  • Any third-party tracker changes the privacy posture that Section 20 and the public disclaimer jointly promise visitors.
  • Missing the disclaimer on even one page is a legal-posture gap, not a cosmetic omission — Section 20 treats it as a control, not a footer decoration.
  • An accessibility regression on any public page fails the launch gate in milestone M12 regardless of how minor it looks, because Section 18's WCAG 2.2 AA target is a hard gate, not a goal to approach; relying on the automated audit alone would silently pass real regressions that only the manual keyboard-only script catches, so both gates are required, not just the one that is easy to automate.
  • A single committed secret is a permanent compromise the moment the repository has any external collaborator, mirror, or backup; it cannot be un-leaked by a later commit removing it.

27.7 Common pitfalls and the correct approach #

Pitfall Correct approach
Smoothing pixel art when upscaling to scale 2 or 3 Use nearest-neighbour integer upscaling only, per Section 8. Never use a canvas API or image library default that applies bilinear/bicubic filtering; explicitly disable smoothing (imageSmoothingEnabled = false on any 2D canvas context involved, and a manual nearest-neighbour loop in the WASM/TypeScript encoder path).
Forgetting partial hue Partial hue remaps only pixels whose three unpacked channels are exactly equal (per the algorithm in Section 8.3 and the worked examples in Section 28.2, which reproduce it); applying full-hue logic to a partial-hue asset recolors pixels that must stay as-drawn. Always branch on the asset variant's hue_mode before transforming a sprite.
Mutating a design on remix "Remix" (starting a new edit from an existing permalink) must copy the canonical JSON into a new transient editor state and never write back to the original designs row. The original code must keep resolving to its original JSON forever.
Breaking cursor stability in pagination The opaque cursor per Section 16 must encode a stable sort key (creation order plus id tiebreaker), not an offset. Never derive the cursor from a value that can change between requests (such as a live count or a mutable updated_at).
Leaking untrusted asset names into HTML Asset keys, tags, and any staff-entered free text (including Section 7's --note field) are untrusted input for HTML purposes even though they come from an internal pipeline, because they ultimately derive from arbitrary file names on the operator's disk. Always escape them through the framework's normal templating (Fresh/Preact JSX auto-escapes by default) and never interpolate them into a dangerouslySetInnerHTML or raw SQL string.
Blocking the event loop in the encoder WASM encoding calls (@jsquash/webp, @jsquash/png) and the perceptual hash computation are CPU-bound; run them through the in-process job runner from Section 4's architecture (the Postgres jobs table with FOR UPDATE SKIP LOCKED) for batch extraction work, and keep the render-on-demand path's per-request work within the timeout budget in SKINFORGE_RENDER_TIMEOUT_MS (Section 24) so one slow render does not stall the whole Deno process.
Unbounded render concurrency Always gate concurrent render/encode work behind SKINFORGE_RENDER_MAX_CONCURRENCY (Section 24) with an explicit queue and a documented HTTP 503 with Retry-After when the queue is full, per milestone M11 in Section 26. Never let an unbounded number of simultaneous encode calls run against the same process.
N+1 queries on the catalog grid The catalog listing in Section 15 must fetch an asset page and its images/tags in a bounded number of queries (a join or a single batched IN (...) follow-up query), never one query per row. Verify this with the check in milestone M11's exit criteria.
Treating the discovery/extraction format tables as guaranteed-correct for the real client Section 7 and Appendix A (Section 28.1) describe the best-known layout, not a verified fact about every UO Outlands client build. Follow the reality-disagreement protocol in Section 27.5 rather than assuming a decode failure is a bug in the reader.
Re-deriving the error envelope or pagination shape in a new endpoint Every new API endpoint reuses the envelope and pagination helpers from Section 16; never hand-roll a slightly different JSON shape for a "special case" endpoint.
Validating a request shape twice, once by hand and once with the shared schema The Zod schema module convention in Section 5 / Section 16.9 requires one schema module per domain object, imported by both the server handler and any island that needs client-side validation. Writing a second, slightly different manual check invites drift between client and server validation.
Assuming the designer's client-side compositor and the server's compositor can be two separate implementations that merely produce "similar" output They must be the same algorithm applied to the same data, per the render contract in Section 8. A second, independently-written client compositor is exactly the failure mode the golden-image parity test in Section 22 exists to catch — do not treat that test as optional or as a "nice to have" once the UI looks right visually.
Hard-coding the hue range, slot count, or table names instead of reading them from Section 3 / Section 6 Any of these written as a magic number (for example, looping for (let h = 1; h <= 3000; h++)) breaks the moment Stage D6 discovers Outlands-specific hues outside that range, per Section 7. Read counts and ranges from the seeded data or the schema, not from a literal in application code.
Treating retired_at as a soft "hidden" flag that can later be reversed casually Retirement is part of the immutability contract in Section 9: a retired asset must keep rendering for any design created while it was active. Do not add a hard-delete path anywhere in the admin console, even as an "advanced" staff action, except through the legal takedown path in Section 20.
Skipping the no-JS fallback because "everyone has JavaScript enabled" Section 11's designer UI and Section 27.10's acceptance script both require a working no-JS path. Search crawlers generating the Open Graph preview and accessibility tooling both exercise the server-rendered path; treat it as a first-class path, not a progressive-enhancement afterthought.
Logging the full canonical design JSON or full request bodies at info level in production Section 21 owns the log schema; verbose payload logging at a default level bloats storage and can incidentally capture more of a request than intended. Follow the log schema and level conventions in Section 21 exactly rather than adding ad hoc debug logging that ships to production.

27.8 Quality bar and self-review checklist #

Before declaring any milestone done, confirm every item:

  1. Every task in the milestone's task list (Section 26) has a corresponding commit.
  2. Every exit criterion for the milestone has been run and observed to pass, not assumed.
  3. deno task check (lint, format, type-check) passes with zero warnings.
  4. All tests required by Section 22 for this milestone's layer of the pyramid pass, including any golden-image tests if the milestone touches rendering.
  5. No new TBD, TODO, placeholder string, or commented-out block was introduced.
  6. No secret, token, or credential appears in source, in a commit message, or in a log statement.
  7. Every new table, column, endpoint, route, or environment variable matches the names already locked in Sections 6, 16, 10/11, and 24 respectively — no ad hoc renaming.
  8. The changelog entry and Conventional Commit message accurately describe the change.
  9. Any decision made under the protocol in Section 27.4 has a corresponding docs/adr/ entry.
  10. The milestone leaves the app in a state where deno task dev boots and the previously passing exit criteria of all EARLIER milestones still pass (no regression).

27.9 Handover artifacts #

By the end of milestone M12 (Section 26), the executor must have produced, in the developer's own repository (not in this specification):

  1. A README.md covering local setup, environment variables (pointing to Section 24's table), running migrations, running the CLI, and running tests.
  2. The runbooks required by Section 23: deploy, rollback, backup/restore, and re-import after a game patch.
  3. Seeded fixtures under tests/fixtures/ sufficient for a new contributor to run the full test suite without access to the operator's real client files.
  4. The first real import report: the output of skinforge-cli import against the operator's actual client files, including the diff summary and any candidates left in pending or needs_operator_input state, handed to the operator for the first staff review pass.
  5. The docs/adr/ directory containing every decision recorded under Section 27.4's protocol during the build.
  6. A CHANGELOG.md with one entry per milestone at minimum.
  7. A short operator-facing guide (in the developer's own docs/ directory) explaining, in plain language and without assuming familiarity with this specification, how to trigger a re-import after a future UO Outlands client patch: which CLI commands to run, in what order, and how to use the admin candidate review queue to approve new or changed assets before they go live. This is the artifact the operator will actually use months after launch, when the details of this specification have faded from memory.
  8. A list of every docs/adr/ entry produced during the build, summarized in one paragraph each, so the operator can see at a glance which decisions were extended beyond what this specification stated explicitly.

27.9.1 Why handover artifacts matter #

This specification is complete and self-contained, but it describes the system as designed, not as built. The handover artifacts in this sub-section are what let a human operator — who may never read this document — run, maintain, and extend the product safely. Treat producing them with the same rigor as passing a milestone's exit criteria; a milestone is not truly finished if its operational knowledge lives only in the executor's own reasoning during the build.

27.10 Final acceptance test script #

Run these checks in order against a fully deployed instance to confirm v1 is complete. Every check must pass.

  1. curl -sI https://<public-base-url>/ returns HTTP 200 and includes the security headers from Section 20.
  2. curl -s https://<public-base-url>/api/v1/slots | jq '.data | length' returns 19.
  3. Load / in a browser, select a body, a skin hue, and at least three cosmetic slots with hues, and confirm the canvas preview updates instantly for each change. Separately, confirm /design issues a 301 redirect to / (Section 10.1).
  4. Click Share; confirm the browser navigates to /d/:code with a 10-character lowercase code and the page renders the same look shown in the designer.
  5. curl -sI https://<public-base-url>/og/d/:code.png returns Content-Type: image/png with a long-lived Cache-Control header per Section 19.
  6. Reload /d/:code with JavaScript disabled; confirm the composite image and metadata still render server-side.
  7. Visit /catalog, /catalog/hair, and any /catalog/hair/:assetKey page; confirm thumbnails load and pagination controls work using cursor parameters, not offsets.
  8. Log in at /admin with a staff account and a valid TOTP code; confirm the dashboard loads.
  9. From /admin/candidates, approve one pending candidate, then run skinforge-cli import publish --build <label>, then confirm the asset appears at /catalog/:slotKey on the next request.
  10. From /admin/imports, view the most recent import run's diff summary and confirm it lists added/changed/unchanged/retired counts consistent with the actual database state.
  11. Attempt to access any /admin/* route without a session cookie; confirm redirection to /admin.
  12. Run the automated accessibility audit against the production URL and confirm zero violations, and separately run the manual keyboard-only script from Section 22.6 (including its SC 2.1.4, SC 2.5.7 and SC 2.4.11 checks) and confirm it passes — the automated tool alone does not cover WCAG 2.2 AA in full.
  13. Confirm the fan-project disclaimer text is present in the footer of /, /catalog, /d/:code, and every other public page reachable from the sitemap.
  14. Run the full automated test suite (deno task test) and the Playwright suite against the deployed build (or a staging mirror) and confirm all tests pass.
  15. Confirm SKINFORGE_MAINTENANCE_MODE correctly takes the public site into a maintenance response when set, and back out of it when unset, per Section 23.9 (a container restart to pick up the changed environment variable is expected and is not a code redeploy).

A v1 build that passes all fifteen checks is complete per the definition in Section 26.5.

28. Appendices #

28.1 Appendix A — Binary format quick reference #

This appendix is a lookup card for the byte-level layouts used by the readers in Section 7. Section 7 owns the decoding algorithms and staged pipeline; this appendix restates only the layout facts needed while writing or debugging those readers.

28.1.1 MUL/IDX index record #

A classic UO "index" file (*.idx or an *idx.mul companion to a data .mul file) is a flat array of fixed 12-byte records, one per entry, addressed by entry number:

Offset Size Field Meaning
0 4 bytes, int32le lookup Byte offset of the entry's data in the companion .mul file, or -1 (0xFFFFFFFF) if the entry is absent.
4 4 bytes, int32le length Byte length of the entry's data, or -1 if absent.
8 4 bytes, int32le extra Format-specific auxiliary value (for gump art: packed width/height; for some formats: unused, read as 0).

Reading entry n means seeking to byte n * 12 in the index file, reading the 12-byte record, and, if lookup != -1 and length != -1, reading length bytes starting at lookup in the companion data file.

28.1.2 Gump art run-length encoding #

Gump art entries (used for sprites: bodies, hair, clothing, and every other visual slot in Section 3) are stored as run-length encoded rows of ARGB1555 pixels:

Offset Size Field Meaning
0 4 bytes, uint32le width Sprite width in pixels (also derivable from the index extra field).
4 4 bytes, uint32le height Sprite height in pixels.
8 height * 4 bytes rowOffsets One uint32le per row: the byte offset (from the start of the pixel data, i.e. after the row-offset table) where that row's run-length data begins.
8 + height*4 variable rowData For each row, a sequence of runs. Each run is a uint16le colorOrCount pair: first a uint16le pixel color (ARGB1555, see 28.1.4) then a uint16le run length in pixels; a run with color 0 and any length denotes transparent pixels to skip. A run with length 0 ends the row.

Decoding proceeds row by row using rowOffsets to seek, emitting runLength pixels of color (transparent if color == 0) until the terminating zero-length run, producing a full width x height RGBA buffer (color 0 maps to alpha 0, all other colors map to alpha 255 after ARGB1555 expansion).

The unit and origin of rowOffsets are not verified against a real Outlands file — community implementations commonly disagree, some treating each entry as a byte offset from the start of the pixel data (as tabulated above), others as a 32-bit-word offset from the start of the rowOffsets table itself; getting it wrong decodes every sprite to garbage, the same failure mode 28.1.3 already guards against for the hues.mul spacer. Per Section 27.5's reality-disagreement protocol, the Stage D4 reader MUST decode row 0 under both hypotheses (bytes-after-table, and dwords-from-table-start) and select whichever yields a run sequence that terminates within width pixels, rather than assuming the byte-offset reading shown above is correct without checking.

28.1.3 hues.mul layout #

hues.mul is a flat array of fixed-size hue blocks, no index file. "Hue block" is this appendix's name for the file-format grouping described here — eight raw hue entries sharing one 708-byte record — which is a different concept from the curated hue_groups taxonomy table (Section 6.14, five rows: skin, hair, tattoo, clothing, event) that the catalog and admin console use for browsing. A Stage D6 step (Section 7, milestone M4 in Section 26.2.5), the same stage that decodes this block into hues rows, assigns each decoded hue to one or more curated hue_groups rows via hue_group_members; the raw block below plays no further part once decoding is done.

Offset (within a block) Size Field Meaning
0 8 entries × 88 bytes = 704 bytes entries[8] Eight fixed-size hue entries, see below.
704 4 bytes unused Historically a spacer; read and discard.

Each 88-byte hue entry within the block:

Offset Size Field Meaning
0 32 × 2 bytes = 64 bytes colorTable[32] 32 ARGB1555 colors forming the hue's gradient/ramp, low to high luminance.
64 2 bytes, uint16le tableStart Index into colorTable where "normal" application begins (implementation detail retained for parity with the original client; SkinForge's own hue math in Section 8.3 uses the full 32-entry table directly and does not need this field beyond provenance).
66 2 bytes, uint16le tableEnd Companion end index to tableStart.
68 20 bytes name ASCII hue name, null-padded, not guaranteed to be present or meaningful for custom Outlands hues.

Total file size is blockCount * 708 bytes; blockCount is 375 in the stock client and MUST be re-derived from the actual file size (fileSize / 708) rather than hard-coded, per Section 27.5's reality-disagreement protocol, since UO Outlands may extend this file.

Whether the 4-byte spacer precedes or follows the eight entries within a block is not verified against a real Outlands file — every widely-circulated community description disagrees on this point, and getting it wrong shifts every offset in the table above by 4 bytes, decoding all ~3,000 hues to garbage. Per Section 27.5's reality-disagreement protocol, the Stage D2/D4 reader MUST validate by decoding hue index 1 under both hypotheses (spacer-first, entries at offset 4..707; or spacer-last as tabulated above, entries at offset 0..703) and selecting whichever hypothesis produces a printable-ASCII name field, rather than assuming the layout shown here is correct without checking.

28.1.4 ARGB1555 packing #

A 16-bit ARGB1555 value packs one alpha bit and 5 bits each of red, green, blue:

bit:    15    14-10   9-5    4-0
field:  A     R       G      B

Unpacking to 8-bit-per-channel RGBA — this is the one unpackArgb1555 implementation used everywhere in this specification, owned by Section 8.3.1, reproduced here verbatim:

function unpackArgb1555(value: number): { r: number; g: number; b: number; a: number } {
  const r5 = (value >> 10) & 0x1f;
  const g5 = (value >> 5) & 0x1f;
  const b5 = value & 0x1f;
  // 5-bit to 8-bit: replicate the top 3 bits into the low 3 bits for even spread across 0..255
  const scale = (v5: number) => (v5 << 3) | (v5 >> 2);
  return {
    r: scale(r5),
    g: scale(g5),
    b: scale(b5),
    a: value === 0x0000 ? 0 : 255,
  };
}

Sprite transparency comes from the run-length color == 0 sentinel in 28.1.2, never from bit 15: the packed value 0x0000 is the transparency convention, and bit 15 is not consulted at all — a sprite pixel is transparent exactly when its packed source color is zero, opaque otherwise. Packing the reverse direction truncates each 8-bit channel to its top 5 bits and sets the source color to 0x0000 when the source alpha is zero, to any non-zero packed value otherwise.

28.1.5 UOP MythicPackage layout #

UOP files use a chained-block container format:

File header (fixed, at offset 0):

Offset Size Field Meaning
0 4 bytes magic ASCII MYP\0.
4 4 bytes, uint32le version Format version.
8 4 bytes, uint32le formatTimestamp Build timestamp, format-defined.
12 8 bytes, int64le firstBlockOffset Byte offset of the first block header.
20 4 bytes, uint32le blockSize Number of entries per block ("max entries per block").
24 4 bytes, uint32le entryCount Total entry count across all blocks.

Block header (at firstBlockOffset, then chained):

Offset Size Field Meaning
0 4 bytes, uint32le entriesInBlock Number of entries in this block.
4 8 bytes, int64le nextBlockOffset Offset of the next block, or 0 if this is the last block.

Entry record (repeated entriesInBlock times immediately after the block header):

Offset Size Field Meaning
0 8 bytes, uint64le dataOffset Byte offset of this entry's compressed/raw data.
8 4 bytes, uint32le headerLength Length of a per-entry data header to skip before the payload.
12 4 bytes, uint32le compressedSize Size of the stored payload.
16 4 bytes, uint32le decompressedSize Size after decompression (equal to compressedSize if uncompressed).
20 8 bytes, uint64le pathHash 64-bit hash of the entry's original virtual file path (order-independent identifier; matched against a known hash table for the expected virtual paths, since UOP files do not store plain filenames).
28 4 bytes, uint32le headerHash Adler32 or format-defined checksum of the header; used for integrity verification.
32 2 bytes, uint16le compressionFlag 0 = uncompressed, 1 = zlib-compressed (per Section 7's Stage D3 compression sniffing, treat any other value as unknown/needs_operator_input rather than assuming zlib).

To read an entry: seek to dataOffset, skip headerLength bytes, read compressedSize bytes, and if compressionFlag == 1, zlib-inflate to decompressedSize bytes.

28.2 Appendix B — Hue math worked examples #

These examples use the hue algorithm owned by Section 8.3 — the only source of truth for the unit tests and golden images built in milestone M2 (Section 26.2.3) — and reproduce its numbers exactly; this appendix is a lookup card, never an independent restatement. Each example shows one input pixel (as a packed 16-bit ARGB1555 value), one hue table entry, and the resulting output pixel, with every intermediate value shown so the transform is checkable by hand using unpackArgb1555 (28.1.4).

28.2.1 Example 1 — full hue on a mid-tone pixel #

Input packed pixel: c = 0xC104. Unpacking per 28.1.4:

  • r5 = (c >> 10) & 0x1F = 0b10000 = 16r8 = (16 << 3) | (16 >> 2) = 128 | 4 = 132.
  • g5 = (c >> 5) & 0x1F = 0b01000 = 8g8 = (8 << 3) | (8 >> 2) = 64 | 2 = 66.
  • b5 = c & 0x1F = 0b00100 = 4b8 = (4 << 3) | (4 >> 2) = 32 | 1 = 33.
  • a = c === 0x0000 ? 0 : 255 = 255 (c is non-zero).

Unpacked source pixel: r=132, g=66, b=33, a=255.

Full hue mode replaces every non-transparent pixel's color with the hue table color at the index taken directly from the source pixel's own 5-bit red channel, ignoring every other channel and ignoring whether the pixel is grey (this is what makes "full hue" produce flat, uniformly recolored art regardless of the source sprite's original palette):

  1. index = r8 >> 3 = 132 >> 3 = 16. (This always recovers the original 5-bit value: the low 3 bits the unpacking step added back in never push the shifted result into the next bucket, so index = r8 >> 3 = r5 for every r5 in 0..31.)
  2. Look up colorTable[16] = { r: 200, g: 40, b: 40 } (the hue table entry for this hue, per Section 8.3.2).
  3. Output pixel: r=200, g=40, b=40, a=255 — alpha is carried over unchanged from the unpacked source pixel; full hue replaces only the color channels.

28.2.2 Example 2 — partial hue on a grey pixel #

Input packed pixel: c = 0x4210 (r5 = g5 = b5 = 0b10000 = 16 — chosen so all three 5-bit channels are identical). Unpacking gives r8 = g8 = b8 = 132 (the same (16 << 3) | (16 >> 2) expansion computed in 28.2.1) and a = 255 (c is non-zero).

Partial hue mode recolors a pixel only when its three unpacked 8-bit channels are exactly equal (r8 === g8 && g8 === b8, no tolerance window), using the same red-channel index rule as full hue for those pixels, and leaves every non-grey pixel completely unchanged:

  1. Exact-equality check: r8 === g8 === b8 === 132 → true → the pixel qualifies for recoloring.
  2. index = r8 >> 3 = 16 (same derivation as 28.2.1).
  3. Look up colorTable[16] = { r: 200, g: 40, b: 40 }.
  4. Output pixel: r=200, g=40, b=40, a=255.

28.2.3 Example 3 — partial hue on a non-grey pixel (pass-through) #

Same input pixel as Example 1: c = 0xC104, unpacking to r8=132, g8=66, b8=33, a=255 — not grey.

  1. Exact-equality check: 132 === 66 is false → the pixel does NOT qualify for recoloring.
  2. Output pixel: unchanged, r=132, g=66, b=33, a=255.

This is the behaviour the pitfall table in Section 27.7 refers to when it warns against applying full-hue logic where partial-hue is required: Examples 1 and 3 hue the identical source pixel differently only because the asset's hue_mode differs, never because the pixel itself changes — full hue (28.2.1) recolors it via the red-channel index regardless of greyness, partial hue (28.2.3) passes it through unchanged because it fails the exact-equality test.

28.2.4 Hue index 0 (unhued) #

Hue index 0 is a sentinel meaning "apply no hue transform at all" — the sprite's own decoded RGBA values pass through unchanged, regardless of full or partial mode. This is the "as-drawn" case referenced in Section 13's canonical design JSON rules.

28.3 Appendix C — Slot registry lookup card #

Consistent with the taxonomy owned by Section 3. Reproduced here as a flat reference card. 19 rows total: one required body slot (always part of every design) plus 18 choosable cosmetic slots, backpack included — 17 of those 18 are slots a design may leave entirely empty, and the 18th, backpack, is choosable like any other slot but always renders (a default asset key, backpack.default, is used when the visitor has not chosen one — Section 13.2.1), since a paperdoll never shows an empty back.

z slot_key Display name uo_layer Hueable Gender
10 body Body yes both (required)
20 tattoo_body Body Tattoo yes both
30 footwear Footwear 3 yes both
40 legs_inner Pants 4 / 24 yes both
50 torso_inner Shirt 5 / 13 yes both
60 torso_middle Chest Piece 17 yes both
70 arms Arms 19 yes both
80 gloves Gloves 7 yes both
90 waist Belt / Sash 12 yes both
100 legs_outer Skirt / Kilt 23 yes both
110 torso_outer Robe / Outer Torso 22 yes both
120 neck Neck 10 yes both
130 hair Hair 11 yes both (different style sets)
140 facial_hair Beard 16 yes male
150 face Face Art 15 yes both
160 earrings Earrings 18 yes both
170 head Hat / Helm 6 yes both
180 cloak Cloak 20 yes both
190 backpack Backpack 21 yes both (always visible)

Excluded layers, never modeled as slots: one-handed weapon (1), two-handed weapon (2), ring (8), bracelet (14), talisman (9), and any stat-bearing equipment catalog beyond visible cosmetics.

Body codes: m (Male, paperdoll base gump 12 / 0x000C), f (Female, paperdoll base gump 13 / 0x000D). These are the only two designable bodies.

28.4 Appendix D — Canonical design JSON reference #

Consistent with the format owned by Section 13. One compact example, with slots already in the required ascending z-order (torso_outer=110 before hair=130, per Appendix C):

{"v":1,"body":"m","skinHue":1002,"slots":[{"slot":"torso_outer","asset":"robe.plain","hue":0},{"slot":"hair","asset":"hair.long-wavy","hue":1102}]}

Field reference:

Field Type Notes
v integer Schema version, currently 1.
body "m" | "f" Required.
skinHue integer Hue index applied to the body slot; 0 means as-drawn.
slots array Sorted by the z-order in Appendix C; entries for empty slots are omitted entirely. One entry per occupied slot, out of the 18 choosable slots (the 17 optional slots plus backpack) — never an entry for body itself.
slots[].slot string One of the 18 choosable slot_key values in Appendix C.
slots[].asset string An asset_key natural key, e.g. hair.long-wavy.
slots[].hue integer 0 means as-drawn.

Canonicalization for hashing (Section 13.3): keys emitted in the fixed order v, body, skinHue, slots — not alphabetically sorted — and within each slot entry, the fixed order slot, asset, hue; no insignificant whitespace; UTF-8 encoding; exactly as shown in the compact example above (which is already in canonical form). A fixed emission order is what makes the form deterministic; a generic "sort object keys" step would not reproduce the example, since an alphabetical sort of v, body, skinHue, slots is body, skinHue, slots, v.

Query-string (transient, pre-share) form of the same design:

?b=m&s=1002&hair=hair.long-wavy:1102&torso_outer=robe.plain:0

One query parameter per non-empty slot, named after the slot_key, valued assetKey:hue (order is not significant in this transient form, unlike the permanent canonical form above); b is the body code and s is the skin hue. This form is never persisted as-is (Section 13.6); pressing Share converts it to canonical JSON, computes the short code, and the client replaces the address bar with /d/:code via history.pushState (Section 11.9) — there is no full-page redirect.

28.5 Appendix E — Glossary #

Alphabetical. Covers both the UO Outlands game domain and this system's own vocabulary.

Term Meaning
Admin console The staff-only web interface for reviewing and publishing assets, owned by Section 17.
ARGB1555 A 16-bit pixel format: 1 alpha bit, 5 bits each red/green/blue. See Appendix A.4.
Asset A single designable visual item (a hair style, a shirt) with one asset_key, owned by Section 6.
Asset key The stable, human-readable natural key for an asset, e.g. hair.long-wavy.
Asset variant A per-body sprite record for an asset (geometry, offsets, hash), scoped to the build it was extracted from — one row per (asset, body, build) triple.
Body One of the two designable player bodies, body code m (Male) or f (Female).
Build One versioned extraction run tied to a specific state of the operator's client files, table game_builds.
Canonical JSON The fixed-key-order, whitespace-free JSON form of a design used for hashing, owned by Section 13.
Catalog The public browsing surface for assets and hues, owned by Section 15.
Classification Stage D5 of extraction: mapping decoded sprites to slots and bodies.
Compositing Layering multiple hued sprites onto one canvas in z-order, owned by Section 8.
Compression sniffing Detecting zlib/deflate/LZ4 signatures in an unknown container, Stage D3.
Cursor pagination The only pagination model used in this system's API; opaque cursor, no offsets.
Design One saved, immutable combination of body, skin hue, and slot choices, table designs.
Discovery Stage D1–D3 of extraction: inventorying and identifying files before extracting them.
Entropy (file) A statistical measure used in Stage D1 to help distinguish compressed/unknown data from structured data.
Extraction Stage D4–D6: decoding identified files into usable sprites and hue tables.
Extraction candidate A file or record flagged by discovery as possibly containing designable art, awaiting classification or review.
Fan-project disclaimer The persistent, mandatory statement that SkinForge is unofficial and unaffiliated with the game's operators.
Full hue A hue mode that recolors every non-transparent pixel using the hue table index taken from the pixel's own 5-bit red channel, ignoring whether the pixel is grey.
Golden-image test A test that compares rendered output byte-for-byte against a committed reference image.
Gump Classic UO term for a 2D art asset, including paperdoll sprites, decoded via run-length encoding.
Gump id The numeric identifier of a gump art entry within the client's art files.
Hue A recoloring table applied to a sprite; index 0 means unhued.
Hue block This appendix's name for the file-format grouping in hues.mul: 8 raw hue entries sharing one 708-byte record. A different concept from "hue group" below; see Appendix A.3 (28.1.3).
Hue group A curated taxonomy row (table hue_groups, Section 6.14) used to organize hues for browsing and picking — 5 fixed rows (skin, hair, tattoo, clothing, event), unrelated to the raw 8-entry file blocks decoded from hues.mul (see "Hue block" above).
Hueable Whether a given slot accepts a hue selection, per the registry in Section 3 / Appendix C.
IDX file The index file paired with a .mul data file, fixed 12-byte records. See Appendix A.1.
Immutability (design) The guarantee that a permalink's rendered meaning never changes after creation.
Ingestion Turning an extraction run into diffed, reviewable, publishable catalog data, owned by Section 9.
Job runner The in-process, Postgres-backed background work system with no external broker.
Layer (UO) The classic UO equipment slot numbering system; SkinForge's uo_layer column references it where applicable.
MUL file A classic UO flat binary data file, typically paired with an .idx index.
Natural key A stable, human-readable identifier (e.g. asset_key) used in URLs and JSON instead of a numeric id.
Normalization (Stage D6) Trimming, re-encoding, and hashing extracted sprites into final stored assets; also the stage that decodes hues.mul into hues rows and assigns each to its curated hue_groups row via hue_group_members (Section 7.7).
Object storage The pluggable file storage layer (fs or s3 driver), owned by Section 4's architecture.
OG image The Open Graph social preview image auto-generated per permalink, owned by Section 14.
Paperdoll The static, front-facing full-body character illustration UO Outlands renders in-game; SkinForge's one supported pose.
Partial hue A hue mode that recolors only pixels whose three unpacked RGB channels are exactly equal (no tolerance window) and leaves every other pixel unchanged.
Perceptual hash A hash sensitive to visual similarity rather than exact bytes, used to detect near-duplicate sprites during normalization.
Permalink The permanent, immutable share URL for one design, /d/:code.
Provenance The recorded source file, offset, and build for every extracted asset.
Publish Promoting a reviewed build's assets to live, publicly visible status.
Rate limit A Postgres-backed token-bucket throttle applied to API and render endpoints.
Render URL The content-addressed URL scheme for composite and per-asset images, owned by Section 8.
Retirement Soft-deleting a catalog row (retired_at) while it remains renderable for old designs.
Rollback (publish) Republishing a previous build's asset set without re-running extraction.
Short code The 10-character Crockford Base32 identifier derived from a design's canonical JSON hash.
Skin hue A hue tagged into the skin hue group, applied to the body slot.
Slot One designable position in the composite (e.g. hair, footwear), 19 total including body and backpack.
Slot key The canonical string identifier for a slot, e.g. torso_outer.
Source file One file recorded during discovery, with its detected format and metadata.
Takedown request The formal legal/removal request workflow, owned by Section 20.
Tiledata Classic UO metadata file describing item/art flags, used to help classify sprites in Stage D5.
TOTP Time-based One-Time Password, RFC 6238, used for mandatory admin second-factor authentication.
UOP file MythicPackage container format, chained blocks of compressed entries. See Appendix A.5.
Z-order The bottom-to-top stacking order of slots in the composite, fixed in Section 3 / Appendix C.

28.6 Appendix F — External references #

Reference What it is for
WCAG 2.2 (W3C Recommendation) The accessibility conformance standard targeted at AA level, owned by Section 18.
Open Graph protocol (ogp.me) The metadata standard behind the social preview images in Section 14.
Crockford Base32 (Douglas Crockford's specification) The alphabet and encoding used for permalink short codes in Section 13.
ARGB1555 / 16-bit color packing (general graphics reference) The pixel format used throughout the classic UO art files, detailed in Appendix A.4.
WebP image format (Google, via the WebP container specification) The default render output format, encoded via the codec named in Section 4.
Argon2 (RFC 9106) The password hashing algorithm used for admin authentication in Section 17.
TOTP (RFC 6238) The one-time-password algorithm used for mandatory admin second-factor authentication.
Conventional Commits (conventionalcommits.org) The commit message convention required by Section 27.3.
The ClassicUO project (open-source UO client reimplementation) A public reference implementation useful for cross-checking MUL/UOP/gump decoding behaviour during Section 7's development.
Community UO file-format documentation (various long-standing UO emulation community wikis and forums) General public reference material on MUL/UOP/hues.mul layouts, useful background for Section 7 and Appendix A; treat any single community source as informative, not authoritative, and verify against real extracted files per Section 27.5.
PostgreSQL 18 official documentation Reference for the database engine named in Section 4.
Deno 2.x and Fresh 2.x official documentation Reference for the runtime and framework named in Section 4.

28.7 Appendix G — One-page system summary #

What it is: a free, no-login web tool where UO Outlands players design a human male or female character's cosmetic appearance (skin hue plus 18 choosable hueable slots, backpack included) and share it as a permanent link with an auto-generated social preview image. A staff-only pipeline extracts the underlying art and hue data from the operator's own UO Outlands client files; nothing is scraped from or downloaded by end users.

The stack (full detail in Section 4): Deno 2.x runtime, Fresh 2.x web framework with Preact 10.x islands, PostgreSQL 18 via postgres.js, pure-TypeScript-plus-WASM rendering (no native image libraries), Docker Compose deployment behind Caddy, optional S3-compatible object storage.

The five surfaces:

  1. The public designer (Sections 10–13): build a look, see it live, share it.
  2. The catalog (Section 15): browse every published asset and hue independently of the designer.
  3. The public API (Section 16): the JSON contract every page and island consumes.
  4. The admin console (Section 17): staff review, approval, and publish workflow.
  5. The extraction pipeline (Sections 7 and 9): discovery, decoding, ingestion, and re-sync after patches, run entirely by staff via the CLI.

Data flow, end to end: operator's client files → discovery (Stage D1–D3) → extraction (Stage D4–D6) → staff review in the admin console → publish → public catalog and designer read published assets → a visitor's chosen combination becomes a design → the design is hashed into a short code and persisted as a permalink → the permalink page and its Open Graph image are rendered server-side from the same composite algorithm the designer used client-side, guaranteeing pixel parity.

The ten decisions that matter most:

  1. Exactly two bodies (Male m, Female f), one static paperdoll pose, no animation.
  2. A 19-row slot registry — one required body slot plus 18 choosable cosmetic slots, backpack included (uniquely among them, backpack always renders a default asset when left unset, since a paperdoll never shows an empty back) — with a fixed z-order, owned by Section 3.
  3. No user accounts anywhere; permalinks are the entire persistence model for end users.
  4. Pure TypeScript plus WASM rendering, avoiding native image libraries and headless browsers.
  5. Cursor-only pagination and one consistent success/error envelope across the entire public API.
  6. Asset discovery is staged and adaptive (Stages D1–D6), never assuming a fixed, known file layout.
  7. Every catalog row is soft-deleted (retired_at) so permalinks never break, and designs themselves are fully immutable and never auto-deleted — the only removal path is the legal takedown process (Section 20.8).
  8. Admin authentication requires TOTP for every staff account; there is no public account system to compare it against.
  9. No third-party trackers, no cookies for public visitors, no PII — aggregate counters only.
  10. The fan-project disclaimer is a persistent, non-negotiable control, not a cosmetic footer note.

Generated at GenerateSpecs.com — one idea in, one buildable spec out.

Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.