BareGit

Design for internal games

Author: MetroWind <chris.corsair@gmail.com>
Date: Thu Sep 10 14:48:15 2026 -0700
Commit: 8af0e8763dee30b5f15447ca8b949a9b47c217e0

Changes

diff --git a/designs/design-3-internal.md b/designs/design-3-internal.md
new file mode 100644
index 0000000..b195989
--- /dev/null
+++ b/designs/design-3-internal.md
@@ -0,0 +1,842 @@
+# Internal games
+
+Status: proposed implementation design.
+
+This document extends
+[design-2-games.md](design-2-games.md). It adds administrator-controlled
+visibility to database-defined games without changing card identity, custom
+field behavior, artwork storage, or the schema version used during current
+development.
+
+## 1. Product definition
+
+Every game has one of two visibility states:
+
+- A **public game** participates in all existing card workflows.
+- An **internal game** and its application metadata are visible only to the
+  configured administrator account.
+
+Cards and series do not store independent visibility flags. A game card is
+internal exactly when its owning game is internal, and a series is internal
+exactly when its owning game is internal. This derived rule prevents a card,
+series, and game from disagreeing after one update.
+
+Loose cards have no owning game and remain subject to the existing visibility
+and authorization rules.
+
+Games are public by default. An administrator can choose internal visibility
+when creating a game and can change either direction later. Changing this
+visibility is an ordinary game-definition edit and increments the aggregate
+game revision.
+
+The configured administrator remains the single role allowed to view or
+manage internal application content. A creator is not an administrator for
+this purpose, including when that creator originally authored a card before
+its game became internal.
+
+### 1.1 Artwork boundary
+
+Artwork delivery is outside this feature's authorization contract. The
+application does not add authentication, authorization, game-visibility
+lookups, signed URLs, or another wrapper around artwork files. Deployment may
+serve those files directly from the reverse proxy without involving an
+application route.
+
+This design deliberately does not classify artwork as public or private and
+does not promise which clients can retrieve it. That policy belongs to the
+reverse-proxy and deployment configuration. Application pages naturally stop
+containing card metadata and artwork references when the corresponding card
+is outside the viewer's game-content scope, but those pages are not an access
+control layer for the artwork itself.
+
+### 1.2 Pull behavior
+
+Internal cards never participate in the pull pool, including pulls performed
+by the administrator. Internal games are intended for drafts, testing, or
+catalog content that is not currently part of the collection experience.
+
+Existing holdings are retained when a public game becomes internal. They are
+hidden from non-administrator collections while the game is internal and
+become visible again if the game returns to public visibility. No holding is
+deleted or decremented by a visibility change.
+
+### 1.3 Non-goals
+
+This feature does not add:
+
+- per-card or per-series visibility overrides;
+- visibility for selected creators or groups;
+- artwork delivery, authentication, authorization, or reverse-proxy policy;
+- a separate internal card-number sequence;
+- automatic deletion of player holdings;
+- a second administrator role or account;
+- audit-history tables;
+- a schema-version bump or existing-data migration.
+
+## 2. Visibility matrix
+
+The following matrix is normative. "Current rules" means existing ownership
+and authorship checks still apply after the visibility check succeeds.
+
+| Operation | Administrator | Creator | Player |
+| --- | --- | --- | --- |
+| List public game/card content | Yes | Current rules | Current rules |
+| List internal game/card content | Yes | No | No |
+| View a public card | Current rules | Current rules | Current rules |
+| View an internal card | Yes | No | No |
+| Create a card in a public game | Yes | Yes | No |
+| Create a card in an internal game | Yes | No | No |
+| Edit a public game card | Yes | Author only | No |
+| Edit an internal game card | Yes | No | No |
+| Manage games and series | Yes | No | No |
+| Pull an internal card | No | No | No |
+
+Anonymous requests continue to follow the existing authentication flow. The
+server does not look up a card merely to distinguish public from internal
+before asking an anonymous visitor to authenticate.
+
+## 3. Data model
+
+Add this public enum to `src/game.h`:
+
+```c++
+/// Application visibility assigned to a database-defined game.
+enum class GameVisibility
+{
+    PUBLIC = 0,
+    INTERNAL = 1
+};
+```
+
+The explicit values define the persistence encoding. Do not depend on an
+implicit declaration order when writing database values.
+
+Add this field to `Game` before `revision`, keeping the revision as the final
+aggregate concurrency field:
+
+```c++
+/// Application visibility for this game and its derived content.
+GameVisibility visibility;
+```
+
+`GameDefinitionSnapshot` automatically carries the state because it owns a
+`Game`.
+
+Do not add visibility to `Card`, `Series`, `CardIdentity`, or
+`CardGameFields`. Those objects already identify their game, and visibility
+must be read from the owning game rather than copied into multiple records.
+
+### 3.1 Explicit query scope
+
+Add this public enum to `src/game.h`:
+
+```c++
+/// Visibility boundary for data derived from database-defined games.
+enum class GameContentScope
+{
+    PUBLIC_ONLY,
+    INCLUDE_INTERNAL
+};
+```
+
+`GameVisibility` and `GameContentScope` describe different concepts and must
+not be combined. `GameVisibility` is persistent state belonging to one game.
+`GameContentScope` is a query capability selected from the authenticated
+actor. Both use enums rather than Boolean values so construction, comparison,
+and call sites name the intended state. There is no default scope argument:
+each application-facing read must consciously select a scope.
+
+Add a side-effect-free authorization helper:
+
+```c++
+/// Return the game-content scope available to an actor.
+GameContentScope gameContentScope(const User& actor) const;
+
+/// Return whether an actor may use a game in a card mutation.
+bool canUseGame(const User& actor, const Game& game) const;
+```
+
+`gameContentScope()` returns `INCLUDE_INTERNAL` only for
+`UserRole::ADMINISTRATOR`. `canUseGame()` returns true when visibility is
+`GameVisibility::PUBLIC`, and returns true for `GameVisibility::INTERNAL` only
+for the administrator. Existing
+`canCreateCard()`, `canEditCard()`, and `canViewCard()` continue to enforce
+role, ownership, and authorship after this visibility decision.
+
+## 4. SQLite schema
+
+Change the unreleased version-1 `games` table definition to include:
+
+```sql
+visibility INTEGER NOT NULL DEFAULT 0
+    CHECK(visibility IN (0, 1)),
+```
+
+The integer is a stable encoding of the enum, not a Boolean in the C++ domain
+model: zero means `GameVisibility::PUBLIC`, and one means
+`GameVisibility::INTERNAL`. `INTEGER` is compact, fits SQLite's `STRICT` type
+system directly, and is sufficient for two states. The `CHECK` constraint
+prevents other integer values. SQLite documents strict type enforcement and
+the operation of `CHECK` constraints in
+[STRICT Tables](https://www.sqlite.org/stricttables.html) and
+[CREATE TABLE](https://www.sqlite.org/lang_createtable.html).
+
+The complete relevant table shape becomes:
+
+```sql
+CREATE TABLE games(
+    short_name TEXT PRIMARY KEY,
+    display_name TEXT NOT NULL,
+    description TEXT NOT NULL DEFAULT '',
+    visibility INTEGER NOT NULL DEFAULT 0
+        CHECK(visibility IN (0, 1)),
+    revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
+    CHECK(length(short_name) > 0),
+    CHECK(short_name NOT GLOB '*[^a-z0-9]*')
+) STRICT;
+```
+
+Do not add a separate index for `visibility`. The games table is expected to
+be small, most card queries begin with a card or holding index, and joining a
+card's `game_short_name` to the game primary key is already indexed.
+
+Fresh databases still contain zero games. Creating a game inserts its normal
+sequence row in the same transaction, regardless of visibility.
+
+### 4.1 Development schema policy
+
+Keep `DB_SCHEMA_VERSION` and `PRAGMA user_version` at 1. This feature remains
+part of the unreleased version-1 schema. Do not add a 1-to-2 migration.
+
+The migration dispatcher remains intact for future released schema changes.
+For current development databases, extend the version-1 shape check so a
+database whose `games` table lacks `visibility` is rejected with the existing
+instruction to delete and recreate the unreleased database. Do not allow an
+old version-1 database to start and fail later in an unrelated query.
+
+Tests must prove that rejecting the obsolete shape does not modify it.
+
+### 4.2 Persistence encoding
+
+Bind `GameVisibility::PUBLIC` as integer zero and
+`GameVisibility::INTERNAL` as integer one. Centralize this mapping in small
+encoding and decoding helpers rather than scattering casts throughout the
+data source. Decoding must reject every integer other than zero or one as
+invalid database state rather than choosing a fallback. The schema constraint
+is the primary defense; explicit decoding also protects fake, hand-built, or
+corrupted test fixtures.
+
+Every query that materializes a `Game` must include `visibility`. This
+includes:
+
+- complete game lists;
+- single definition snapshots;
+- writer-locked definition snapshots;
+- card custom-field snapshots that contain a definition;
+- fake and mock data-source implementations.
+
+## 5. Persistence visibility boundary
+
+Application-facing reads must make visibility explicit. Update the read-only
+data-source surface to accept `GameContentScope` for records that can reveal
+game-derived content:
+
+```c++
+getCards(GameContentScope scope)
+getCardsByCreator(
+    std::int64_t creator_user_id,
+    GameContentScope scope)
+getCard(
+    const CardIdentity& identity,
+    GameContentScope scope)
+getGames(GameContentScope scope)
+getGameDefinition(
+    const std::string& short_name,
+    GameContentScope scope)
+getCardFieldValues(
+    std::int64_t card_id,
+    GameContentScope scope)
+getSeries(GameContentScope scope)
+getSeries(
+    std::int64_t series_id,
+    GameContentScope scope)
+getCollection(
+    std::int64_t user_id,
+    GameContentScope scope)
+```
+
+If an optional single-record read is outside the selected scope, it returns an
+empty optional exactly as it does for a nonexistent identity. A list simply
+omits records outside the scope.
+
+There must be no overload or default that silently chooses
+`INCLUDE_INTERNAL`. Requiring the enum at compilation time forces all callers,
+including tests and asset reconciliation, to document their intent.
+
+`AssetStore` reconciliation is an internal maintenance operation and passes
+`INCLUDE_INTERNAL`; otherwise it could incorrectly classify an internal
+card's published directory as orphaned. This choice concerns storage
+bookkeeping only and creates no artwork-delivery policy.
+
+Numeric series reads use the same scope rule as series lists. This keeps the
+interface uniform and prevents a future non-administrator series route from
+accidentally exposing an internal series through an unscoped helper.
+
+### 5.1 Transactional reads
+
+Writer-locked reads such as `getGameDefinitionForUpdate()` remain unfiltered.
+They are low-level persistence primitives used after a service has loaded the
+actor under the same transaction. The service must apply `canUseGame()` before
+performing a card mutation.
+
+A mutation service may use `INCLUDE_INTERNAL` for a non-rendered preflight
+read needed to locate a target or prepare bounded image work. That read is not
+an authorization decision and must not place internal metadata in a response.
+The service still re-reads the actor, card, and game under its transaction and
+applies the current internal state before writing.
+
+This separation is intentional:
+
+1. Read-only application paths use scoped queries and cannot accidentally
+   render internal rows.
+2. Mutation services read the actual current row while holding the write lock.
+3. The service compares visibility, role, revision, fields, and series before
+   writing.
+4. A concurrent administrator visibility change cannot race between checking
+   and committing a creator mutation.
+
+### 5.2 Card SQL predicate
+
+For `PUBLIC_ONLY`, card queries join the owning game and retain loose cards:
+
+```sql
+FROM cards AS card
+LEFT JOIN games AS game
+    ON game.short_name = card.game_short_name
+WHERE card.game_short_name IS NULL OR game.visibility = 0
+```
+
+Additional conditions, such as creator identity or card identity, are joined
+with parentheses and `AND`. `INCLUDE_INTERNAL` omits the visibility predicate
+but may retain the join when decoding related state.
+
+Because game cards have a foreign key to `games`, a non-null game identity
+must resolve. A missing joined game is an integrity error, not a public loose
+card.
+
+### 5.3 Game and definition SQL predicate
+
+`getGames(PUBLIC_ONLY)` and
+`getGameDefinition(short_name, PUBLIC_ONLY)` add
+`visibility = 0`.
+`INCLUDE_INTERNAL` returns both states. Ordering remains display name followed
+by short name.
+
+Definition snapshots remain atomic: the game row, fields, and choices are
+read under the same data-source lock. Filtering occurs on the game row before
+fields or choices are returned.
+
+### 5.4 Series SQL predicate
+
+Public series lists join `series.game_short_name` to `games.short_name` and
+require `games.visibility = 0`. There is no independent series
+visibility.
+
+This primarily affects the creator card form. The standalone series index,
+create, edit, and delete pages remain administrator-only and use
+`INCLUDE_INTERNAL`.
+
+### 5.5 Collection SQL predicate
+
+`getCollection(user_id, PUBLIC_ONLY)` joins each held card to its game and
+omits internal game cards. It leaves `card_holdings` untouched.
+
+`getCollection(user_id, INCLUDE_INTERNAL)` returns every holding. This allows
+the administrator's own collection page to display internal cards while
+players and creators see only public holdings.
+
+`userOwnsCard()` does not need a scope because it returns a Boolean used only
+after the card itself has passed the visibility boundary.
+
+## 6. Pull pool
+
+Both `getPoolCards()` and transactional `getPoolCardsForUpdate()` always omit
+internal cards. They do not accept `GameContentScope`; no actor is allowed to
+pull internal content.
+
+The predicate is:
+
+```sql
+WHERE card.rarity > 0
+  AND (card.game_short_name IS NULL OR game.visibility = 0)
+```
+
+Both methods must use the same join and predicate so the collection page's
+"pool empty" state agrees with the transaction that performs a pull.
+
+When a game becomes internal, its positive-rarity cards leave the pool on the
+next query. When it becomes public, they re-enter without rewriting cards.
+The game update and pull transaction are serialized by existing immediate
+writer transactions, so a pull observes one complete visibility state.
+
+On an administrator's internal card detail page, probability text reads
+"Not currently in the pull pool." Do not calculate an administrator-only
+probability.
+
+## 7. Game creation and editing
+
+Use the existing game routes:
+
+| Route | Added form value | Meaning |
+| --- | --- | --- |
+| `POST /admin/games` | required `visibility` | Initial state |
+| `POST /admin/games/{short}` | required `visibility` | Replacement state |
+
+The form uses a required two-option control. It submits exactly one of these
+values:
+
+- `visibility=PUBLIC`;
+- `visibility=INTERNAL`.
+
+Use same-named radio controls, whose mutual-exclusion behavior is defined by
+the HTML [Radio Button state][html-radio] specification.
+
+Missing, duplicate, or unknown values produce `400 Bad Request`. Parse the
+value directly into `GameVisibility`; do not first convert it to a Boolean.
+
+Add a `GameVisibility visibility` parameter to
+`GameService::createGame()` and `GameService::updateGame()`. The transaction
+insert and update methods receive the same value.
+
+The update SQL changes display name, description, internal state, and revision
+in one statement guarded by the expected revision. Even when only visibility
+changes, the revision increments exactly once.
+
+Submitting the same state is allowed and follows existing game-edit behavior:
+it still constitutes a saved definition edit and advances the revision. Do
+not add state-difference logic solely for the visibility control.
+
+### 7.1 Toggle behavior
+
+Public to internal proceeds as follows:
+
+1. Verify CSRF and parse the exact visibility discriminator.
+2. Start the game-service transaction.
+3. Re-read and authorize the actor as administrator.
+4. Read the game at the expected revision.
+5. Update metadata, `visibility = 1`, and revision atomically.
+6. Commit.
+7. Redirect to the administrator game index.
+
+No cards, fields, choices, series, holdings, sequences, or assets are changed.
+
+Internal to public uses the same sequence with `visibility = 0`.
+Existing cards and holdings immediately become eligible for their ordinary
+public behavior; positive-rarity cards also return to the pull pool.
+
+## 8. Card mutation enforcement
+
+Filtering the HTML form is not authorization. `CardService` must enforce the
+current visibility state inside its writer transaction.
+
+### 8.1 Creation
+
+For a game-card creation:
+
+1. Re-read the actor under the transaction lock.
+2. Apply the existing `canCreateCard()` role check.
+3. Load the current game definition with
+   `getGameDefinitionForUpdate()`.
+4. If the game does not exist, return the existing unknown-game validation
+   error.
+5. If `canUseGame(actor, game)` is false, return the same unknown-game error.
+6. Check the submitted game revision.
+7. Decode custom fields and validate series membership.
+8. Allocate a number and insert the card using the existing atomic flow.
+
+An internal game guessed by a creator must be indistinguishable from a
+nonexistent game. Use the same status and safe message for both. No definition
+name, current revision, field key, or series name is returned.
+
+The existing image-processing order may remain unchanged. A rejected request
+can consume bounded image-processing work, but it must not publish assets,
+allocate a committed number, or write metadata.
+
+### 8.2 Editing
+
+A creator may have loaded an edit form immediately before an administrator
+makes the game internal. The update service therefore re-reads the game under
+the same transaction used for the card update.
+
+If the game is now internal and the actor is not the administrator, return
+`404` before reporting a stale definition revision. This conceals the current
+game state and performs no database or published-asset change.
+
+Administrator updates to internal cards follow all normal revision, field,
+series, image, rarity, and asset rollback rules.
+
+Loose-card creation and editing are unchanged.
+
+## 9. Read paths
+
+### 9.1 Card detail
+
+After authentication, derive the actor's `GameContentScope` and call the
+scoped `getCard()`. A non-administrator lookup of an internal card returns an
+empty optional, and the handler returns `404` without querying ownership,
+custom values, series, or probability.
+
+The existing `canViewCard()` check then handles ownership and creator
+authorship for visible public cards. Administrators retrieve the card with
+`INCLUDE_INTERNAL` and pass the existing administrator branch.
+
+### 9.2 Card edit and delete pages
+
+Card edit GET and POST use the actor's scope before rendering or mutating.
+Creators receive `404` for internal cards even if they authored them.
+
+Deletion remains administrator-only. Administrator delete confirmation and
+mutation paths use `INCLUDE_INTERNAL` so an internal card can be removed.
+
+### 9.3 Administrator card index
+
+`/admin/cards` uses `INCLUDE_INTERNAL` and lists every card. Add an `Internal`
+badge to cards whose owning game is internal so the administrator does not
+mistake them for public content.
+
+Avoid one definition query per card. Either extend the card-index projection
+with derived internal state or load all games once into a short-name map. Do
+not persist the derived visibility in `Card` merely for rendering.
+
+### 9.4 Creator card index
+
+`/creator/cards` calls `getCardsByCreator(actor.id, PUBLIC_ONLY)`. A creator's
+previously authored internal cards disappear from this list immediately after
+the toggle.
+
+### 9.5 Card form
+
+An administrator's create form receives all definitions and series. A
+creator's create form receives only public definitions and public series.
+Existing client-side game switching then operates only on authorized options.
+
+Change the shared loader signature to
+`loadGameDefinitions(data_source, scope)` so each caller supplies its scope.
+Validation-error rerenders must reuse the actor's scope; a failed public card
+submission must not cause an internal definition to appear in the returned
+form.
+
+The service checks visibility again on submission. Removing the option from
+HTML is usability, not the security boundary.
+
+An administrator editing an internal card sees only its fixed game, current
+fields, and matching series as usual. A creator cannot reach that edit form.
+
+### 9.6 Collection
+
+The collection handler passes `gameContentScope(actor)` to
+`getCollection()`. Players and creators do not receive internal entries, card
+names, quantities, links, or thumbnail URLs. Administrators receive all of
+their own holdings.
+
+Available-pull calculation is unchanged. Pool emptiness uses the globally
+public-only pool query.
+
+### 9.7 Series
+
+All standalone series management routes remain administrator-only and show
+both public and internal games. Add an `Internal` badge beside an internal
+game in game selectors and the series index where this improves clarity.
+
+Non-administrator exposure is limited to the card form. Its series choices
+must be fetched with `PUBLIC_ONLY`, and the server-side card mutation must
+still verify the game and series under the transaction lock.
+
+### 9.8 Game administration
+
+The administrator game index and editor use `INCLUDE_INTERNAL`. Internal games
+remain fully editable, including their custom fields and choices. Add a clear
+status badge to the index and editor.
+
+There is no non-administrator game index or game-detail route, so no new
+public route is introduced.
+
+## 10. HTTP behavior and caching
+
+Authenticated non-administrators receive `404 Not Found` for direct internal
+card view and edit targets. RFC 9110 explicitly permits `404` when an origin
+does not wish to disclose a forbidden target and defines `404` as also
+covering unwillingness to disclose existence; see
+[RFC 9110 sections 15.5.4 and 15.5.5][rfc-9110].
+
+Use the same styled not-found response as a nonexistent card. Do not include
+the public ID, game name, visibility state, creator, or a link to the internal
+resource in that response.
+
+Because a `404` can otherwise be heuristically cached, concealed internal
+responses must include:
+
+```http
+Cache-Control: private, no-store
+```
+
+Authenticated dynamic HTML pages that can contain game or card metadata
+should use the same header. This prevents a browser or shared intermediary
+from retaining a public representation after an administrator makes its game
+internal. It cannot recall a page already loaded before this feature is
+deployed. This cache requirement applies only to application responses and
+does not define caching behavior for artwork served by a reverse proxy.
+
+Authorization failures for administrator-only management endpoints remain
+`403`; those endpoints already disclose their general existence in
+administrator navigation and do not need target-level concealment.
+
+All state-changing requests retain existing CSRF validation. The visibility
+control does not introduce a new route or method.
+
+## 11. Artwork delivery boundary
+
+Do not introduce an application artwork authorization path. In particular, do
+not add:
+
+- a session check before artwork delivery;
+- a game or card lookup when an artwork path is requested;
+- a `GameVisibility` decision in an artwork-serving handler;
+- signed or administrator-only artwork URLs;
+- application-owned cache or access policy for reverse-proxy responses.
+
+The intended production shape is that the reverse proxy may serve the
+published artwork directory directly. The proxy configuration, URL layout,
+authentication policy, authorization policy, and caching policy are all
+outside this design. This feature neither explicitly permits nor denies any
+artwork request.
+
+Existing development-only application mounting may remain available for
+local testing, but it must not gain a visibility-aware wrapper. Generic asset
+tests may continue to test that mount independently. This feature must not add
+an integration assertion about who can retrieve an internal card's artwork,
+because such an assertion would turn deployment behavior into an application
+access contract.
+
+`AssetStore` remains responsible for publishing, replacing, trashing, and
+reconciling files. If scoped persistence APIs require reconciliation to pass
+`GameContentScope::INCLUDE_INTERNAL`, that choice preserves storage
+bookkeeping only; it does not affect artwork delivery.
+
+## 12. User interface
+
+Follow [styling.md](../styling.md). Internal visibility is status information,
+not a destructive action.
+
+### 12.1 Game form
+
+Add a recessed, rounded visibility fieldset after the description. Use two
+radio choices so the submitted value maps directly to `GameVisibility`:
+
+- `Public` with the supporting text `Available through normal card workflows.`;
+- `Internal` with the supporting text
+  `Only the administrator can see this game and its cards.`;
+- `Public` selected by default during creation;
+- the stored or submitted choice selected during editing and validation-error
+  rerenders.
+
+Each choice must have at least a 44-pixel target, a visible keyboard focus
+state, and an associated label. Present the pair as clay-styled choice cards
+consistent with existing forms. Do not use Bootstrap or an unstyled
+browser-default radio presentation.
+
+### 12.2 Status badges
+
+Use one consistent rounded `Internal` badge on:
+
+- internal game cards on the game administration index;
+- the internal game editor;
+- internal cards on the administrator card index;
+- internal game options or adjacent descriptions in administrator series
+  forms.
+
+Use an amber or pink clay accent that remains readable against the lavender
+canvas. Do not rely on color alone; the word `Internal` is mandatory.
+
+Public games need no `Public` badge. Omitting a badge keeps the common state
+quiet while making the exceptional state obvious.
+
+### 12.3 Empty and transition states
+
+If all games are internal, a creator's card form behaves like a database with
+no games: loose-card creation remains available and no internal name appears.
+
+If all positive-rarity cards are internal, player collection pages report the
+ordinary empty-pool state. Do not mention that hidden cards exist.
+
+## 13. Error handling and logging
+
+Use these response classes:
+
+| Condition | Response |
+| --- | --- |
+| Missing, invalid, or duplicate `visibility` form value | 400 |
+| Creator submits internal game for new card | 422 unknown game |
+| Non-admin requests internal card page | 404 |
+| Stale administrator game edit | 409 |
+| Non-admin uses administrator management route | 403 |
+| Invalid database visibility discriminator | 500 and startup/read error |
+
+Do not log internal card names, descriptions, custom values, or series names
+merely because a non-administrator guessed an identity. Existing operational
+logs may record safe internal IDs and error categories where needed.
+
+Database and template failures continue to use existing structured error
+paths. A visibility denial is expected authorization behavior and should not
+produce an error-level log.
+
+## 14. Concurrency and consistency
+
+The game revision is the concurrency boundary for visibility and custom-field
+state together.
+
+Consider a creator submitting a public game card while an administrator marks
+the game internal:
+
+1. Both operations request an immediate writer transaction.
+2. One transaction obtains the lock first.
+3. If card creation wins, it commits under the old public state; the following
+   visibility edit hides that card with the rest of the game.
+4. If the visibility edit wins, the creator re-reads
+   `GameVisibility::INTERNAL` and the card mutation is rejected.
+5. No transaction observes a half-updated game or a mismatched revision.
+
+The same ordering applies to card edits and pulls. Visibility never requires
+updating every dependent row, so there is no long-running bulk transaction.
+
+## 15. Implementation map
+
+Expected primary changes are:
+
+- `src/game.h`: visibility and query-scope enums;
+- `src/authorization.h/.cpp`: scope and game-use policy;
+- `src/data.h/.cpp`: explicit scoped read interfaces;
+- `src/data_sqlite.h/.cpp`: version-1 column, decoding, query predicates,
+  pool exclusion, and obsolete-shape check;
+- `src/data_fake.h/.cpp` and `tests/data_mock.h`: matching scoped behavior;
+- `src/game_service.h/.cpp`: create/update visibility;
+- `src/card_service.cpp`: transactional internal-game rejection;
+- `src/app.h/.cpp`: parsing, scoped calls, status data, and cache headers;
+- `templates/game_form.html`: visibility control;
+- `templates/game_admin.html`, `templates/card_index.html`, and series
+  templates: administrator status badges;
+- `static/css/styles.css`: clay toggle and badge styles;
+- persistence, authorization, service, rendering, and live HTTP tests.
+
+Do not add artwork authentication or authorization code. Storage code changes
+are limited to passing `INCLUDE_INTERNAL` during reconciliation reads if the
+scoped API requires it.
+
+## 16. Implementation sequence
+
+1. Add `GameVisibility`, `Game::visibility`, `GameContentScope`, fake/mock
+   support, and fixture values so compiler errors enumerate every affected
+   construction site.
+2. Change the fresh version-1 schema and obsolete-shape detection. Add strict
+   decoding and persistence tests before changing application queries.
+3. Add scoped game, card, series, custom-value, and collection reads. Verify
+   loose cards remain visible in `PUBLIC_ONLY`.
+4. Exclude internal cards from both pool read paths and test transitions in a
+   transaction.
+5. Add authorization helpers and enforce internal state inside card create and
+   update transactions.
+6. Extend game creation/update services and HTTP form parsing, preserving
+   optimistic concurrency and validation rerenders.
+7. Apply scopes to card details, card forms, creator lists, collections,
+   administrator lists, series inputs, and asset reconciliation.
+8. Add the styled visibility choices and badges following `styling.md`.
+9. Add cache control for authenticated metadata and concealed `404` responses.
+10. Run targeted unit and SQLite tests, the full suite, the live HTTP test,
+    and Firefox desktop/mobile checks.
+
+## 17. Test plan
+
+### 17.1 Model and authorization tests
+
+- Administrator scope includes internal content.
+- Creator and player scopes are public-only.
+- `canUseGame()` accepts public games for creators and administrators.
+- `canUseGame()` rejects internal games for creators and players.
+- Existing loose-card and authorship rules remain unchanged.
+
+### 17.2 SQLite tests
+
+- Fresh schema stores `visibility` as constrained integer data.
+- Default insertion produces a public game.
+- Both enum states round-trip through every game read.
+- Values other than zero and one fail the schema constraint.
+- Public game lists omit internal games; administrator lists include them.
+- Public card reads omit internal game cards and retain loose cards.
+- Creator reads omit an authored internal card.
+- Public series reads omit series belonging to internal games.
+- Player collection reads omit internal holdings without deleting them.
+- Administrator collection reads include internal holdings.
+- Both pull queries omit positive-rarity internal cards.
+- Switching back to public makes cards, series, and holdings visible again.
+- An old version-1 shape without `games.visibility` is rejected unchanged.
+
+### 17.3 Service tests
+
+- Administrator creates both public and internal games.
+- Visibility update increments the game revision.
+- A stale visibility update returns `409` and changes nothing.
+- Creator creation in a guessed internal game returns the same result as an
+  unknown game and allocates no number.
+- Creator update of a newly internal game card returns `404` and preserves
+  metadata, fields, membership, revision, and assets.
+- Administrator can create, edit, and delete cards in an internal game.
+- Existing game deletion usage rules are unaffected.
+
+### 17.4 HTTP tests
+
+- Create and edit forms submit both visibility choices correctly.
+- Missing, duplicate, or malformed `visibility` parameters return `400`.
+- Validation rerenders preserve the submitted visibility state.
+- Administrator game and card indexes show the `Internal` text badge.
+- Creator forms contain no internal game or series names.
+- Creator authored-card lists omit internal cards.
+- Player collections omit an internal card they still own.
+- Non-admin direct view and edit requests return the same `404` body as a
+  nonexistent card and include `Cache-Control: private, no-store`.
+- Toggling back to public restores normal page and collection visibility.
+- Internal cards never appear in pulls or pool probabilities.
+- Administrator pages continue to show and manage internal content.
+- Every visibility mutation requires CSRF and the current game revision.
+
+### 17.5 Browser and accessibility checks
+
+- Firefox desktop and mobile layouts match `styling.md`.
+- Both visibility choices are keyboard operable and visibly focused.
+- Each label selects its radio control across the full touch target.
+- Screen readers receive both the label and supporting explanation.
+- Internal badges remain legible without color perception.
+- Creator forms do not briefly reveal internal controls before JavaScript
+  initializes; filtering must occur on the server.
+
+## 18. Acceptance criteria
+
+The feature is complete when all of the following are true:
+
+1. A game can be created public or internal and changed in either direction.
+2. Only the administrator receives internal game, card, series, custom-value,
+   description, and holding metadata from application routes.
+3. Creators cannot create or edit cards in an internal game, including with a
+   forged or stale form.
+4. Internal cards never enter the pull pool.
+5. Existing holdings and card data survive visibility changes unchanged.
+6. Direct non-administrator card requests conceal internal targets with
+   non-cacheable `404` responses.
+7. Administrator workflows show clear, consistently styled internal status.
+8. The feature adds no application authentication, authorization, or delivery
+   policy for artwork; reverse-proxy artwork serving remains outside scope.
+9. The schema remains development version 1 and old unreleased databases are
+   rejected rather than migrated or silently accepted.
+10. The build, complete test suite, live HTTP test, and Firefox checks pass.
+
+[rfc-9110]: https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.4
+[html-radio]: https://html.spec.whatwg.org/#radio-button-state-(type=radio)