BareGit
# Prototype architecture

## Status

This document specifies the implementation of the Card Collection prototype.
It derives product behavior from `prd.md` and turns that behavior into concrete
components, database tables, HTTP routes, browser flows, and failure rules.

The document deliberately excludes MVP accounts and collecting behavior. The
prototype has no users. Every visitor receives the same administrative UI and
can create, edit, view, and delete cards and series.

## Goals

The prototype must:

1. start from a validated TOML configuration;
2. initialize and migrate a SQLite database automatically;
3. expose compiled game definitions and dynamic series;
4. create loose cards and cards belonging to compiled games;
5. preserve sequential, never-reused numbers within each game;
6. derive every public card ID from a nullable game and integer number;
7. accept, validate, normalize, and store card images;
8. render foil cards with the WebGL implementation copied from `foil`;
9. generate static thumbnails in the backend for plain cards and in the
   browser for foil cards;
10. serve server-rendered card, index, form, and series pages;
11. render Markdown with MacroDown while rejecting unsafe URLs and raw HTML;
12. leave the previous card state intact after a handled edit failure; and
13. provide enough automated tests to change the prototype safely.

## Non-goals

The prototype does not implement:

- users, authentication, authorization, or ownership;
- card collection mechanics;
- a JSON application API;
- client-side routing or a Preact application;
- filtering, searching, or pagination;
- browser fallbacks when WebGL 2 is unavailable;
- remote-image proxying by the backend;
- storage quotas or upload rate limits;
- backups or disaster recovery;
- a generic runtime game-schema editor; or
- perfectly atomic commits spanning SQLite and the filesystem.

The lack of a JSON API is intentional. HTML forms are the public mutation
interface for the prototype. Internal C++ services must not depend on HTTP
types, so a JSON API can be added later without rewriting domain logic.

## Architectural decisions

### Server-rendered HTML with progressive enhancement

The server renders complete HTML documents. Browser JavaScript is limited to
the behavior that genuinely requires a browser:

- the interactive WebGL preview;
- local and CORS-enabled remote image loading;
- image dimension validation for immediate feedback;
- foil thumbnail rendering and transparent-edge trimming; and
- attaching prepared binary blobs to a native form submission.

This boundary keeps game validation, Markdown rendering, database access, and
permission decisions in C++. The browser does not duplicate those rules.

### Layered backend

`App` handlers translate requests into typed service inputs. Services
implement use cases. `DataSourceSQLite` owns SQL. The asset store owns
filesystem changes. Page renderers own HTML templates. A handler must not
contain SQL or construct card paths directly.

This separation matters because a card mutation touches several resources. A
single `CardService` operation can validate metadata, stage images, open a
database transaction, allocate an ID, and publish a directory while keeping
the ordering visible and testable.

### One common card table

All cards live in one `cards` table. A compiled game may add one extension
table keyed by `cards.id`. This keeps series membership, index queries, and
future user ownership attached to one stable internal identity.

The formatted public ID is never stored. A game card stores a game short name
and integer number. A loose card stores a null game and a random 32-bit integer
in the same number column.

### Filesystem assets with staged publication

Uploaded content is written to a unique staging directory. The server only
publishes the directory after every required image and thumbnail has passed
validation. Handled failures remove the staging directory.

The configured card-storage root has this fixed internal layout:

```text
<card-root>/
├── published/
│   └── <public-id>/
├── .staging/
└── .trash/
```

Only `published` is exposed as a static mount. Staging, edit backups, and trash
remain outside the mounted tree even though they share the configured root.
The canonical directory for a card is
`<card-root>/published/<lowercase-public-id>`.

SQLite and directory renames cannot participate in one transaction. The
service therefore chooses a strict operation order, attempts compensating
cleanup after errors, and renders a useful card page if committed database
metadata refers to a missing asset.

## System overview

```mermaid
flowchart LR
    Browser[Server-rendered browser page]
    WebGL[WebGL card component]
    HTTP[App and route handlers]
    Service[Card and series services]
    Games[Compiled GameRegistry]
    Data[DataSourceInterface]
    DB[(SQLite)]
    Assets[(Card asset root)]
    Templates[Inja templates]
    Markdown[MacroDown renderer]

    Browser -->|GET and native form POST| HTTP
    Browser --> WebGL
    WebGL -->|prepared foil thumbnail blob| Browser
    HTTP --> Service
    HTTP --> Templates
    Service --> Games
    Service --> Data
    Data --> DB
    Data --> Games
    Service --> Assets
    Templates --> Markdown
```

The dependency direction always points inward. `CardService` does not know
about Inja, `httplib::Request`, or browser field names. `App` translates HTTP
requests into `CreateCardInput` and `UpdateCardInput` values. Services depend
on `DataSourceInterface`, never on SQLite or HTTP types.

## Source tree

The implementation should use this initial layout:

```text
card-collection/
├── CMakeLists.txt
├── cmake/
│   └── dependencies.cmake
├── config.example.toml
├── designs/
│   └── design-0-prototype.md
├── prd.md
├── src/
│   ├── app.cpp
│   ├── app.h
│   ├── asset_store.cpp
│   ├── asset_store.h
│   ├── card.cpp
│   ├── card.h
│   ├── card_service.cpp
│   ├── card_service.h
│   ├── config.cpp
│   ├── config.h
│   ├── data.cpp
│   ├── data.h
│   ├── game.cpp
│   ├── game.h
│   ├── game_registry.cpp
│   ├── game_registry.h
│   ├── html_renderer.cpp
│   ├── html_renderer.h
│   ├── image_processor.cpp
│   ├── image_processor.h
│   ├── main.cpp
│   ├── markdown_renderer.cpp
│   ├── markdown_renderer.h
│   ├── multipart_reader.cpp
│   ├── multipart_reader.h
│   ├── non_secret_random.cpp
│   ├── non_secret_random.h
│   ├── public_id.cpp
│   ├── public_id.h
│   ├── series_service.cpp
│   ├── series_service.h
│   ├── url_builder.cpp
│   └── url_builder.h
├── static/
│   ├── card_placeholder.avif
│   ├── css/
│   │   └── styles.css
│   ├── js/
│   │   ├── card_form.js
│   │   ├── card_preview.js
│   │   ├── image_input.js
│   │   └── thumbnail.js
│   └── foil/
│       ├── foil_math.js
│       ├── frag-shader.glsl
│       ├── libwebgl.js
│       ├── model/card.obj
│       ├── obj.js
│       ├── spectral_xyz.bin
│       └── vert-shader.glsl
├── templates/
│   ├── card_form.html
│   ├── card_index.html
│   ├── card_view.html
│   ├── error.html
│   ├── layout.html
│   ├── series_form.html
│   └── series_index.html
└── tests/
    ├── asset_store_test.cpp
    ├── card_service_test.cpp
    ├── config_test.cpp
    ├── data_mock.h
    ├── data_test.cpp
    ├── fixtures/
    ├── app_test.cpp
    ├── html_renderer_test.cpp
    ├── image_processor_test.cpp
    ├── markdown_renderer_test.cpp
    ├── non_secret_random_test.cpp
    ├── public_id_test.cpp
    ├── series_service_test.cpp
    ├── url_builder_test.cpp
    └── web/
        ├── image_input_test.js
        └── thumbnail_test.js
```

All public C++ types and functions require intention comments. C++ and
JavaScript follow the naming and brace rules in `AGENTS.md`.

## Build and dependencies

### CMake policy

The root requires CMake 3.26 or newer and C++23. It enables
`CMAKE_CXX_EXTENSIONS OFF`, warnings, and tests through a
`CARD_COLLECTION_BUILD_TESTS` option.

