Changes
diff --git a/designs/design-2-games.md b/designs/design-2-games.md
new file mode 100644
index 0000000..2159e7f
--- /dev/null
+++ b/designs/design-2-games.md
@@ -0,0 +1,590 @@
+# Database-defined games and custom card fields
+
+Status: proposed implementation design.
+
+This document replaces the compiled-game architecture in
+[design-0-prototype.md](design-0-prototype.md) and the game-registration
+startup dependency retained by [design-1-mvp.md](design-1-mvp.md).
+Authentication, ownership, rarity, pulls, images, and public card numbering
+continue to follow those documents except where explicitly changed here.
+
+## 1. Product scope and decisions
+
+Administrators can create and edit games through the application. Each game
+has a permanent short name, a display name, a description, and an ordered
+list of custom card fields. Creating a game or field takes effect without
+rebuilding or restarting the server.
+
+The supported field types are integer, string, and choice. A choice is a
+single selection from a list of strings, not a multiple-selection field.
+There are no configurable validation rules: no required setting, minimum,
+maximum, pattern, string length, default value, expression, or dependency.
+
+The following semantics are design recommendations, rather than additional
+user requirements:
+
+- Every custom field is optional. Adding a field to a populated game does
+ not require rewriting existing cards.
+- Integer values must parse as signed 64-bit integers. Type correctness
+ and representable range are storage requirements, not game rules.
+- Strings are plain text, including multiline text. They are not Markdown,
+ HTML, executable templates, or scripts.
+- Nonempty choice values must exactly match an option in that field.
+- Empty controls clear a value. There is no distinction between an empty
+ string and an unset field in this version.
+- Field types and keys are immutable. Labels and ordering can change.
+- A used field or option cannot be deleted. An option's string is immutable;
+ changing it means adding a new option and moving cards explicitly.
+- Game short names remain immutable, because they are part of card URLs
+ and asset directory names.
+
+These decisions keep old cards readable and prevent definition edits from
+silently deleting or reinterpreting their values. Bulk value conversion,
+option retirement, and changing field types are separate future features.
+
+Going Home becomes an ordinary database game with short name `gh`, display
+name `Going Home`, an empty description, and no custom fields. It receives
+no runtime special treatment.
+
+Schema migration and existing-data migration are explicitly out of scope.
+Implementation targets a fresh database. No legacy-game adapters, data
+conversion, or compatibility path are required. Definition-editing rules
+still apply to cards created after this feature is installed.
+
+## 2. Current implementation and change boundary
+
+The current `GameDefinition` interface combines presentation metadata with
+executable validation and SQLite extension-table hooks. `GameRegistry`
+owns definitions for the life of the process. `main.cpp` registers Going
+Home before `prepareDataSource()` migrates and reconciles the database.
+
+`App` calls registry methods to build game selectors, metadata controls,
+and card details. `CardService` accepts a `GameDefinition` and polymorphic
+`GameCardMetadata`; `DataSourceSQLite` invokes game-owned persistence hooks.
+`SeriesService` also checks the registry. These dependencies all change.
+
+Replace them with value objects read through `DataSourceInterface`, one
+generic field decoder, and common relational metadata tables. There will
+be no SQL generated from field names and no new table per game.
+
+Keep `cards.game_short_name`, `series.game_short_name`, and the existing
+game sequence structure. They already represent stable game identity.
+Add references to a database game row rather than changing public IDs or
+introducing a second externally meaningful identifier.
+
+The existing common card fields are not redefined as custom fields. For
+example, custom fields do not control rarity or creator ownership.
+
+## 3. Field values and decoding
+
+### Integer
+
+Use `std::int64_t` and a full-consumption decimal parser. Accept an optional
+leading minus and one or more ASCII digits; leading zeros are allowed.
+Reject plus signs, fractional notation, exponents, whitespace, trailing
+characters, and overflow. Store the parsed number, so `007` displays as `7`.
+Accept zero, negative numbers, and both signed 64-bit endpoints.
+
+Use a text input with a decimal-integer hint rather than relying on browser
+floating-point number conversion. Never pass integers through JavaScript
+`Number`; it cannot preserve every signed 64-bit value. Submit strings and
+parse on the server. An empty input means unset.
+
+### String
+
+Store submitted text unchanged after ordinary browser form decoding. Do
+not trim, case-fold, normalize Unicode, or interpret markup. Whitespace-only
+strings are values; a zero-length string is unset. Render with Inja escaping
+and whitespace-preserving CSS. Use a textarea so multiline content can be
+entered without inventing a fourth field type.
+
+Normal request encoding and resource limits still apply. Keep the existing
+1 MiB text-part limit in `multipart_reader.cpp`. This is an application-wide
+transport bound, not a configurable field rule. Reject invalid UTF-8 or NUL
+in text inputs rather than truncating them. Apply equivalent transport
+handling to definition forms and card forms.
+
+### Choice
+
+An option is a nonempty UTF-8 string. Matching is exact, case-sensitive,
+and does not normalize or trim text. `Home` and `home` are different values.
+Empty strings are reserved for the unset control and cannot be options.
+
+Use a select whose empty entry says `Not set`. The other option values are
+the actual strings, escaped by Inja; decode form encoding once before
+comparison. A tampered or obsolete value produces a field-specific 422
+error. HTML select controls are assistance, not the enforcement boundary.
+
+Every choice definition must have at least one option. Duplicate strings
+within a field are rejected. Options have explicit display order; their
+strings do not encode order or identity for other fields.
+
+### Missing and unknown inputs
+
+Card forms submit a complete replacement of custom values for their selected
+game. A missing or empty known input becomes unset. Existing nonempty values
+must be rendered back into an edit form to avoid unintended clearing.
+Reject unknown field keys and duplicate parameters before constructing an
+unordered map. Reject all custom values for loose cards and values belonging
+to a different game. Do not silently ignore stale controls.
+
+## 4. Data model
+
+Keep models in `game.h` or a new `game_field.h`; remove the compiled interface
+as part of this implementation. All public items need intent
+comments and must follow the repository naming conventions.
+
+| Type | Required members |
+| --- | --- |
+| `Game` | short_name, display_name, description, revision |
+| `GameFieldType` | INTEGER, STRING, CHOICE |
+| `GameField` | id, game_short_name, key, label, type, position, options |
+| `GameChoice` | value, position |
+| `GameDefinitionSnapshot` | game and ordered fields with options |
+| `GameFieldValue` | field_id and variant of int64_t or string |
+| `SubmittedGameFields` | unique key-to-text entries after duplicate checks |
+
+Absence is represented by no `GameFieldValue` entry. A string variant can
+represent STRING or CHOICE; the field definition determines which, and the
+decoder checks it. Persistence also enforces the discriminator.
+
+Read a complete definition as a consistent snapshot, under one data-source
+lock. Do not query options later after releasing that lock. Return owning
+values, not pointers into a mutable global registry. Avoid caching in this
+version; this makes administration changes visible on the next request.
+
+## 5. Fresh database schema
+
+The following new tables use SQLite STRICT storage like the current schema.
+SQL type names are fixed application code. User strings are bound values.
+
+```sql
+CREATE TABLE games (
+ short_name TEXT PRIMARY KEY,
+ display_name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
+ CHECK(length(short_name) > 0),
+ CHECK(short_name NOT GLOB '*[^a-z0-9]*')
+) STRICT;
+
+CREATE TABLE game_fields (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ game_short_name TEXT NOT NULL
+ REFERENCES games(short_name) ON DELETE RESTRICT,
+ key TEXT NOT NULL,
+ label TEXT NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('INTEGER', 'STRING', 'CHOICE')),
+ position INTEGER NOT NULL CHECK(position >= 0),
+ UNIQUE(game_short_name, key),
+ UNIQUE(id, type),
+ CHECK(length(key) > 0)
+) STRICT;
+
+CREATE TABLE game_field_choices (
+ field_id INTEGER NOT NULL REFERENCES game_fields(id) ON DELETE CASCADE,
+ value TEXT NOT NULL CHECK(length(value) > 0),
+ position INTEGER NOT NULL CHECK(position >= 0),
+ PRIMARY KEY(field_id, value)
+) STRICT;
+
+CREATE TABLE card_field_values (
+ card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
+ field_id INTEGER NOT NULL,
+ field_type TEXT NOT NULL,
+ integer_value INTEGER,
+ string_value TEXT,
+ choice_value TEXT,
+ PRIMARY KEY(card_id, field_id),
+ FOREIGN KEY(field_id, field_type)
+ REFERENCES game_fields(id, type) ON DELETE RESTRICT,
+ FOREIGN KEY(field_id, choice_value)
+ REFERENCES game_field_choices(field_id, value) ON DELETE RESTRICT,
+ CHECK(
+ (field_type = 'INTEGER' AND integer_value IS NOT NULL
+ AND string_value IS NULL AND choice_value IS NULL)
+ OR
+ (field_type = 'STRING' AND integer_value IS NULL
+ AND string_value IS NOT NULL AND length(string_value) > 0
+ AND choice_value IS NULL)
+ OR
+ (field_type = 'CHOICE' AND integer_value IS NULL
+ AND string_value IS NULL AND choice_value IS NOT NULL)
+ )
+) STRICT;
+
+CREATE INDEX card_field_values_by_field ON card_field_values(field_id);
+CREATE INDEX card_field_values_by_choice
+ ON card_field_values(field_id, choice_value);
+CREATE INDEX game_fields_by_game
+ ON game_fields(game_short_name, position, id);
+```
+
+Update fresh creation SQL for `cards`, `series`, and `game_sequences` to add
+`REFERENCES games(short_name) ON DELETE RESTRICT` to their existing
+`game_short_name` columns. Keep nullable game identity only for loose cards.
+Retain the existing common columns, indexes, checks, and membership triggers.
+Create `games` before its referencing tables. No table rebuild is needed.
+
+Also install these fixed invariant triggers:
+
+1. Before insert or update of a value, reject unless the card's non-null
+ game matches the field's game. Use `NOT EXISTS` with an equality join so
+ a loose card or missing parent cannot pass through SQL NULL semantics.
+2. Before insert or update of an option, reject unless its field is CHOICE.
+3. Prevent updates of game short names and field game/key/type. Prevent
+ changing an option's field/value pair. Only labels and positions mutate.
+4. Preserve card game identity and number on update, including transitions
+ between NULL and non-NULL game identities. Preserve series game identity.
+
+The service enforces at least one option per CHOICE field at transaction
+completion. Temporary intermediate states while adding a field are allowed
+inside that transaction. Definition writes are only exposed by the service;
+direct hand edits to the database are not a supported administration API.
+
+Positions need not have a uniqueness constraint. Reordering a list should
+not fail halfway through due to swapped positions. Services write dense
+zero-based positions; reads sort by position and then stable ID (or option
+value as a deterministic tie-breaker).
+
+Foreign keys must be enabled on every connection, with child lookup indexes
+as above. Composite keys provide membership and discriminator enforcement;
+see [SQLite foreign keys](https://www.sqlite.org/foreignkeys.html).
+
+## 6. Administration and definition evolution
+
+Only administrators mutate games, fields, and options. Creators can select
+games and edit custom values on cards they own under existing authorization.
+Players only see the values on cards they may view. Access checks remain
+server-side even if a link or control is hidden.
+
+### Game operations
+
+Create a game and its sequence row in the same transaction. Short names
+must match `[a-z0-9]+`, consistent with existing registry/public-ID parsing.
+Display names must be nonblank; duplicates are allowed because short names
+identify games. Trim game names and field labels, but never card strings or
+choice strings. Game descriptions use the existing Markdown renderer.
+
+Editing changes only the display name and description. Delete is permitted
+only if the game has no cards, no series, and its sequence has never issued
+a number. Keeping a game after all its cards are deleted preserves the
+never-reused public-ID guarantee. Delete unused fields/options explicitly
+within the game-delete transaction, then the unused sequence and game.
+
+### Field operations
+
+Create a field with a key matching `[a-z][a-z0-9_]*`, nonblank label, type,
+and its ordered options when applicable. Keys are unique within a game.
+Use numeric IDs in administration routes and keys in card input names.
+Keys never become SQL identifiers. Adding a field leaves existing cards
+unset for that field.
+
+Edit a field's label, and reorder fields. Do not expose a type/key editor.
+Deleting an unused field deletes its options. Deleting a field with any
+stored values returns 409 and shows the number of affected cards; there is
+no force-delete checkbox. Users can clear card values individually first.
+
+### Choice operations
+
+Allow adding and reordering strings, and deleting strings not referenced
+by any card. Deletion of a used string returns 409 with its usage count.
+Never cascade choice deletion into card data. No rename operation is exposed
+because it would change the displayed value on every referencing card.
+
+Use a list of individually editable inputs on the creation form, not
+comma-separated or newline-separated syntax. This avoids ambiguities for
+commas, newlines, and whitespace inside option strings. Existing option
+strings are read-only; add/remove controls express actual supported changes.
+
+### Concurrency
+
+`games.revision` is the aggregate definition revision. Increment it for any
+game, field, option, or ordering mutation, including label changes. Every
+administration POST includes the revision from its GET. A stale revision
+returns 409 before changing anything.
+
+Card forms also include the selected game's revision as `game_revision`.
+Recheck it in the card transaction, alongside `cards.revision` for updates.
+This prevents an old form from erasing newly introduced values or accepting
+a removed option. A conflict asks the user to reload; retain text where
+feasible and explain that browser-selected files may need reselecting.
+
+Use the existing transaction lock and immediate transaction model. Re-read
+authorization, definition, usage counts, and revisions while that lock is
+held. Do not call ordinary data-source methods that acquire the same mutex
+from inside a transaction. SQLite documents immediate writer acquisition
+in [transaction behavior](https://www.sqlite.org/lang_transaction.html).
+
+## 7. Service and persistence interfaces
+
+Add `GameService` in `game_service.h/.cpp`, owning a reference to
+`DataSourceInterface`. It supplies create/update/remove operations for games,
+fields, choices, and ordering. Each accepts `actor_user_id`; mutations of an
+existing game also accept `expected_revision`. Return `mw::E<T>` throughout.
+Game creation returns the short name, and field creation returns a numeric ID.
+
+Add read operations:
+
+- `getGames()` returns name-sorted games, tie-broken by short name.
+- `getGameDefinition(short_name)` returns an optional complete snapshot.
+- `getCardFieldValues(card_id)` returns ordered values with definitions for
+ display/edit under one lock, so labels and values cannot be mismatched.
+
+Add transaction operations:
+
+- `getGameDefinitionForUpdate(short_name)` and game usage queries.
+- `insertGame`, `updateGame`, `deleteGame`, and sequence deletion.
+- `insertGameField`, `updateGameFieldLabel`, `deleteGameField`.
+- `insertGameChoice`, `deleteGameChoice`, and field/option position updates.
+- `countFieldUsage`, `countChoiceUsage`, and conditional revision increment.
+- Card insert/update overloads accepting decoded generic values in place
+ of `GameDefinition*` and `GameCardMetadata*`.
+
+All concrete data sources, fakes, and mocks implement the new contract.
+Persistence methods receive already checked domain values but still have
+database constraints protecting against programmer errors. Game mutations
+do not flow through `App` directly to arbitrary SQL.
+
+The generic decoder takes a snapshot and submitted text and returns typed
+values or structured field errors. Keep it independent of HTTP and images.
+Its errors contain field keys and messages; the handler maps them to form
+controls and HTTP 422. Do not put user data into raw HTML error strings.
+
+Update `CardService::createGameCard` and `updateGameCard` to accept a short
+name, expected game revision, submitted fields, and series IDs. Keep image
+processing outside the writer lock. Within the transaction:
+
+1. Re-read and authorize the actor and current card if editing.
+2. Read the current definition and compare its revision.
+3. Decode values against that definition; reject unknown fields/options.
+4. Check series ownership against that same game inside the transaction.
+5. Allocate a game number for creation, or preserve identity for edits.
+6. Write common card data, complete replacement custom values, and series
+ membership together. Increment the card revision for updates.
+7. Perform existing asset publication/replacement and commit using the
+ existing compensation/reconciliation behavior. Clean staging on errors.
+
+Do not erase old values until all submitted values are valid. A failure
+must roll back card data, values, membership, and numbering together. Loose
+cards use the same pipeline with an empty custom-value set and no game
+revision. Metadata-only updates retain artwork and still advance card revision.
+
+`SeriesService` drops its registry reference and verifies game existence
+through transaction reads. It continues enforcing unique names per game and
+the rule that series deletion does not delete cards.
+
+## 8. Routes and form contracts
+
+Routes are relative to the configured base URL and are named in `ROUTES`.
+Use `urlFor`/`url_for` everywhere. Preserve `/admin/games` as the entry point.
+The following route names are proposed additions; existing card routes stay.
+
+| Name | Method | Path | Purpose |
+| --- | --- | --- | --- |
+| admin-games | GET | /admin/games | List games and create action |
+| game-new | GET | /admin/games/new | Game creation form |
+| games | POST | /admin/games | Create game and sequence |
+| game-edit | GET | /admin/games/{short}/edit | Game and field editor |
+| game-update | POST | /admin/games/{short} | Update name/description |
+| game-delete | GET, POST | /admin/games/{short}/delete | Confirm/delete |
+| game-field-new | GET | /admin/games/{short}/fields/new | Field form |
+| game-fields | POST | /admin/games/{short}/fields | Create field |
+| game-field-edit | GET | /admin/games/{short}/fields/{id}/edit | Edit label/options |
+| game-field-update | POST | /admin/games/{short}/fields/{id} | Save label/options |
+| game-field-delete | GET, POST | /admin/games/{short}/fields/{id}/delete | Confirm/delete |
+| game-field-order | POST | /admin/games/{short}/field-order | Save field order |
+
+Register literal `new` routes before parameter routes where needed. Check
+that the numeric field belongs to the route's game; otherwise return 404.
+Route helpers currently support multiple segments, so extend their tests.
+
+Game creation posts `csrf_token`, `short_name`, `display_name`, `description`.
+Existing-game mutations post `csrf_token` and `game_revision` as well.
+Field creation posts `key`, `label`, `type`, and repeated `choice` parameters
+in display order. Repeated `choice` is intentional; duplicate scalar keys
+are invalid. Non-CHOICE fields must not submit choice parameters.
+
+Field editing posts `label` and, for CHOICE fields, the complete ordered list
+of `choice` strings. The service computes additions/removals, checks usage
+and at least one remaining option, then saves everything atomically. An
+apparent rename of a used option is therefore rejected as a used deletion.
+Reorder fields with repeated `field_id` parameters containing every current
+field exactly once. Missing, foreign, or duplicate IDs are invalid.
+
+Card custom controls are named `game.<key>`, preserving the existing namespace.
+For example, a hypothetical game may submit `game.score=-2`,
+`game.note=Near the station`, `game.route=Forest`, and `game_revision=4`.
+These are examples of the mechanism, not extra fields for Going Home.
+
+Add `game_revision` to multipart allowed fields. Keep duplicate detection
+before maps are populated. When switching games on the create form, disable
+inactive game controls and replace the revision token with the selected
+snapshot's revision. Loose-card selection disables all custom controls and
+the game revision. Editing never changes a card's game.
+
+No JSON API is introduced. Existing server-rendered forms and progressive
+enhancement remain the public interface. Success returns 303 to a relevant
+GET page, so browser refresh does not repeat a mutation.
+
+## 9. User interface and styling
+
+Read and follow [styling.md](../styling.md) during implementation. Reuse the
+existing `clay-action`, `clay-action-secondary`, `form-field`, and form-submit
+components rather than creating another visual treatment for management links.
+Keep responsive full-width content gutters and floating navigation.
+
+The Games page gains `Create game`. Each game has an `Edit game` action and
+a summary of its fields. Remove compiled/deployment messaging. A game with
+no custom fields says `Uses standard card fields` and still supports series.
+
+The game editor presents identity/name/description and a field list. Show
+type, label, key, and edit/delete controls. Type and key are visibly read-only
+after creation. Add-field forms expose only the three types; choice inputs
+appear only for CHOICE. Do not show a validation-rules section.
+
+Provide labelled move-up/down controls for fields and choices; drag-and-drop
+may enhance them but must not be the only reorder mechanism. Add/remove
+choice rows can be implemented with a small local script; submit the full
+definition through normal forms. Preserve keyboard focus after row changes.
+
+Card forms render generic controls from trusted type mappings, never from
+stored HTML or a stored input-type string. Hidden inactive controls are also
+disabled. Associate errors with controls through `aria-describedby`; provide
+an error summary on failed submissions. Read-only card views omit unset
+custom values and show set values in field order using current labels.
+
+On conflict, show an explanatory page with a link to reload the affected
+editor. For multipart submissions, explain that files need reselecting if
+the response cannot safely retain the staging upload. Do not claim values
+were saved if the transaction rolled back.
+
+## 10. Error handling and bounds
+
+| Condition | Result |
+| --- | --- |
+| Missing session on a GET | Existing sign-in redirect |
+| Player/creator accesses game administration | 403 |
+| Invalid CSRF token | Existing 403 behavior |
+| Missing game/field or mismatched route ownership | 404 |
+| Duplicate scalar parameter or malformed request structure | 400 |
+| Invalid integer, choice, field type, key, or empty label | 422 |
+| Unknown custom field in a complete card submission | 422 |
+| Stale game/card revision | 409 |
+| Duplicate short name/key/option, used deletion | 409 |
+| Application request size exceeded | 413 |
+| Unexpected persistence failure | Logged server error, generic 500 |
+
+Apply one aggregate text budget to both admin and card requests in addition
+to the existing per-part limit: proposed initial value 8 MiB, excluding
+image bytes governed by existing limits. Never silently truncate metadata.
+This bounds resource use without adding business rules to strings/integers.
+Implement consistent payload accounting for URL-encoded and multipart forms.
+
+String/choice values and labels must be escaped when rendered. Bind all SQL
+values. Do not log uploaded text or complete payloads for validation failures.
+Integer failures should identify the field without echoing an arbitrary
+unbounded submitted value.
+
+## 11. Initialization and startup
+
+Create the complete new schema directly in an empty database within one
+transaction. Set `DB_SCHEMA_VERSION` and `user_version` to 2 so an older
+database cannot accidentally be treated as compatible. This is a format
+identifier, not a requirement to implement a version-1 conversion.
+
+Expose `initializeSchema()` without a `GameRegistry` argument. Replace the
+old migration dispatch with initialization for an empty database, acceptance
+of version 2, and a clear error for any other nonempty schema. Never erase
+or reset an existing database automatically. Using a fresh development
+database is a deployment step, not data conversion performed by the server.
+
+Initialization creates `gh` with display name Going Home, empty description,
+revision 1, zero custom fields, and a sequence at zero. Seed only on fresh
+initialization. Later startups do not recreate a deleted unused game or
+overwrite edits. All numbering thereafter follows the normal transaction
+path; deleting cards never reduces a sequence.
+
+Keep foreign keys enabled throughout initialization and normal operation.
+If initialization fails, roll back all schema and seed changes and refuse
+startup. On reopening, perform ordinary integrity checks and administrator
+reconciliation.
+
+Remove runtime registry creation, compiled-schema calls, and checks requiring
+stored games to have a matching C++ class. Remove `GoingHome` from runtime
+code; its only built-in trace is initial seed data. Replace compiled
+`TestGame` fixtures with database definitions created by test setup. Tests
+need no historical extension tables or legacy conversion mappings.
+
+## 12. Implementation sequence
+
+1. Add generic models and decoding with integer/string/choice semantics.
+ Verify edge cases independently of HTTP and SQLite.
+2. Add the fresh schema and initialization fixtures. Verify Going Home seed
+ data and reopening before replacing the runtime registration path.
+3. Add game reads, transaction mutations, usage checks, and revisions to the
+ persistence boundary, fake, and mock implementations.
+4. Implement `GameService`, including permissions and edit/delete conflicts.
+5. Refactor card and series services to database snapshots. Preserve the
+ current image staging, asset reconciliation, and card revision behavior.
+6. Replace game registry calls in `App` and add generic controls and revision
+ tokens. Exercise creation/editing with every field type and with zero fields.
+7. Implement administrator routes and styled game/field/choice editors.
+8. Remove obsolete compiled interfaces, fixtures, includes, and CMake source
+ references. Remove tests whose only purpose was compiled-game migration.
+9. Run targeted service, persistence, form-logic, and live HTTP tests, then
+ the full existing suite. Perform browser checks on desktop and mobile.
+
+## 13. Acceptance and test plan
+
+### Decoding
+
+- Integer: zero, negative, endpoints, leading zeros, empty, overflow,
+ fractions, exponents, whitespace, and suffixes.
+- String: empty, whitespace-only, multiline, Unicode, markup-looking text;
+ prove storage preserves content and HTML output escapes it.
+- Choice: each allowed string, unset, unknown value, case differences,
+ whitespace differences, duplicate options, and options containing `&` or `+`.
+- Reject duplicate fields, foreign game fields, and all metadata on loose
+ cards. A string larger than a game designer might prefer still succeeds
+ when it is within the application transport budget.
+
+### Persistence and concurrency
+
+- A choice value from another field fails even if its text is similar.
+- A field from another game or wrong value discriminator fails insertion.
+- A used option/field cannot be deleted; deleting a card cascades its values.
+- A new field does not insert default rows for old cards.
+- Stale definition edits and stale card forms return 409 without partial
+ writes. Test an option deletion racing with a new card selecting it.
+- Force a metadata failure during card creation/update and verify common
+ data, values, membership, numbering, and staged assets remain consistent.
+- Definition snapshots cannot combine a new field list with old options.
+
+### Initialization
+
+- A fresh database has the full schema and Going Home with zero fields.
+- Failed initialization rolls back schema and seed data together.
+- Reopening never reseeds or overwrites an administrator's edits.
+- Unsupported nonempty databases are rejected without modification.
+- A deleted high-number card does not let the sequence decrease.
+
+### HTTP and visual behavior
+
+- Administrators create a game and all three field types, then create,
+ edit, view, and delete a card and series using that game.
+- Game/field edits are visible without a restart.
+- Players and creators cannot mutate definitions; creator card permissions
+ remain unchanged. Every mutation requires CSRF.
+- Unset values remain absent, choices round-trip exactly, and integer values
+ above JavaScript's safe-integer range retain precision.
+- Game switching disables inactive controls and sends the correct revision.
+- Going Home remains available with `gh-*` numbering and zero extra fields.
+- Card pulls, rarity probabilities, assets, login, and role-aware links pass
+ existing regression tests.
+- In Firefox and a Chromium browser, inspect desktop/mobile forms, keyboard
+ navigation, error focus, choice reordering, and shared clay button styles.
+
+The feature is complete when a newly created database game can use all three
+field types through the normal administrator and card workflows, and Going
+Home works as an ordinary seeded game with zero custom fields.