BareGit
# 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)