All direct non-system C++ dependencies are declared with CMake
[`FetchContent`](https://cmake.org/cmake/help/latest/module/FetchContent.html)
and pinned to a tag or commit. Tracking a default branch would make identical
source checkouts produce different binaries. cpp-httplib is an intentional
transitive exception described below.

The initial pins should be:

| Dependency | Pin | Purpose |
| --- | --- | --- |
| libmw | `c0f2254` or later reviewed commit | HTTP, SQLite, errors, URLs |
| MacroDown | `31e42d5` or later reviewed commit | Markdown AST and rendering |
| toml++ | `v3.4.0` | TOML parsing |
| Inja | `v3.5.0` | HTML templates with automatic escaping |
| GoogleTest | `v1.17.0` | C++ tests |

ImageMagick is an explicit exception to the `FetchContent` rule. Require an
operator-installed ImageMagick 7 with Magick++, JPEG, PNG, WebP, and AVIF
delegates. CMake locates it with:

```cmake
find_package(ImageMagick 7 REQUIRED COMPONENTS Magick++)
target_link_libraries(card_collection PRIVATE ImageMagick::Magick++)
```

CMake 3.26 is required because that release provides the imported
`ImageMagick::Magick++` target. Configuration fails if Magick++ is absent.
Runtime startup additionally checks the required coder capabilities, because
finding the library does not prove that its installed delegates support every
required format.

Enable libmw's SQLite, HTTP-server, and URL components. Disable libmw tests in
this project's normal build. Do not enable the crypto component for the
prototype; none of its random values are authentication secrets.

### cpp-httplib resolution

libmw owns the cpp-httplib `FetchContent` declaration and intentionally omits
`GIT_TAG`, allowing a clean configure to resolve the upstream default branch.
Every cpp-httplib declaration reachable through the enabled libmw components,
including `http-server`, must omit the tag. Leaving a tag on any reachable
declaration would make CMake's first declaration silently restore that pin.

The application must not redeclare cpp-httplib merely to override libmw. It
builds against the revision selected by libmw and runs the HTTP and multipart
integration tests against that same revision. The resolved source commit must
be printed during configuration and recorded with build artifacts so a build
can be reproduced when diagnosing a regression. A clean configuration may
resolve a newer cpp-httplib commit; this variability is the accepted cost of
the deliberate unpinned dependency.

If a new upstream revision is incompatible, update libmw or the application
for the new API. Do not restore an old transitive pin as a compatibility fix.

Do not enable compressed request-body support for the prototype. Static
response compression may be provided by a reverse proxy.

### Non-secret randomness

Loose numbers, private temporary suffixes, render markers, and request
correlation IDs require collision resistance, not unpredictability. Use the
C++ standard random library through one process-owned helper:

```cpp
/// Thread-safe pseudorandom values that must never be used as secrets.
class NonSecretRandom
{
public:
    /// Seed a production generator from `std::random_device`.
    NonSecretRandom();

    /// Seed a deterministic generator for tests.
    explicit NonSecretRandom(std::uint64_t seed);

    /// Return one uniformly distributed 32-bit value.
    std::uint32_t next();

    /// Return `byte_count` pseudorandom bytes encoded as lowercase hex.
    std::string hex(std::size_t byte_count);

private:
    std::mutex mutex_;
    std::mt19937_64 engine_;
};
```

The production constructor fills a `std::seed_seq` from multiple
`std::random_device` results and uses it to initialize `std::mt19937_64`.
`next()` uses `std::uniform_int_distribution<std::uint32_t>` across the full
32-bit range. `hex()` draws complete bytes from the same locked engine. The
mutex covers every engine access because HTTP handlers run concurrently.

`main()` creates one `NonSecretRandom` and transfers it into `App`. Services,
`AssetStore`, and `HtmlRenderer` receive non-owning references whose lifetimes
are bounded by `App`. The `App` member declaration places the generator before
all consumers so reverse-order destruction is safe.

`AssetStore` uses `hex(16)` for staging, backup, and trash suffixes and retries
if atomic directory creation reports an existing path. `HtmlRenderer` uses
`hex(16)` for each raw-HTML marker and performs its documented collision scan.
`App` uses `hex(16)` for request correlation IDs. `CardService` uses `next()`
for loose-card candidates and relies on SQLite uniqueness plus retry.

This generator is deliberately not cryptographically secure. Future account
sessions, reset tokens, API keys, or other secrets must enable and use
`mw::Crypto`; they must never reuse `NonSecretRandom`.

### Template library

Use [Inja](https://github.com/pantor/inja) with
`Environment::set_html_autoescape(true)`. Pass ordinary text to templates as
raw JSON strings and let Inja escape it. Do not pre-escape names, IDs, errors,
or form values, because that would produce double escaping.

Inja does not provide a safe-string value type for selective raw HTML. The
`HtmlRenderer` therefore registers each trusted `RenderedHtml` value under a
fresh, opaque marker and supplies the marker as an ordinary template value.
After Inja renders the page, `HtmlRenderer` replaces the expected markers with
their trusted fragments. Markers contain only ASCII letters, digits, and
underscores, so autoescaping leaves them intact. They use 16 random bytes from
`NonSecretRandom::hex()` and must not occur in any template, ordinary input
value, or trusted fragment. Replacement is a single pass, so one fragment
cannot expose a marker for another fragment. Every marker must be replaced
exactly as many times as its template field is expected to render. A failed
invariant is an internal rendering error. Only the typed `RenderedHtml`
registration function may create such a substitution.

Templates are source assets, not user data. Load and parse the complete
template set at startup and fail startup with the filename and Inja parse
error if any template is invalid. Use Inja inheritance and blocks with
`layout.html` as the common page layout. After startup, the environment and
parsed templates are immutable.

## Configuration

### Example

```toml
base_url = "http://127.0.0.1:8080/"
listen_address = "127.0.0.1"
listen_port = 8080
static_root = "static"
database_path = "var/card_collection.sqlite3"
card_storage_root = "var/cards"
avif_quality = 75
thumbnail_long_side = 256
```

A Unix-domain-socket listener uses an explicit `unix:` prefix:

```toml
base_url = "https://cards.example.test/collection/"
listen_address = "unix:/run/card-collection/http.sock"
static_root = "/srv/card-collection/static"
database_path = "/srv/card-collection/data/cards.sqlite3"
card_storage_root = "/srv/card-collection/data/cards"
avif_quality = 75
thumbnail_long_side = 256
```

`listen_port` is ignored when `listen_address` begins with `unix:`. The prefix
avoids guessing whether a relative string is an IP address, hostname, or path.

### `Config` representation

`Config` contains:

```cpp
/// Validated process configuration.
struct Config
{
    mw::URL base_url;
    mw::HTTPServer::ListenAddress listen_address;
    std::filesystem::path static_root;
    std::filesystem::path database_path;
    std::filesystem::path card_storage_root;
    int avif_quality;
    std::uint32_t thumbnail_long_side;
};
```

`loadConfig()` performs these steps:

1. Parse the named file with toml++.
2. Reject missing and unknown keys. Rejecting unknown keys catches operator
   spelling mistakes instead of silently using an unintended default.
3. Parse `base_url` with `mw::URL::fromStr()`.
4. Require an HTTP or HTTPS scheme and a nonempty host.
5. Normalize its path to exactly one trailing slash.
6. Parse a `unix:` address into `mw::SocketFileInfo`; otherwise require a port
   from 1 through 65535 and create `mw::IPSocketInfo`.
7. Convert path settings to absolute, lexically normalized paths. Relative
   paths are resolved against the configuration file's parent directory, not
   the process working directory.
8. Require `avif_quality` from 0 through 100.
9. Require a positive `thumbnail_long_side`.
10. Require the static root to exist and be a directory.
11. Create the database parent and card-storage root if absent.
12. Reject equal or nested static and card-storage roots in either direction.
    Also reject a database path inside either mounted-data root. These checks
    prevent the page-asset mount from exposing private staging data and keep
    the two static mounts backed by disjoint directory trees.

Configuration errors are printed to standard error and cause a nonzero exit
before the listening socket is opened.

### Base-URL helper

`UrlBuilder` is the only component that performs URL construction. It stores
the normalized `Config::base_url`, percent-encodes each dynamic path segment,
and can return either an absolute URL or the request path used by the HTTP
server. Ordinary dynamic route arguments are always individual segments, so a
slash inside a card ID is encoded rather than treated as structure. Static
mounts use a separate, explicitly validated relative-path operation.

```cpp
/// Kind of one path segment supplied to `UrlBuilder`.
enum class RouteSegmentKind
{
    LITERAL,
    DYNAMIC,
    PLACEHOLDER
};

/// One trusted literal, encoded value, or route-registration placeholder.
struct RouteSegment
{
    RouteSegmentKind kind;
    std::string value;
};

/// Ordered query parameters encoded by `UrlBuilder`.
using QueryParameters =
    std::vector<std::pair<std::string, std::string>>;

/// Build base-aware URLs from trusted literal and encoded dynamic segments.
class UrlBuilder
{
public:
    /// Construct a builder from a validated absolute HTTP or HTTPS base URL.
    explicit UrlBuilder(mw::URL base_url);

    /// Return an absolute URL for the supplied route segments.
    std::string absolute(
        const std::vector<RouteSegment>& segments,
        const QueryParameters& query = {}) const;

    /// Append one validated relative path beneath a static-mount prefix.
    std::string absoluteFromRelativePath(
        const std::vector<RouteSegment>& mount_prefix,
        const std::string& relative_path,
        const QueryParameters& query = {}) const;

    /// Return only the base-prefixed request path for route registration.
    std::string requestPath(
        const std::vector<RouteSegment>& segments) const;
};
```

`RouteSegment` distinguishes a trusted literal segment from a dynamic value.
Literal segments are compile-time application route text such as `cards`.
Dynamic values are percent-encoded as one segment. Route placeholders such as
`:id` are a third explicit kind used only by `requestPath()` during route
registration; they are never accepted by `absolute()`.

Dynamic segments must be nonempty and must not equal `.` or `..`. The encoder
always escapes `/`, `\`, `%`, `?`, and `#`, even if an underlying URL helper
would otherwise treat one as structural syntax. Placeholder names must match
`[a-z_][a-z0-9_]*`. These checks occur inside `UrlBuilder`, not at each call
site.

`absoluteFromRelativePath()` accepts exactly one logical path argument. It
requires a nonempty relative path without a leading slash or backslash, splits
it on `/`, rejects empty, `.`, and `..` components, and percent-encodes every
remaining component independently. Thus `foil/model/card.obj` retains its
intended separators, while `foil//card.obj` and `../card.obj` are rejected.

`QueryParameters` is an ordered vector of key/value pairs. Both absolute URL
operations percent-encode each key and value and preserve order, which makes
generated URLs and tests deterministic. `requestPath()` never includes a query
string.

`App` owns the application-specific named route table. Its public `urlFor()`
resolves a name and dispatches by route kind. Dynamic routes delegate their
individual arguments to `UrlBuilder::absolute()`. Static mounts require one
argument and delegate it to `absoluteFromRelativePath()`. The private
`getPath()` resolves a dynamic route definition with named placeholders and
delegates to `UrlBuilder::requestPath()`. It does not build an absolute URL and
parse it back into a path. Its private `getMountPath()` returns the literal
prefix of a static-mount route without its dynamic tail.

If the base URL is `https://example.test/collection/`, the card index request
path is `/collection/`, not `/`. Route registration therefore prefixes every
application and static route with the normalized base path.

### `App` interface

Follow the structure used by `shrt`: `App` derives from `mw::HTTPServer` and
contains all HTTP handlers. It owns the injected data source and the concrete
application services, while the services remain independently testable.

```cpp
/// Card Collection HTTP application and named-route owner.
class App : public mw::HTTPServer
{
public:
    using Request = mw::HTTPServer::Request;
    using Response = mw::HTTPServer::Response;

    App() = delete;

    /// Construct the application from validated configuration and services.
    App(const Config& config,
        std::unique_ptr<DataSourceInterface> data_source,
        std::unique_ptr<GameRegistry> games,
        std::unique_ptr<NonSecretRandom> random,
        std::unique_ptr<AssetStore> assets);

    /// Return the absolute URL for a named application route.
    std::string urlFor(
        const std::string& name,
        const std::vector<std::string>& arguments = {},
        const QueryParameters& query = {}) const;

    /// Render the card index.
    void handleCardIndex(const Request& request, Response& response);

    /// Render the new-card form.
    void handleNewCard(const Request& request, Response& response);

    // The complete interface declares one handle*() method for every dynamic
    // application route in the HTTP route table below.

private:
    /// Register all application and static routes exactly once.
    void setup() override;

    /// Return the server request path for a named route.
    std::string getPath(
        const std::string& name,
        const std::vector<std::string>& argument_names = {}) const;

    /// Return the request prefix for a named static mount.
    std::string getMountPath(const std::string& name) const;
};
```

The actual header lists every handler rather than relying on the abbreviated
comment above. `App::setup()` binds each handler to `server` using `getPath()`
and installs the two static mounts using `getMountPath()`. Calling `getPath()`
for a mount or `getMountPath()` for a dynamic route is a programming error.
Handlers may delegate validation and use cases to services, but no handler is
defined outside `App`.

During construction, `App` registers one Inja callback named `url_for`. Its
first argument is a route name. A dynamic handler route accepts its declared
dynamic arguments as individual path segments. A static-mount route accepts
exactly one additional string containing the relative path beneath that mount.
Templates receive no lower-level URL-building callback. An unknown name,
nonstring argument, or incorrect argument count is a source/template
programming error; the renderer logs it and returns the standard 500 page.

The template callback does not accept query parameters. `App` precomputes
versioned card-asset URLs with a `v` query value equal to the decimal card
revision and places them in template data. Templates therefore never
concatenate `?v=` manually.

## Process startup and shutdown

`main()` follows this order:

1. Parse the command line. The only required option is the configuration
   path; `--help` prints usage and exits.
2. Load and validate `Config`.
3. Configure spdlog.
4. Call `Magick::InitializeMagick(argv[0])` exactly once.
5. Configure fixed ImageMagick resource limits and verify coder capabilities.
6. Construct the immutable `GameRegistry`.
7. Open SQLite through `DataSourceSQLite::fromFile()`.
8. Call `DataSourceInterface::migrateToLatest()` with the game registry.
9. Reconcile compiled games through the data-source interface.
10. Verify that every stored non-null game short name is registered. Fail
   startup if a compiled game was removed without a migration.
11. Construct `NonSecretRandom` and `AssetStore`, then delete abandoned private
    entries. No request can still own them because the server has not started.
12. Construct `App`, transferring the data source, game registry, and asset
    store and random generator through `unique_ptr` ownership. Its constructor
    configures the Inja callback, loads templates, and fails construction on a
    parse error.
13. Call `App::start()`. The inherited start path invokes `App::setup()` before
    libmw begins listening.
14. Wait for SIGINT or SIGTERM, stop accepting requests, and join the server.

The server must not begin listening if ImageMagick capability checks,
migration, game validation, template loading, or storage initialization fail.

## Domain types

### Card identity

```cpp
/// Persisted components from which a public card ID is derived.
struct CardIdentity
{
    std::optional<std::string> game_short_name;
    std::uint64_t card_number;
};
```

Although a loose number is at most `UINT32_MAX`, `uint64_t` is used in C++ so
the same field can represent future positive game sequences without signed
conversion mistakes. SQLite stores it as an `INTEGER`; accepted values remain
within SQLite's signed 64-bit range.

### Card record

```cpp
/// Common persisted and asset metadata for one card.
struct Card
{
    std::int64_t id;
    CardIdentity identity;
    std::string name;
    std::optional<std::string> short_description;
    std::optional<std::string> long_description;
    std::int64_t rarity;
    std::string front_extension;
    std::optional<std::string> foil_extension;
    std::string thumbnail_extension;
    std::int64_t revision;
};
```

`revision` is an implementation field used to reject one browser overwriting a
newer edit. It also provides a cache-busting query value for fixed asset
filenames. It starts at one and increments after each successful edit.

```cpp
/// Logical card image used consistently by validation, storage, and URLs.
enum class CardAssetType
{
    FRONT_ART,
    FOIL_CONTROL,
    THUMBNAIL
};
```

This is the only server-side enum for card image roles. `ImageProcessor`,
`AssetStore`, and card-asset URL helpers all use it, preventing separate enums
from assigning different format or filename rules to the same logical asset.

### Series record

```cpp
/// Dynamic series metadata belonging to one compiled game.
struct Series
{
    std::int64_t id;
    std::string game_short_name;
    std::string name;
    std::string description;
};
```

### Mutation inputs

The service layer receives typed structures. File fields refer to paths in a
request-owned temporary directory, never browser-supplied filenames.

```cpp
/// Validated common text and grouping values used to create a card.
struct CreateCardInput
{
    std::optional<std::string> game_short_name;
    std::string name;
    std::optional<std::string> short_description;
    std::optional<std::string> long_description;
    std::int64_t rarity;
    std::vector<std::int64_t> series_ids;
    FormFields game_fields;
    IncomingImage front;
    std::optional<IncomingImage> foil;
    std::optional<IncomingImage> thumbnail;
};

/// Validated edit values and requested asset changes for one card.
struct UpdateCardInput
{
    std::int64_t expected_revision;
    std::string name;
    std::optional<std::string> short_description;
    std::optional<std::string> long_description;
    std::int64_t rarity;
    std::vector<std::int64_t> series_ids;
    FormFields game_fields;
    ImageChange front;
    ImageChange foil;
    std::optional<IncomingImage> thumbnail;
};
```

`ImageChange` is `KEEP`, `REPLACE`, or `REMOVE` plus an optional staged file.
Artwork never accepts `REMOVE`. Foil does. The server derives whether a render
input changed from these actions; it does not trust a client Boolean.

## Compiled game interface

### Registry

`GameRegistry` owns `unique_ptr<GameDefinition>` objects and becomes immutable
before the server starts. Registration validates that short names are nonempty,
match `[a-z0-9]+`, and are unique. Lookup returns a non-owning const pointer
whose lifetime is the process lifetime.

Production may initially register no games, in which case loose cards work but
series creation has no valid game. Tests register a `TestGame` with short name
`test` and an extension table. A real shipped game is product content and must
not be invented by the framework.

### `GameDefinition`

Each game implements this public interface:

```cpp
/// Compiled behavior and metadata schema for one game.
class GameDefinition
{
public:
    virtual ~GameDefinition() = default;

    /// Return the immutable lowercase public short name.
    virtual std::string_view shortName() const = 0;

    /// Return the human-readable game name.
    virtual std::string_view displayName() const = 0;

    /// Return the Markdown game description.
    virtual std::string_view description() const = 0;

    /// Add this game's tables and indexes to a new schema.
    virtual mw::E<void> createSchema(mw::SQLite& database) const = 0;

    /// Validate posted fields and return game-owned typed metadata.
    virtual mw::E<std::unique_ptr<GameCardMetadata>>
    validateMetadata(const FormFields& fields) const = 0;

    /// Insert validated extension data for a new common card ID.
    virtual mw::E<void> insertMetadata(
        mw::SQLite& database, std::int64_t card_id,
        const GameCardMetadata& metadata) const = 0;

    /// Replace extension data for an existing common card ID.
    virtual mw::E<void> updateMetadata(
        mw::SQLite& database, std::int64_t card_id,
        const GameCardMetadata& metadata) const = 0;

    /// Return raw JSON template data for the create or edit form.
    virtual TemplateData formData(
        const std::optional<GameCardMetadataView>& current) const = 0;

    /// Return label/value rows for the read-only card page.
    virtual mw::E<std::vector<DisplayField>> displayFields(
        mw::SQLite& database, std::int64_t card_id) const = 0;
};
```

`GameCardMetadata` is a polymorphic base class owned with `unique_ptr`. A game
must verify the dynamic type it receives before accessing it and return an
internal error for a mismatch.

The generic card form renders a game-provided Inja partial. The partial is
compiled into the binary or shipped with the application source, not read
from the database. Its ordinary values remain raw strings for Inja to escape.
Field names use `game.<name>` so they cannot collide with common fields.

Game tables use a one-to-one foreign key:

```sql
CREATE TABLE test_cards (
    card_id INTEGER PRIMARY KEY
        REFERENCES cards(id) ON DELETE CASCADE,
    hp INTEGER NOT NULL CHECK(hp >= 0),
    attack INTEGER NOT NULL CHECK(attack >= 0)
);
```

The test game is a test fixture only. It proves the extension mechanism without
shipping fictional product content.

## SQLite design

### Connection policy

`DataSourceInterface` is the only persistence dependency visible to `App` and
the services. `DataSourceSQLite` implements it with one `mw::SQLite`
connection and one process-wide mutex. This is intentionally simple for the
prototype and prevents another request from interleaving statements between
`BEGIN` and `COMMIT` on the same connection.

```cpp
/// Latest schema version implemented by this binary.
inline constexpr std::int64_t DB_SCHEMA_VERSION = 1;

/// Storage transaction used for an atomic group of persistence operations.
class DataSourceTransactionInterface
{
public:
    virtual ~DataSourceTransactionInterface() = default;

    /// Allocate and persist the next never-reused number for one game.
    virtual mw::E<std::uint64_t>
    allocateGameNumber(const std::string& game_short_name) = 0;

    /// Ensure a registered game has a persistent sequence row.
    virtual mw::E<void>
    ensureGameSequence(const std::string& game_short_name) = 0;

    /// Return whether a loose-card number already exists.
    virtual mw::E<bool> looseNumberExists(std::uint32_t number) = 0;

    /// Re-read a card while the transaction lock is held.
    virtual mw::E<std::optional<Card>>
    getCardForUpdate(std::int64_t card_id) = 0;

    /// Insert common, game-specific, and series-membership card rows.
    virtual mw::E<std::int64_t> insertCard(
        const Card& card, const GameDefinition* game,
        const GameCardMetadata* metadata,
        const std::vector<std::int64_t>& series_ids) = 0;

    /// Replace a card's common, game-specific, and membership rows.
    virtual mw::E<void> updateCard(
        const Card& card, const GameDefinition* game,
        const GameCardMetadata* metadata,
        const std::vector<std::int64_t>& series_ids) = 0;

    /// Delete a card and its dependent database rows.
    virtual mw::E<void> deleteCard(std::int64_t card_id) = 0;

    /// Insert a series and return its internal ID.
    virtual mw::E<std::int64_t> insertSeries(const Series& series) = 0;

    /// Replace a series name and description without changing its game.
    virtual mw::E<void> updateSeries(const Series& series) = 0;

    /// Delete a series and its membership rows.
    virtual mw::E<void> deleteSeries(std::int64_t series_id) = 0;

    /// Commit the transaction and release its lock.
    virtual mw::E<void> commit() = 0;
};

/// Common persistence API used by the application and services.
class DataSourceInterface
{
public:
    virtual ~DataSourceInterface() = default;

    /// Return the stored schema version.
    virtual mw::E<std::int64_t> getSchemaVersion() const = 0;

    /// Apply every required migration in order through the current version.
    mw::E<void> migrateToLatest(const GameRegistry& games);

    /// Create schema version 1 from an empty version-0 database.
    virtual mw::E<void>
    migrateSchema0To1(const GameRegistry& games) = 0;

    /// Start an immediate transaction with exclusive mutation ownership.
    virtual mw::E<std::unique_ptr<DataSourceTransactionInterface>>
    beginTransaction() = 0;

    /// Return all cards for the unpaginated index.
    virtual mw::E<std::vector<Card>> getCards() const = 0;

    /// Return a card by its parsed identity.
    virtual mw::E<std::optional<Card>>
    getCard(const CardIdentity& identity) const = 0;

    /// Return a card's game-owned display fields.
    virtual mw::E<std::vector<DisplayField>> getGameDisplayFields(
        const GameDefinition& game, std::int64_t card_id) const = 0;

    /// Return all series, ordered by game and name.
    virtual mw::E<std::vector<Series>> getSeries() const = 0;

    /// Return one series by internal ID.
    virtual mw::E<std::optional<Series>>
    getSeries(std::int64_t series_id) const = 0;

    /// Return the series memberships for one card.
    virtual mw::E<std::vector<std::int64_t>>
    getCardSeries(std::int64_t card_id) const = 0;

    /// Return every persisted game name used for startup reconciliation.
    virtual mw::E<std::vector<std::string>> getPersistedGameNames() const = 0;

protected:
    /// Set the schema version inside a concrete migration transaction.
    virtual mw::E<void> setSchemaVersion(std::int64_t version) = 0;
};
```

The real header includes intention comments on every item. It may add focused
read methods needed by forms, such as series filtered by game, but it must not
expose raw SQL, prepared statements, or `mw::SQLite` to services.

For a loose card, `insertCard()` and `updateCard()` receive null game and
metadata pointers. For a game card, both pointers are non-null and the game
short name must match the card identity. The transaction implementation
validates these pairings before invoking a game persistence hook. On insert,
the input card's internal ID must be zero; a nonzero value is an internal
error. The returned integer is the newly assigned SQLite primary key.

`DataSourceSQLite::fromFile()` opens the connection and enables its pragmas;
it does not migrate implicitly. `main()` calls the common
`migrateToLatest()` coordinator before moving the interface into `App`.
`DataSourceMock` and `DataSourceTransactionMock` implement the two interfaces
for service and handler tests.

Code outside `DataSourceSQLite`, its transaction implementation, and compiled
game persistence hooks must not issue SQL. The SQLite implementation calls
the game hooks for extension schemas and rows; this exception is necessary
because compiled games define their own strongly typed SQLite tables.

On every connection, enable:

```sql
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
```

SQLite requires foreign-key enforcement to be enabled per connection; see the
[official foreign-key documentation](https://www.sqlite.org/foreignkeys.html).
WAL allows readers and the serialized writer to coexist; see
[SQLite WAL](https://www.sqlite.org/wal.html).

### Transaction wrapper

`DataSourceSQLiteTransaction` is the concrete, noncopyable implementation of
`DataSourceTransactionInterface`. `beginTransaction()` acquires the data
source mutex, executes `BEGIN IMMEDIATE`, and returns it through `unique_ptr`.
`commit()` executes `COMMIT`, marks the object complete, and releases the lock.
Destruction executes `ROLLBACK` if commit was not called and then releases the
lock.

`BEGIN IMMEDIATE` obtains the write reservation before the service changes the
filesystem, so another process cannot win a conflicting write halfway through
publication. SQLite documents the transaction modes in
[`BEGIN TRANSACTION`](https://www.sqlite.org/lang_transaction.html).

The transaction performs its SQL on the owning data source's connection
without attempting to lock the mutex again. It cannot outlive its data source;
`App` declares the data-source member before services so destruction occurs in
the safe reverse order. The wrapper must not throw from its destructor. A
rollback error is logged. Explicit operations still return `mw::E<T>`.

### Schema version 1

```sql
CREATE TABLE cards (
    id INTEGER PRIMARY KEY,
    game_short_name TEXT,
    card_number INTEGER NOT NULL,
    name TEXT NOT NULL,
    short_description TEXT,
    long_description TEXT,
    rarity INTEGER NOT NULL DEFAULT 0 CHECK(rarity >= 0),
    front_extension TEXT NOT NULL
        CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
    foil_extension TEXT
        CHECK(foil_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
    thumbnail_extension TEXT NOT NULL
        CHECK(thumbnail_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
    revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
    CHECK(
        (game_short_name IS NULL
         AND card_number >= 0
         AND card_number <= 4294967295)
        OR
        (game_short_name IS NOT NULL AND card_number >= 1)
    )
);

CREATE UNIQUE INDEX cards_game_number_unique
ON cards(game_short_name, card_number)
WHERE game_short_name IS NOT NULL;

CREATE UNIQUE INDEX cards_loose_number_unique
ON cards(card_number)
WHERE game_short_name IS NULL;

CREATE TABLE game_sequences (
    game_short_name TEXT PRIMARY KEY,
    last_number INTEGER NOT NULL CHECK(last_number >= 0)
);

CREATE TABLE series (
    id INTEGER PRIMARY KEY,
    game_short_name TEXT NOT NULL,
    name TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    UNIQUE(game_short_name, name)
);

CREATE TABLE card_series (
    card_id INTEGER NOT NULL
        REFERENCES cards(id) ON DELETE CASCADE,
    series_id INTEGER NOT NULL
        REFERENCES series(id) ON DELETE CASCADE,
    PRIMARY KEY(card_id, series_id)
);
```

PNG is absent from stored extensions because every accepted PNG is normalized
to AVIF before publication. Front artwork, foil-control textures, and
thumbnails accept the same stored formats. WebGL treats a missing alpha
channel in a foil-control texture as fully opaque. The server canonicalizes
`.jpeg` and `.jpg` input to one chosen extension, preferably `jpg`, before
insertion.

SQLite partial unique indexes enforce the two identity namespaces without
making null game values conflict. See
[partial indexes](https://www.sqlite.org/partialindex.html).

### Same-game series trigger

Application validation gives useful errors, while a trigger protects the
invariant if a future code path bypasses the service:

```sql
CREATE TRIGGER card_series_same_game_insert
BEFORE INSERT ON card_series
BEGIN
    SELECT CASE
        WHEN (SELECT game_short_name
              FROM cards
              WHERE id = NEW.card_id) IS NULL
        THEN RAISE(ABORT, 'loose card cannot belong to a series')
        WHEN (SELECT game_short_name
              FROM cards
              WHERE id = NEW.card_id)
             !=
             (SELECT game_short_name
              FROM series
              WHERE id = NEW.series_id)
        THEN RAISE(ABORT, 'card and series games differ')
    END;
END;
```

An equivalent update trigger is unnecessary because `card_series` key values
are never updated; rows are deleted and inserted. A card's game is immutable.
A series may not move between games after creation for the same reason. The
edit-series form therefore displays its game read-only.

### Migration algorithm

`DataSourceInterface::migrateToLatest()` is a nonvirtual coordinator shared by
the SQLite implementation and mocks. At startup it:

1. Calls `getSchemaVersion()`.
2. Rejects a version greater than the binary's supported version.
3. For zero, calls `migrateSchema0To1(games)`.
4. For each older supported version, calls exactly one virtual function such as
   `migrateSchema1To2()`.
5. Re-read the schema version after each step and require it to equal the
   migration's declared target.
6. Stop startup immediately if any migration fails.

`DataSourceSQLite::migrateSchema0To1()` begins an immediate transaction,
creates the common schema, calls every registered game's `createSchema()`,
sets `user_version = 1`, and commits. Every later SQLite migration follows the
same pattern and updates the version in the same transaction as its schema
changes. Do not skip versions. A migration function is valid only from its
declared source version; automatic retry occurs through rollback on the next
process start.

### Compiled-game reconciliation

After migration, run:

```sql
INSERT OR IGNORE INTO game_sequences(game_short_name, last_number)
VALUES(?, 0);
```

once for every registered game through a transaction object, then commit that
transaction. Next call `getPersistedGameNames()`. If any returned name is
absent from the registry, fail startup with an error requiring a migration.
Silently treating such cards as loose would change their public IDs and is
forbidden.

## Public ID algorithms

### Game card allocation

Inside the creation transaction:

```sql
UPDATE game_sequences
SET last_number = last_number + 1
WHERE game_short_name = ?
RETURNING last_number;
```

The update must return exactly one row. Zero rows means the compiled game was
not reconciled and is an internal error. SQLite's `RETURNING` behavior is
documented in the
[official reference](https://www.sqlite.org/lang_returning.html).

The transaction inserts the card using the returned number. Deleting a card
does not decrement `last_number`, so deleted numbers are never reused. A
rolled-back creation may reuse a number because no card ever committed with
that number; this does not violate the deletion rule.

### Loose card allocation

`CardService` performs these steps while holding its data transaction:

1. Call `NonSecretRandom::next()` to obtain a uniformly distributed 32-bit
   integer.
2. Attempt to insert the card with `game_short_name = NULL` and that number.
3. If the loose-number unique index reports a conflict, generate another
   value and retry.
4. Stop after 128 collisions and return an internal error. Reaching that bound
   indicates a broken random source or an unexpectedly saturated namespace.

The database unique index is the correctness guarantee; pseudorandomness only
spreads candidates through the 32-bit namespace. Loose IDs are public
identifiers, not secrets, so a cryptographic generator adds no product value.

### Encoding and parsing

Do not implement a custom `encodeBase36()` function. The integer overloads of
[`std::to_chars`](https://en.cppreference.com/w/cpp/utility/to_chars) and
[`std::from_chars`](https://en.cppreference.com/w/cpp/utility/from_chars)
accept an explicit base from 2 through 36 and are locale-independent.

For a loose card, `formatPublicId()` allocates a stack
`std::array<char, std::numeric_limits<std::uint32_t>::digits>`, calls
`std::to_chars()` with base 36, checks the returned `std::errc`, and constructs
the result from the written range. The standard representation uses lowercase
digits `0-9a-z` and has no leading zeroes except for zero itself. Game-card
decimal numbers use the same function with base 10. `formatPublicId()` returns:

- `<shortname>-<decimal-number>` for a game card; or
- `<base36-number>` for a loose card.

For a loose ID, `parsePublicId()` first applies the canonical lowercase syntax
checks, then calls `std::from_chars()` into `std::uint32_t` with base 36. It
requires an empty error code and requires the returned pointer to equal the end
of the input. It formats the parsed value again and requires exact equality
with the input, which rejects noncanonical leading zeroes. Game-card numbers
use `std::from_chars()` with base 10 into `std::uint64_t` followed by the same
complete-consumption and canonical-form checks.

`parsePublicId()` rejects:

- empty strings;
- uppercase input in canonical routes;
- characters outside lowercase ASCII letters, digits, and one optional
  hyphen;
- multiple hyphens;
- a missing game or numeric part;
- decimal zero for a game card;
- leading zeroes other than the value zero for a loose card;
- unknown game short names;
- decimal overflow beyond signed SQLite integer range; and
- base36 overflow beyond `UINT32_MAX`.

The read route may detect uppercase input and respond with `308 Permanent
Redirect` to the lowercase canonical URL. Mutation routes require the exact
canonical form and return 404 otherwise.

### Natural sorting

The prototype has no pagination or scale limit, so the data source loads the
index rows and sorts them in C++. The comparator operates on the derived
lowercase public IDs:

1. Split each string into alternating digit and nondigit runs.
2. Compare nondigit runs bytewise.
3. Compare digit runs as integers without converting to a fixed-width type:
   remove leading zeroes, compare digit counts, then compare bytes.
4. If numeric values are equal, the shorter original run sorts first.
5. Continue with the next run.
6. Use the internal card ID only as a deterministic final tie breaker.

Descending order reverses the final comparator. This produces `pkm-2` before
`pkm-10` without requiring a custom SQLite collation.

## Image processing

### Trust boundary

Browser checks improve feedback but do not establish validity. The server
ignores filename extensions and multipart content types when deciding image
format. A minimal application-owned signature sniffer accepts only JPEG, PNG,
WebP, and AVIF. Magick++ then performs the authoritative parse and full decode.

No decoder receives a path outside the request staging directory. No
browser-supplied filename becomes part of a filesystem path. The application
selects an explicit `JPEG:`, `PNG:`, `WEBP:`, or `AVIF:` coder from the sniffed
format before giving the server-owned staging path to Magick++. This prevents
the filename or upload contents from selecting a pseudo-format or unrelated
ImageMagick coder.

### System capability and resource checks

The operator must install ImageMagick 7, its Magick++ development files, and
JPEG, PNG, WebP, and AVIF delegates. The installation can be inspected with:

```sh
magick identify -list format
```

At startup, after `Magick::InitializeMagick()`, construct
`Magick::CoderInfo` for `JPEG`, `PNG`, `WEBP`, and `AVIF`. All four must be
readable, and AVIF must also be writable for PNG conversion. Failure names the
missing coder or capability and exits before the server listens.

Before request threads start, set ImageMagick's process-global width and
height resource limits to 2048 pixels and its image-list limit to two. The
second list slot permits bounded detection of a multi-frame input. The
application never changes global ImageMagick limits after startup and never
weakens the operator's ImageMagick security policy. Request byte limits and
the installed policy continue to bound memory, map, disk, and time resources.
Because these settings are process-global, initialization and capability
checks happen exactly once in `main()`.

### `ImageProcessor` interface

```cpp
/// Validated image information and normalized staged path.
struct ProcessedImage
{
    std::filesystem::path path;
    std::string extension;
    std::uint32_t width;
    std::uint32_t height;
    bool has_alpha;
};

/// Validate and normalize uploaded images before publication.
class ImageProcessor
{
public:
    /// Process one staged upload according to its intended role.
    mw::E<ProcessedImage> process(
        const std::filesystem::path& input, CardAssetType type) const;

    /// Generate a plain-card thumbnail from normalized front artwork.
    mw::E<ProcessedImage> generatePlainThumbnail(
        const ProcessedImage& artwork,
        const std::filesystem::path& output) const;
};
```

### Validation sequence

For every upload:

1. Open the staged file without following a browser filename.
2. Read enough bytes to identify JPEG, PNG, WebP, or AVIF by signature.
3. Reject a format not allowed for the requested role.
4. Prefix the server-owned path with the selected explicit Magick++ coder and
   call `Magick::readImages()` under the configured resource limits.
5. Catch `Magick::Exception` at the `ImageProcessor` boundary. Log its detail
   and return an `mw::E` with a safe invalid-image or processing error.
6. Require exactly one decoded image. Reject animation and image sequences.
7. Require the decoded `magick()` value to agree with the sniffed format.
8. Read `columns()`, `rows()`, and alpha state from the decoded image. Reject
   zero dimensions or a long side greater than 2048.
9. For PNG, transform the decoded image to sRGB and encode a single still AVIF
   using configured quality. Preserve its alpha channel.
10. For JPEG, WebP, and AVIF, retain the original bytes after the successful
    full decode.
11. Rename the normalized staged file to its canonical role filename.

Full validation is necessary because retaining original bytes after reading
only a header would let malformed files reach browsers and reverse proxies.

### Format-specific rules

- JPEG is valid for artwork, foil controls, and thumbnails.
- PNG is decoded by Magick++ and always converted to AVIF. An APNG produces
  more than one image and is rejected.
- WebP is fully decoded by Magick++. Animated WebP produces more than one
  image and is rejected.
- AVIF is fully decoded by Magick++. An AVIF sequence produces more than one
  image and is rejected.

Decoded allocation sizes must be checked before multiplication. With a
2048-pixel maximum on each dimension, an RGBA8 buffer is at most 16 MiB, but
the code still uses checked multiplication rather than relying on that
observation.

### AVIF conversion

PNG conversion operates on the single decoded `Magick::Image`. Transform it
to the sRGB color space, set its output format to `AVIF`, apply
`Config::avif_quality`, and preserve the source alpha channel when present.
Write exactly one image to a separate staged `.avif` path, flush and close it,
then replace the staged PNG. Never overwrite the input while Magick++ may
still be reading it. Do not invoke the `magick` command-line program.

Conversion failure returns an internal processing error and deletes both
temporary files. It never falls back to storing the PNG because that would
contradict the storage contract.

### Backend plain-thumbnail generation

When the resulting card has no foil, `ImageProcessor` generates the thumbnail
from the normalized front artwork. It does not accept a client thumbnail. The
target height is `Config::thumbnail_long_side`; the target width is the
nearest integer to `height * 5 / 7`, with a minimum of one pixel. Thus the
default target is 183 by 256 pixels. This is the closest integer-pixel 5:7
frame for the configured long side.

Fully decode the normalized artwork with its explicit coder, transform it to
sRGB, and resize it to the exact target width and height without preserving
the source aspect ratio. This deliberate stretch matches the 5:7 card frame
used by the renderer. Encode the result as one AVIF image using
`Config::avif_quality`, preserve alpha when present, and write it to a
separate staged `thumb.avif` path. A processing or write failure removes the
partial output and fails the whole card operation.

## Multipart request handling

### Streaming receiver

Use cpp-httplib's multipart `ContentReader` path so binary fields are streamed
to request staging files rather than copied into `Request::body`. The official
[cpp-httplib documentation](https://github.com/yhirose/cpp-httplib) describes
the blocking multipart content receiver.

`MultipartReader` tracks the current part and enforces:

- only expected field names;
- exactly one value for singleton fields;
- text accumulation only for text fields;
- binary streaming only for image fields;
- no use of the supplied filename as a path; and
- cleanup of every opened path from its destructor unless ownership has moved
  to the service.

The receiver accepts only `multipart/form-data` for card mutations. Series
forms use `application/x-www-form-urlencoded` because they contain no files.

### Create-card form contract

`POST /cards` contains:

| Field | Type | Rule |
| --- | --- | --- |
| `game` | text | Empty for loose, otherwise a registered short name |
| `name` | text | Required after trimming |
| `short_description` | text | Empty becomes null |
| `long_description` | text | Empty becomes null |
| `rarity` | text integer | Required, nonnegative |
| `series_id` | repeated text integer | Each must exist in selected game |
| `game.*` | game-specific text/file | Validated by the compiled game |
| `front` | binary | Required artwork |
| `foil` | binary | Optional foil control |
| `thumbnail` | binary | Required with foil; rejected without foil |

The server ignores any attempted public ID or card number field. Allocation is
server-owned.

### Edit-card form contract

`POST /cards/<id>` contains the common metadata fields plus:

| Field | Type | Rule |
| --- | --- | --- |
| `revision` | text integer | Must equal current database revision |
| `front_action` | text | `keep` or `replace` |
| `foil_action` | text | `keep`, `replace`, or `remove` |
| `front` | binary | Required only for front replacement |
| `foil` | binary | Required only for foil replacement |
| `thumbnail` | binary | Required for a changed resulting foil card only |

The game and card number are displayed but are not editable or accepted as
mutation fields. A stale revision returns 409 and leaves both database and
assets unchanged.

For an edit that changes a rendering input, the thumbnail rule is based on the
resulting card. If it has foil, the browser must send a thumbnail. If it has no
foil, the browser must not send one because the backend derives it from the
resulting front artwork. A metadata-only edit accepts no thumbnail.

## Browser card component

### Copied `foil` snapshot

Copy the reviewed `foil` snapshot at commit `d014dd4` into `static/foil` and
preserve its license. Remove demo-only URL fragment code, example reset,
uniform foil sliders, and bundled example selection from application pages.

Refactor the copied global `main()` into a public `CardPreview` class. The
class owns one canvas, WebGL context, renderer, scene, material controller,
and animation-frame handle. It has this conceptual interface:

```javascript
/** Own one interactive card renderer and its replaceable images. */
class CardPreview
{
    /** Create a preview attached to one canvas and load static resources. */
    static async create(canvas, options) {}

    /** Replace artwork and optional foil control after both decode. */
    async setImages(artwork, foil_control) {}

    /** Restore the camera to the deterministic thumbnail state. */
    resetCamera() {}

    /** Render a foil card and return a tightly cropped transparent PNG. */
    async captureThumbnail(long_side) {}

    /** Stop animation and release WebGL resources. */
    dispose() {}
}
```

Do not define a closure solely to reuse an algorithm. Named functions such as
`findAlphaBounds()` and `scaleDimensions()` remain independently testable.

### Initial page data

Server pages include one non-executable JSON block:

```html
<script id="CardPageData" type="application/json">
    {"mode":"edit","front_url":"...","foil_url":"..."}
</script>
```

The JSON serializer must escape `<` as `\u003c` so a value cannot terminate
the script element with `</script>`. JavaScript reads `textContent` and calls
`JSON.parse()`. Do not interpolate strings into executable JavaScript.

### Local files and remote URLs

`ImageInput` normalizes both sources to `File` objects:

1. For a local input, use the selected `File`.
2. For a URL, construct `new URL(value)` and require HTTP or HTTPS.
3. Call `fetch()` with CORS mode.
4. Require an OK response.
5. Read a `Blob`, derive a safe internal filename from its detected type, and
   construct a `File`.
6. Decode it with `createImageBitmap()`.
7. Reject a long side greater than 2048.
8. Pass the decoded source to `CardPreview`.

A CORS failure remains a browser error and is shown beside the URL field. The
backend never receives or fetches the remote URL. The HTML standard explains
that cross-origin image state affects canvas use in its
[image section](https://html.spec.whatwg.org/multipage/images.html).

### No-foil rendering

The original `foil` material always has a control source. Application code
must add an explicit nonfoil front material or shader branch. Absence of a
control texture must not substitute uniform foil values. The view and editor
both select the plain material when `foil_url` is null.

### Form submission

Card forms remain native form submissions. JavaScript uses the form's
`formdata` event to append prepared remote `File` objects and, for a resulting
foil card, the generated thumbnail synchronously.

Remote-file preparation and foil-thumbnail capture are asynchronous, so the
submit handler follows this state machine:

1. On the first submit, prevent submission.
2. Disable the submit button and set state to `PREPARING`.
3. Validate and load current image sources.
4. If the resulting card has foil and a rendering input changed, generate the
   required thumbnail.
5. Store any resulting blob in component memory.
6. Set state to `READY` and call `form.requestSubmit()`.
7. On the second submit, do not prevent default.
8. During the synchronous `formdata` event, append remote image files and any
   foil-thumbnail blob.
9. Let the browser navigate normally to the server response.

This preserves ordinary redirect and error-page behavior without using a
JSON API. A preparation failure re-enables the form and displays an inline
message; no HTTP request is sent.

## Thumbnail algorithms

### Plain card

The browser does not generate or upload a thumbnail for a card without foil.
The backend applies the Magick++ plain-thumbnail algorithm above to the
resulting front artwork and stores `thumb.avif`. In particular, it stretches
the artwork to the closest integer-pixel 5:7 frame rather than preserving the
artwork's original aspect ratio.

### Foil card

For a card with foil:

1. Wait until artwork, foil control, model, shaders, and spectral lookup data
   are ready.
2. Reset camera rotation to `[0, 0]` and use the initial scene light.
3. Size a temporary WebGL canvas large enough to avoid upscaling the final
   thumbnail.
4. Draw exactly one deterministic frame with a transparent clear color.
5. Call `gl.readPixels()` before the drawing buffer can be discarded.
6. Flip rows while copying WebGL's bottom-left origin into a top-left-origin
   `ImageData` buffer.
7. Scan alpha values and find the smallest rectangle containing a nonzero
   alpha pixel.
8. If no pixel has nonzero alpha, fail generation.
9. Copy that rectangle to a 2D canvas.
10. Scale it so its long side equals the configured size.
11. Export a PNG blob and append it to the form.

The alpha scan uses `alpha != 0`, matching ImageMagick-style transparent-edge
trimming while retaining antialiased edge pixels. It does not use an arbitrary
alpha threshold that could shave off translucent card edges.

The browser receives `thumbnail_long_side` in page JSON from the server. It
does not hard-code 256.

### Edit regeneration

The edit component caches whether a rendering input changed. Replacing the
front, replacing the foil, or removing the foil requires a new thumbnail. If
the resulting card has foil, the browser captures and uploads it. If the
resulting card has no foil, the backend generates it and rejects a thumbnail
upload. A metadata-only edit sends no thumbnail. The backend independently
derives these rules from the stored card and the two asset actions.

## Markdown processing

### Why an AST transformation is required

The inspected MacroDown implementation stores text and link URLs verbatim and
its standard library places values into HTML templates. Parsing alone does not
disable raw HTML. `MarkdownRenderer` must transform the parsed user tree before
calling `MacroDown::render()`.

### Validation and escaping algorithm

For every description:

1. Parse the source with `MacroDown::parse()`.
2. Walk the complete syntax tree with a mutable recursive function. MacroDown's
   `Node::forEach()` accepts a const node, so it is suitable for validation but
   not the later escaping mutation. The application walker recurses through
   the public `Node::data` variant instead of using `const_cast`.
3. For every `link` or `img` macro, require at least one argument and require
   its URL argument to contain only literal text/group nodes.
4. Parse the literal URL with `mw::URL::fromStr()`.
5. Require an exact lowercase scheme of `http` or `https` and a nonempty host.
6. Reject the entire description if any URL fails.
7. After URL validation, recursively replace every user-originated `Text`
   node's content with `mw::escapeHTML(content)`.
8. Render the transformed tree with MacroDown.

Validation happens before escaping so `&` in a query string is parsed as the
original URL. Escaping then changes it to `&amp;` for safe use in an HTML
attribute while preserving browser-visible URL semantics.

Escaping all user-tree text also neutralizes literal `<script>` input and raw
HTML embedded in a user-defined macro body. MacroDown's own registered
standard-library templates still generate tags because those templates are
not nodes in the user tree.

If a user-defined macro attempts to compute a link URL through another macro,
the URL is not a plain literal and is rejected. This is a deliberate prototype
restriction; proving arbitrary macro expansion safe would require validating
the evaluated output or introducing an HTML sanitizer.

### Template insertion

`MarkdownRenderer` returns a distinct `RenderedHtml` type, not a plain string.
Only `HtmlRenderer` can register it for post-render marker substitution. An
Inja template references the associated `*_html_marker` value through an
ordinary escaped interpolation such as `{{ description_html_marker }}`. Inja
first escapes every normal JSON string. `HtmlRenderer` then replaces only the
fresh markers it registered for that render with their `RenderedHtml` values.
Ordinary names, IDs, errors, and form values never enter this raw-fragment map.

This type distinction makes an accidental raw insertion visible during code
review and prevents arbitrary database strings from being treated as trusted
HTML.

## HTML pages

### Shared layout

Every response page contains:

- UTF-8 metadata;
- responsive viewport metadata;
- an escaped page title;
- navigation to the card index, new-card page, and series administration;
- the configured base-aware stylesheet URL;
- a main landmark;
- one page-specific body partial; and
- scripts only on pages that need them.

All mutation forms use labels, native input constraints, a visible submit
button, and server-side validation. Browser constraints are feedback, not the
only validation.

### Card index

Each grid entry contains a link to the card, its thumbnail or configured
placeholder, uppercase public ID, name, and optional rarity display. Image
elements use lazy loading. The sort controls are ordinary links preserving
`sort=id` and changing `direction=asc|desc`.

When there are no cards, render an empty grid. An optional short sentence such
as “No cards yet” is presentation, not a required state machine.

### Card view

The view page shows:

- an interactive canvas;
- public ID and common metadata;
- game name when present;
- every series membership;
- rendered short and long descriptions;
- game-specific display fields;
- edit and delete controls; and
- an asset warning when a required file is missing.

Missing front artwork uses the placeholder URL in page data so WebGL receives
a valid texture. The warning names the missing logical asset but does not leak
the absolute filesystem path.

To create this page, you should just copy over the `foil` files, and
modify it, instead of implement the view from scratch. The sidebar in
`foil` should be replaced to display the card info mentioned above. Do
not alter the WebGL-related logic.

### Card form

The create form lets the visitor select loose or a compiled game. Changing
game reveals that game's server-rendered field section and filters series
choices already embedded in page data. Because a selected game changes the
server-owned metadata schema, the simplest prototype behavior is to reload
`/cards/new?game=<shortname>` when the game selection changes.

The edit form does not permit changing the game or number. It shows current
images and lets the visitor keep or replace artwork and keep, replace, or
remove foil control.

To create this page, you should just copy over the `foil` files, and
modify it, instead of implement the view from scratch. You probably
need to add some fields to the sidebar, and add a submit button. You
should also remove the foil sliders. Do not alter the WebGL-related
logic.

### Series pages

The series index groups series by compiled game and provides create, edit, and
delete links. The create form requires a game, unique name, and description.
The edit form keeps the game fixed. Deletion requires a confirmation page and
removes membership rows through `ON DELETE CASCADE`.

Deleting a series never deletes a card.

## HTTP routes

All paths below are relative to the configured base-URL path. The route name
is the exact string accepted by `App::urlFor()` and Inja's `url_for()`.

| Name | Method | Path | Behavior |
| --- | --- | --- | --- |
| `card-index` | GET | `/` | Card index |
| `card-new` | GET | `/cards/new` | Create-card form |
| `cards` | POST | `/cards` | Create card |
| `card` | GET | `/cards/<id>` | Read-only card view |
| `card-edit` | GET | `/cards/<id>/edit` | Edit-card form |
| `card` | POST | `/cards/<id>` | Update card |
| `card-delete` | GET | `/cards/<id>/delete` | Delete confirmation |
| `card-delete` | POST | `/cards/<id>/delete` | Delete card |
| `series-index` | GET | `/series` | Series administration index |
| `series-new` | GET | `/series/new` | Create-series form |
| `series` | POST | `/series` | Create series |
| `series-edit` | GET | `/series/<integer>/edit` | Edit-series form |
| `series-item` | POST | `/series/<integer>` | Update series |
| `series-delete` | GET | `/series/<integer>/delete` | Delete confirmation |
| `series-delete` | POST | `/series/<integer>/delete` | Delete series |
| `card-asset` | GET | `/static-cards/<path>` | Card static mount |
| `static` | GET | `/static/<path>` | Application static mount |

Names with the same path but different methods intentionally share one URL
name. `card`, for example, is both the view URL and the edit form action.
`card`, `card-edit`, `card-delete`, `series-edit`, `series-item`, and
`series-delete` require one dynamic segment. `card-asset` and `static` each
require exactly one relative-path argument. For example, use
`url_for("static", "foil/model/card.obj")`. All other route names require
none.

Use POST for browser mutations because native HTML forms support GET and POST.
The service methods remain operation-oriented and can later be mapped to PUT
or DELETE in an API.

### Successful mutation responses

- Card creation returns `303 See Other` to `/cards/<new-id>`.
- Card editing returns `303 See Other` to the card view.
- Card deletion returns `303 See Other` to the index.
- Series mutations return `303 See Other` to `/series`.

`303` explicitly converts the follow-up navigation to GET and prevents a page
refresh from reposting a multipart body. Its semantics are defined by
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-303-see-other).

### Error responses

| Status | Use |
| --- | --- |
| 400 | Malformed form, ID syntax, integer, or multipart structure |
| 404 | Unknown card, series, game route, or asset |
| 409 | Duplicate series, stale card revision, or exhausted identity conflict |
| 413 | HTTP body rejected by the server's payload bound |
| 415 | Unsupported or misidentified image format |
| 422 | Well-formed input violating field, Markdown, image, or series rules |
| 500 | Database, template, image-processing, or filesystem failure |
| 503 | SQLite remains busy after its configured timeout |

Every `App` handler error response is a complete server-rendered `error.html`
page. The page includes a short safe message and request correlation ID.
Static-mount misses use cpp-httplib's normal 404 response. Detailed Inja,
Magick++, SQL, and filesystem errors go only to logs.

### Static mounts

`AssetStore` publishes only these card asset names:

- `front-art.jpg`;
- `front-art.jpeg` if retained as a supported canonical variant;
- `front-art.webp`;
- `front-art.avif`;
- `foil.jpg`;
- `foil.jpeg` if retained as a supported canonical variant;
- `foil.webp`;
- `foil.avif`;
- `thumb.jpg`;
- `thumb.jpeg` if retained as a supported canonical variant;
- `thumb.webp`; and
- `thumb.avif`.

The shared helper for selecting one of those names is:

```cpp
/// Return a card asset path relative to the published card-storage mount.
mw::E<std::optional<std::string>> cardAssetRelativePath(
    const Card& card, CardAssetType type);
```

It has this exact mapping:

| Type | Relative path |
| --- | --- |
| `FRONT_ART` | `<lowercase-public-id>/front-art.<front_extension>` |
| `FOIL_CONTROL` | `<lowercase-public-id>/foil.<foil_extension>` |
| `THUMBNAIL` | `<lowercase-public-id>/thumb.<thumbnail_extension>` |

`FOIL_CONTROL` returns a successful `nullopt` when the card has no foil.
Required assets always return a value. An invalid identity or persisted
extension returns an internal error rather than producing a path. The helper
does not touch the filesystem and does not include `published`, the configured
storage root, or the `/static-cards` URL prefix.

`AssetStore` uses the same helper beneath its `published` directory, and `App`
passes the returned string as the single relative-path argument to
`urlFor("card-asset", {relative_path}, query)`. Callers must handle `nullopt`
before calling `urlFor()`.

`card-asset` is a named URL, not an `App` handler. Card assets and page assets
may live under unrelated filesystem roots, so `App::setup()` creates two
independent static mounts:

1. Mount `Config::card_storage_root / "published"` at the base-aware
   `/static-cards` prefix.
2. Mount `Config::static_root` at the broader base-aware `/static` prefix for
   CSS, JavaScript, the placeholder, and copied `foil` resources.

The prefixes do not overlap, so mount behavior does not depend on registration
order. The private `.staging` and `.trash` directories are not reachable
through either mount. Neither configured filesystem root is required to
contain the other.

All card filenames and directory names are generated by `AssetStore` from a
validated identity, logical role, and validated extension. Browser filenames
are never published. cpp-httplib rejects traversal and verifies that a
resolved path remains beneath its mounted root, so a second application route
does not add useful protection.

Both mounts use the correct MIME mappings and cacheable response headers.
Generated card-asset URLs include the card revision as the encoded `v` query
parameter, so an edit produces a new cache key. A missing asset naturally
returns the static server's 404 response; the card page separately checks its
expected files and renders the documented missing-asset warning.

A reverse proxy deployment must mount the same two roots at `/static-cards`
and `/static`, prevent symlink escape, and preserve query strings and MIME
types.

## Card service flows

### Create card

The complete creation flow is:

1. `MultipartReader` creates a unique request directory beneath
   `<card-root>/.staging`.
2. It streams binary fields there and accumulates text fields.
3. The route validates common field syntax and builds `CreateCardInput`.
4. `MarkdownRenderer` parses and validates every supplied description.
5. `GameRegistry` validates the selected game and game-specific metadata.
6. `SeriesService` verifies every series exists and belongs to that game.
7. `ImageProcessor` validates and normalizes the front and optional foil. For
   a foil card, it also validates the required uploaded thumbnail. For a plain
   card, it rejects an uploaded thumbnail and generates `thumb.avif` from the
   normalized front artwork.
8. `CardService` calls `DataSourceInterface::beginTransaction()`, then uses the
   returned transaction to allocate either a per-game or loose random number.
9. It derives the public ID and final card directory.
10. It inserts the common card row, game-extension row, and membership rows.
11. It renames canonical files within staging and atomically renames the
    staging directory to the final card directory. The final directory must
    not already exist.
12. It calls the transaction's `commit()`.
13. It releases staging cleanup ownership.
14. The handler returns a 303 redirect.

If a handled error occurs before publication, rollback and delete staging. If
publication succeeds but commit fails, delete the new final directory and
roll back. If that compensating deletion fails, log the orphan path; startup
cleanup removes directories that have no matching database card.

### Metadata-only edit

1. Read the card and compare `expected_revision`.
2. Validate descriptions, series, and game metadata.
3. Call `beginTransaction()`.
4. Re-read the revision inside the transaction.
5. Update common fields, extension data, and membership rows.
6. Increment revision.
7. Commit through the transaction interface and redirect.

No asset directory is touched and no thumbnail is accepted.

### Rendering-input edit

1. Perform all metadata validation from the metadata-only flow.
2. Create a staging directory.
3. Copy unchanged assets from the current card directory into staging.
   Files are copied rather than hard-linked so replacing a staged name cannot
   mutate a published inode accidentally.
4. Process replacement front or foil uploads.
5. Remove foil from staging when requested.
6. If the resulting card has foil, process the required uploaded thumbnail.
   Otherwise reject an uploaded thumbnail and generate `thumb.avif` from the
   resulting staged front artwork.
7. Call `beginTransaction()` and re-check the revision through that object.
8. Update database rows and increment revision.
9. Rename the existing card directory to a unique backup path beneath the
   private staging root. Its name contains the public ID, previous revision,
   and a random suffix, for example `backup-pkm-2-r4-<random>`.
10. Rename the new staging directory to the canonical card directory.
11. Commit through the transaction interface.
12. Delete the backup directory and redirect.

On a handled failure after step 9, remove any newly published directory,
restore the backup to the canonical path, and roll back. Failure to restore is
logged at critical level. The card view still renders from SQLite and reports
missing assets.

### Delete card

1. Resolve and load the card.
2. Call `beginTransaction()`.
3. Re-check that the card exists.
4. Rename its directory into a unique path beneath `<card-root>/.trash`. Its
   name contains the public ID, current revision, and a random suffix. A
   missing directory is logged but does not prevent database deletion.
5. Delete the common card row. Foreign keys remove extension and membership
   rows.
6. Commit.
7. Recursively delete the trash path.

If commit fails, restore the trash path and roll back. Never run a recursive
delete against a path derived directly from request text. `AssetStore` first
checks that the resolved target is a direct child of the configured private
trash directory.

### Startup cleanup

After opening and migrating SQLite but before listening:

1. Delete unfinished `new-<random>` staging directories. They have never
   replaced a committed directory.
2. For each `backup-<id>-r<revision>-<random>` directory, load the database
   card. If the database still has the old revision, remove a different
   canonical directory if present and restore the backup. If the database has
   a later revision, the edit committed and the backup is deleted.
3. For each `.trash/<id>-r<revision>-<random>` directory, load the database
   card. If the card still exists and its canonical directory is missing,
   restore the trash directory. If the card no longer exists, deletion
   committed and the trash directory is deleted.
4. Enumerate children of `<card-root>/published` matching canonical public-ID
   syntax.
5. Query SQLite for each derived ID.
6. Delete a canonical directory with no matching card as a create orphan.
7. Do not synthesize assets for a database card that has neither a canonical
   directory nor a recoverable backup. Its view page reports the inconsistency.

Recovery names are parsed with strict application functions, not string slices
inside the cleanup loop. An ambiguous or malformed private entry is logged and
left untouched for operator inspection. This recovery is limited to completing
or rolling back the directory transitions defined above; it is not a backup or
disaster-recovery facility.

## Series service flows

### Create

1. Require a registered non-null game.
2. Trim the name and require it to be nonempty.
3. Validate and pre-render the Markdown description.
4. Call `beginTransaction()`, insert the series through the returned object,
   and commit.
5. Convert the unique constraint into a 409 duplicate-series error.

Series uniqueness uses SQLite binary text comparison. Therefore names that
differ by case are distinct. This choice avoids SQLite's ASCII-only `NOCASE`
behavior pretending to provide Unicode case folding.

### Edit

The game remains fixed. Call `beginTransaction()`, update the name and
description through the returned object, and commit. Duplicate names return
409. Existing card membership remains unchanged.

### Delete

Call `beginTransaction()`, delete by internal integer ID through the returned
object, and commit. `ON DELETE CASCADE` removes membership rows. Return 404 if
no row changed.

## Concurrency

cpp-httplib may run handlers concurrently. The prototype uses these rules:

- `GameRegistry`, `Config`, and parsed templates are immutable after startup.
- A `CardPreview` exists only in one browser page and has no server state.
- `DataSourceSQLite` serializes complete operations with one mutex.
- Image decoding and staging occur before beginning a data transaction.
- The revision comparison occurs again after beginning a mutation transaction.
- Asset publication occurs while the database mutation lock is held.
- Static file serving does not take the data-source mutex. Asset publication
  uses complete-directory renames, so a request never sees a partially
  populated staging path. During an edit's old-to-backup and new-to-canonical
  rename gap, an asset request may briefly receive 404.

Holding the writer lock across directory renames is acceptable for the
prototype. It avoids a complex job system and the PRD defines no concurrency
target.

## Error and logging model

### Internal errors

Domain and infrastructure operations return `mw::E<T>`. Define application
error payloads containing:

- an error category;
- a safe user-facing message;
- optional field name;
- HTTP status mapping; and
- an internal cause string for logs.

Expected input errors are not exceptions. Unexpected `inja::InjaError` and
`Magick::Exception` values are caught at their adapter boundaries and
converted to an internal `mw::Error`.

`App::urlFor()`, `getPath()`, and `getMountPath()` throw
`std::invalid_argument` only for an unknown route, the wrong route kind, or
wrong argument arity. Those inputs come from application code and templates,
not request routing. The `App` constructor validates and precomputes every
registered handler pattern and mount prefix before startup. `HtmlRenderer`
catches a later template callback error, logs the route name, and returns an
internal rendering error.

### Request logging

For each request, generate a random correlation ID and log:

- correlation ID;
- method and normalized route;
- response status;
- duration;
- resulting card or series internal ID when applicable; and
- internal error cause when a request fails.

Do not log uploaded bytes, complete Markdown descriptions, remote source URLs,
or absolute card-storage paths in normal informational logs.

### Startup logging

Log configuration paths, listener kind, schema version, registered game short
names, migration steps, and cleanup counts. Do not log secrets if later
configuration gains any.

## Testing strategy

### C++ unit tests

Use GoogleTest and temporary directories created per test. Tests must cover:

#### Public IDs

- base-36 `std::to_chars` boundaries `0`, `35`, `36`, and `UINT32_MAX`;
- `std::to_chars` and `std::from_chars` round trips;
- game ID formatting;
- malformed, overflowed, uppercase, and noncanonical IDs;
- collision retry with a deterministically seeded `NonSecretRandom`; and
- natural sorting including `pkm-2`, `pkm-10`, and mixed loose IDs.

#### Non-secret randomness

- equal sequences from equal explicit seeds;
- values within the complete `uint32_t` range;
- lowercase hexadecimal output with exactly twice the requested byte count;
- distinct sequential suffixes under a fixed test seed; and
- safe concurrent access under ThreadSanitizer when that test mode is enabled.

#### Configuration

- TCP and Unix-socket examples;
- relative path resolution against the config directory;
- unknown and missing keys;
- invalid base schemes and ports;
- quality and thumbnail-size boundaries; and
- static/card-root overlap rejection.

#### URLs and routes

- absolute and request-path output at root and nested base paths;
- percent-encoding of every dynamic segment;
- prevention of a dynamic slash becoming a route separator;
- rejection of empty, dot, dot-dot, and invalid placeholder segments;
- every named route and its required argument count;
- equality between `urlFor()` and `getPath()` dynamic-route structure;
- `getMountPath()` output and rejection of dynamic route names;
- one relative-path argument for each static mount;
- acceptance of valid nested relative paths and rejection of absolute, empty,
  dot, dot-dot, and empty-component paths; and
- rejection of unknown names, nonstring template arguments, and wrong arity.

#### Data source and SQLite

- schema creation and `user_version`;
- ordered migration dispatch and future-version rejection;
- foreign-key enforcement;
- per-game sequence independence;
- no reuse after deletion;
- loose-number uniqueness;
- duplicate series behavior;
- same-game membership trigger;
- cascade behavior;
- transaction commit and destructor rollback;
- data-source mutex ownership for a transaction's full lifetime; and
- migration rollback without advancing `user_version`.

#### Markdown

- normal paragraphs and standard formatting;
- HTTP and HTTPS links and images;
- rejection of `javascript:`, `data:`, relative, and computed macro URLs;
- literal `<script>` escaping;
- quotes and ampersands in URLs;
- raw HTML inside a user macro body;
- Inja escaping of ordinary values and safe marker substitution of
  `RenderedHtml` values;
- rejection of missing, duplicate, colliding, or unreplaced raw-HTML markers.

#### Images

- startup acceptance of the required ImageMagick coder capabilities;
- startup rejection when a required coder capability is unavailable through
  an injected capability-check adapter;
- valid JPEG, PNG, WebP, and AVIF artwork;
- valid JPEG, PNG, WebP, and AVIF foil controls;
- valid opaque JPEG, WebP, and AVIF thumbnails;
- PNG-to-AVIF conversion and alpha preservation;
- acceptance of foil controls without alpha;
- acceptance of thumbnails without alpha;
- plain-thumbnail AVIF generation from each accepted artwork format;
- exact 183-by-256 plain-thumbnail dimensions with the default setting;
- exact 100-by-140 dimensions with a configured long side of 140;
- stretching non-5:7 artwork to the target 5:7 frame;
- preservation of artwork alpha in a generated plain thumbnail;
- wrong extension and MIME labels with valid bytes;
- truncated and malformed inputs;
- 2048-pixel boundary and 2049-pixel rejection;
- APNG, animated WebP, and AVIF sequence rejection; and
- failed conversion and plain-thumbnail generation cleanup.

Required-format tests use the installed Magick++ implementation. A missing
required delegate is a failed build or test prerequisite, not a skipped test.

#### Asset store and services

- relative-path mapping for all three `CardAssetType` values;
- successful `nullopt` for an absent foil control;
- rejection of an invalid persisted identity or extension;
- successful creation publication;
- plain creation without an uploaded thumbnail;
- rejection of a thumbnail upload for a plain card;
- foil creation requiring an uploaded thumbnail;
- cleanup after every injected failure point;
- metadata-only edits leaving asset bytes unchanged;
- rendering edits replacing the complete directory and applying the
  resulting-card thumbnail rule;
- backup restoration after commit failure;
- stale revision conflicts;
- missing artwork card view behavior;
- delete and rollback restoration; and
- startup orphan cleanup.

### HTTP integration tests

Start `App` on a temporary local port with `DataSourceSQLite`, a temporary
database, and temporary roots. Use cpp-httplib's client to test:

- every GET route and content type;
- base-path mounting;
- create/edit/delete redirects;
- multipart creation;
- conditional thumbnail-field requirements for plain and foil mutations;
- canonical public-ID redirects;
- sort direction;
- error status mapping;
- both independent static mount prefixes and traversal rejection; and
- HTML escaping of card and series names.

Integration fixtures register `TestGame`. Tests should inspect database rows
and published files after each mutation, not only response codes.
Focused handler tests inject `DataSourceMock` and
`DataSourceTransactionMock`; they do not open SQLite.

### JavaScript unit tests

Keep pure algorithms outside DOM event handlers and test them with Node's
built-in test runner:

- dimension scaling;
- alpha-bound scanning;
- WebGL row flipping;
- source-mode state transitions;
- foil-thumbnail-required decisions; and
- HTTP/HTTPS remote URL validation.

Reuse and retain the applicable mathematical, shader, geometry, and asset
tests copied from `foil`.

### Browser acceptance test

Before declaring the prototype complete, manually verify in at least one
browser with WebGL 2:

1. Create a loose nonfoil card from a local image and verify its backend-made
   thumbnail is 5:7.
2. Create a game foil card using a CORS-enabled remote artwork URL and local
   foil control.
3. Confirm both thumbnails appear without live WebGL contexts in the index.
4. Confirm the foil thumbnail uses the initial camera and light.
5. Edit metadata and verify the thumbnail URL revision changes but the file is
   not regenerated.
6. Replace artwork and verify a new thumbnail.
7. Remove foil and verify plain rendering and a plain thumbnail.
8. Assign and remove multiple series.
9. Delete a series without deleting cards.
10. Delete a card and verify its directory is gone.
11. Submit unsafe Markdown URLs and oversized images and verify rejection.
12. Stop and restart the server and verify persisted cards still render.

## Implementation sequence

Implement in vertical, testable stages:

1. Create CMake targets, dependency pins, style flags, and a trivial test.
2. Implement `Config`, `UrlBuilder`, `NonSecretRandom`, and startup error
   reporting.
3. Implement `DataSourceInterface`, `DataSourceSQLite`, transaction RAII,
   migration version 1, mocks, and data-source tests.
4. Implement public IDs, game registry, test game, sequences, and series.
5. Implement `MarkdownRenderer` and Inja page rendering.
6. Implement `App`, its named routes and static mounts, and the read-only card
   index and card view using fixture data-source rows.
7. Implement `MultipartReader`, `ImageProcessor`, and `AssetStore`.
8. Implement card creation end to end with plain thumbnails.
9. Refactor and copy the `foil` renderer into `CardPreview`.
10. Implement foil thumbnail capture and native form blob attachment.
11. Implement card editing, revision checks, and staged directory replacement.
12. Implement card and series deletion.
13. Add startup orphan cleanup and missing-asset rendering.
14. Complete HTTP, JavaScript, and manual browser acceptance tests.

Each stage must leave tests passing. Do not copy the entire `foil` demo and
then attempt to reshape it at the end; isolate the reusable renderer as soon
as it enters this repository.

## Acceptance criteria

The prototype is complete when:

- a clean checkout configures and builds with documented system prerequisites;
- startup creates and versions a new database;
- TCP and Unix-socket listeners both work;
- all pages are server-rendered and honor a non-root base URL;
- page and published-card assets work from independent filesystem roots;
- templates generate application links only through Inja's `url_for`;
- loose IDs and per-game sequential IDs obey the documented formats;
- series invariants are enforced in both services and SQLite;
- every accepted image format is validated and PNG is stored as AVIF;
- both plain and foil cards generate correctly sized static thumbnails;
- WebGL card views retain `foil`'s visual behavior without uniform foil;
- metadata-only and rendering edits follow their distinct asset flows;
- handled mutation failures do not leave published partial assets;
- missing committed assets produce a usable card page with a warning;
- Markdown cannot emit literal user HTML or non-HTTP(S) links;
- index natural sorting puts numeric components in numeric order;
- card and series deletion clean up their dependent state;
- C++ and JavaScript automated tests pass; and
- the browser acceptance checklist passes.

## Risks and tradeoffs

### Browser-generated foil thumbnails are trusted

A modified client can upload a misleading foil thumbnail. This is accepted by
the trusted-prototype PRD. The server still validates format, dimensions,
role-specific alpha rules, and animation so the file cannot bypass the asset
contract. Plain-card thumbnails do not have this risk because the backend
derives them from normalized artwork.

### A 32-bit loose namespace is finite

Collision probability grows with the number of loose cards. Database
uniqueness and retry guarantee correctness until the namespace becomes
impractically full, but they do not remove retry cost. Moving to a larger
namespace later would change public IDs and therefore requires a product and
migration decision.

### Filesystem and SQLite cannot commit together

Staging, revision-tagged backup names, rename, rollback, and startup cleanup
cover handled failures and the defined rename/commit crash positions, but they
are not a distributed transaction. The UI's missing-asset behavior is the
final safety net for filesystem failures that prevent recovery. A future
production version could add a durable operation journal or content-addressed
immutable assets.

### Static mounts do not consult SQLite

Direct static serving keeps card delivery simple but cannot participate in the
data-source mutex. An edit may produce a brief asset 404 between its two
directory renames, and a published create orphan may remain addressable until
compensating cleanup or the next startup cleanup. No partial staging directory
is mounted. These limitations are acceptable for the trusted prototype; a
future version can use immutable revision directories or a guarded asset
handler if it needs stronger read consistency.

### Compiled game schemas increase migration responsibility

Per-game extension tables provide strong typing and custom rules, but changing
a shipped game requires code plus a schema migration. This is intentional and
matches the PRD's stable-game assumption.

### System ImageMagick versions vary

Using the operator's Magick++ avoids building several codec stacks but makes
image behavior and security fixes depend on the installed ImageMagick package.
The configure-time version check, startup coder checks, real-format tests, and
explicit coders make missing or incompatible capabilities fail visibly. The
operator remains responsible for applying ImageMagick and delegate security
updates.

### MacroDown is not an HTML sanitizer

The AST validation and escaping pass is part of the security boundary. Any
future MacroDown node or macro capable of emitting HTML must receive a focused
test before MacroDown is upgraded. If arbitrary computed URLs or richer raw
content become necessary, adopt a maintained HTML sanitizer rather than
weakening the escape pass.

### Serial database mutations limit throughput

One mutex simplifies transaction correctness and matches the prototype's lack
of scale requirements. `DataSourceInterface` permits a later move to
per-request connections or a writer queue without changing services or HTTP
contracts.

## External references

- [CMake
  `FetchContent`](https://cmake.org/cmake/help/latest/module/FetchContent.html)
- [cpp-httplib](https://github.com/yhirose/cpp-httplib)
- [libmw](https://github.com/MetroWind/libmw)
- [MacroDown](https://git.xeno.darksair.org/macrodown.git)
- [SQLite transactions](https://www.sqlite.org/lang_transaction.html)
- [SQLite foreign keys](https://www.sqlite.org/foreignkeys.html)
- [SQLite WAL](https://www.sqlite.org/wal.html)
- [SQLite `RETURNING`](https://www.sqlite.org/lang_returning.html)
- [TOML specification](https://toml.io/en/)
- [C++ random-number library](https://en.cppreference.com/w/cpp/numeric/random)
- [C++ `to_chars`](https://en.cppreference.com/w/cpp/utility/to_chars)
- [C++ `from_chars`](https://en.cppreference.com/w/cpp/utility/from_chars)
- [Inja](https://github.com/pantor/inja)
- CMake `FindImageMagick`:
  <https://cmake.org/cmake/help/latest/module/FindImageMagick.html>
- [Magick++](https://imagemagick.org/Magick%2B%2B/)
- [ImageMagick image formats](https://imagemagick.org/formats/)
- [Magick++ `CoderInfo`](https://imagemagick.org/Magick%2B%2B/CoderInfo.html)
- [WebGL 2 specification](https://registry.khronos.org/webgl/specs/latest/2.0/)
- [HTML canvas and cross-origin
  images](https://html.spec.whatwg.org/multipage/images.html)
- [HTTP semantics, RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html)