Asynchronous PvP API contract
Status and purpose
This document is the issue #178 contract for the planned asynchronous
player-versus-player mode. It is an implementation input for persistence issue
#179, API and deadline-processing issue #180, and player-experience issue
#181. It does not claim that these routes, tables, background jobs, or screens
are implemented or deployed.
The contract keeps asynchronous PvP separate from the later one-minute live mode. Asynchronous play uses persisted state, polling, and 24-hour response windows. It does not introduce WebSockets, live presence, spectators, matchmaking, ratings, seasons, or client-authoritative results.
The API and PostgreSQL remain authoritative. The browser submits authenticated intent, renders viewer-safe state, and may poll committed resources. It never submits or calculates damage, effects, randomness, legal-action results, deadlines, winners, completion, forfeiture, or progression awards.
Confirmed and unresolved decisions
The following rules are fixed by issue #178 and this contract:
- challenge discovery uses the existing unique Player Name and discloses no email, Auth0 subject, role, location, internal player UUID, or private history;
- invitations expire 24 hours after their server-recorded creation time;
- each decision window in an accepted match lasts 24 hours;
- a missed match decision automatically forfeits the match rather than applying
the shared rules package's live/CPU
timeoutaction; - deck and card values are validated and snapshotted before use;
- existing deterministic move, effect, replacement, and victory rules are shared with CPU battles;
- only participants can inspect invitations, matches, and history;
- polling is the only planned update mechanism;
- every client mutation is idempotent and contains intent only; and
- match completion exposes a stable exactly-once progression source without inventing client-controlled or unapproved award values.
The first delivery deliberately does not add challenge cancellation or a voluntary resignation intent. Account deletion invalidates pending invitations and finalizes an active match as an account-deletion forfeit for the opponent. Completed records retain an anonymous player tombstone under the existing retention policy.
The following social and privacy decisions are unresolved and require separate product authority before a later implementation claims them:
- opting out of challenges or limiting challenges to approved contacts;
- player blocking, reporting, moderation, and abuse-response workflows;
- notification channels and notification retention;
- per-player pending-invitation and active-match limits beyond the one-pair rule below;
- retention periods for declined and expired invitations; and
- whether a reused Player Name should receive additional protection after account deletion.
Until those decisions are approved, exact lookup, the generic authenticated rate limit, and one pending invitation per unordered player pair are the bounded first-delivery protections. They are not substitutes for blocking, reporting, or moderation.
Identity and discovery
Only an authenticated active player with a non-null Player Name can look up or challenge another player. Lookup is an exact canonical Player Name match; it is not prefix search, autocomplete, recommendations, or a player directory.
The lookup response contains only:
| Field | Type | Meaning |
|---|---|---|
playerName | string | Exact canonical challenge name |
requestId | string | Server-generated safe correlation identifier |
No response contains the target's internal UUID, provider identity, account
role, email, location, activity, deck, collection, rank, or challenge history.
A missing, null-name, deleted, deletion-pending, or self target uses the same
PVP_PLAYER_UNAVAILABLE response. Invitation creation resolves the supplied
Player Name again inside its transaction, so a prior lookup is never authority.
Player Name is the only client-visible challenge identity. Internally, foreign keys use server-resolved player UUIDs. The browser cannot submit a player UUID as an ownership or authorization claim.
Invitation lifecycle
An invitation has one of these states:
pending -> accepted
-> declined
-> expired
accepted, declined, and expired are terminal and immutable. There is no
first-delivery cancellation transition.
Creation
The challenger supplies a saved deck and an initial Creature from that deck. The server:
- derives the challenger from the validated token;
- resolves the exact opponent Player Name to an active player;
- rejects self-challenges;
- validates deck ownership and the complete three-Creature/two-Power, ten-point rule;
- validates that
initialCreatureIdis one of the deck's Creature copies; - snapshots the challenger deck and battle-ready card values;
- records
createdAtfrom the server clock andexpiresAt = createdAt + 24 hours; and - commits the invitation and submission receipt atomically.
Only one pending invitation may exist for an unordered player pair. Two players
challenging one another concurrently therefore produce one committed pending
invitation; the other request returns PVP_INVITATION_ALREADY_PENDING. This
does not prohibit a later rematch or establish a global active-match limit.
The recipient sees the challenger Player Name and invitation timestamps, but not the challenger deck, snapshot, card identities, initial selection, or collection. Invitation views do not expose either deck or initial selection; the server retains them only in the private persistence snapshot needed to start an accepted match.
Acceptance
The recipient accepts with their saved deck and initial Creature. In one locked transaction the server:
- locks the invitation through a recipient-scoped lookup;
- performs the fresh post-lock submission-receipt check, returning an identical committed replay before applying a deadline;
- finalizes a still-pending invitation as expired when
now >= expiresAt; - checks
expectedVersionand lifecycle state; - validates and snapshots the recipient deck;
- applies both hidden initial selections through the shared rules package;
- uses server-side randomness to choose the first side;
- creates one active asynchronous match and its first decision deadline;
- marks the invitation accepted and links the match; and
- persists the response used for idempotent replay.
The match starts only when all steps commit. Neither participant can observe a
partially accepted invitation, an unmatched snapshot, or the opponent's hidden
initial selection. The first turnDeadline is 24 hours after the acceptance
transaction's server timestamp.
Decline and expiry
Only the recipient may decline. Decline records the server timestamp and no match or progression source is created.
Invitation expiry is successful when now >= expiresAt. It may be finalized by
the background sweep or by an invitation read or mutation that locks the
overdue row. Expiry has no winner and no progression effect. A late accept or
decline leaves the invitation persisted as expired and returns
PVP_INVITATION_EXPIRED rather than reviving it.
Match lifecycle and deadlines
An asynchronous match is either active or completed. A completed match has
one immutable completion reason:
| Reason | Meaning |
|---|---|
defeat | Shared battle rules produced a winner |
turn_forfeit | The required participant missed a 24-hour window |
account_deletion_forfeit | An active participant began permanent deletion |
The API maps the internal player and cpu rule-engine sides to viewer-relative
self and opponent. CPU-oriented side names and internal player UUIDs do not
cross the PvP HTTP boundary.
Each committed state identifies either no actor because it is terminal, or one participant who must act. An active move turn and a mandatory replacement both receive a fresh deadline:
turnDeadline = decisionStartedAt + 24 hours
A mandatory replacement remains a separate shared-rule decision and does not consume the replacement player's following normal turn. Committing the replacement therefore creates a new 24-hour deadline for that player's normal turn.
At the exact boundary, now >= turnDeadline means the deadline has passed. The
overdue participant loses by turn_forfeit; the opponent wins. The API never
converts this event to the rules package's { "type": "timeout" } transition,
because that action skips a turn in CPU/live play instead of completing an
asynchronous forfeit.
Reads and polling never extend a deadline. Only a committed legal action that creates another required decision changes it.
Viewer-safe match projection
All match fields are protected. In this document, shared means visible to both authenticated participants, not anonymous or spectator-visible.
| Data | Self | Opponent | Non-participant |
|---|---|---|---|
| Player Name | Yes | Yes | No |
| Internal player UUID/Auth0 data | No | No | No |
| Match status, version, timestamps, current actor | Yes | Yes | No |
| Own full Creature and Power snapshots | Yes | No | No |
| Own current HP, Power, modifiers, and used cards | Yes | No | No |
| Opponent active and defeated Creatures | Yes | Viewer-relative equivalent | No |
| Opponent unused Creature identities | No; count only | No; count only | No |
| Opponent unused Power-card identities | No; count only | No; count only | No |
| Legally played Power cards and resolved effects | Yes | Yes | No |
| Legal actions | Only when self acts | No | No |
| Unplayed cards in completed history | No | No | No |
| Authoritative outcome and completion reason | Yes | Yes | No |
The response does not use null card objects as hidden placeholders because
their number or position could leak deck ordering. It returns explicit
aggregate counts and separate revealed-card arrays. A history replay contains
only actions and card information that became legally visible during play.
The common match response is viewer-relative:
| Field | Type | Required | Notes |
|---|---|---|---|
matchId | UUID string | Yes | Opaque match identifier |
mode | "asynchronous" | Yes | Never aliases live PvP |
status | "active" | "completed" | Yes | Authoritative state |
version | non-negative integer | Yes | Optimistic-concurrency version |
opponent.playerName | string | Yes | Only approved opponent identity |
currentActor | "self" | "opponent" | null | Yes | Viewer-relative |
turnDeadline | RFC 3339 timestamp or null | Yes | Null only when completed |
state | AsyncPvpStateView | Yes | Viewer-safe battle projection |
legalActions | action array | Yes | Empty unless self may act |
lastAction | PvpActionResultView or null | Yes | Legally visible committed result |
outcome | "win" | "loss" | null | Yes | Non-null when completed |
completionReason | terminal reason or null | Yes | Non-null when completed |
startedAt | RFC 3339 timestamp | Yes | Server timestamp |
updatedAt | RFC 3339 timestamp | Yes | Polling reconciliation field |
completedAt | RFC 3339 timestamp or null | Yes | Server timestamp |
requestId | string | Yes | Safe request correlation |
AsyncPvpStateView is a closed object with these required fields:
| Field | Type | Visibility rule |
|---|---|---|
phase | "active" | "replacement" | "completed" | Shared |
turnNumber | positive integer | Shared |
self | SelfParticipantView | Full viewer-owned battle snapshot |
opponent | OpponentParticipantView | Revealed cards plus hidden-card aggregate counts |
SelfParticipantView contains the viewer's Player Name, every Creature and
Power-card snapshot, active Creature identifier, current HP and Power,
modifiers, and used Power-card identifiers. OpponentParticipantView contains
the opponent Player Name, active and defeated Creature snapshots, remaining
Creature count, legally played Power cards and resolved effects, and remaining
Power-card count. It contains no array, identifier, position, or placeholder
for an unrevealed card.
Creature, move, effect, modifier, legal-action, and public action-result objects
reuse the corresponding closed public schema shapes already served by CPU
matches. Issue #180 must extract shared OpenAPI components for those shapes
and add viewer-relative participant and side enums; it must not fork their
battle semantics. PvpActionResultView maps any public actor or target side to
self or opponent and contains only the committed public result. All three
legal-action variants are closed objects, and legalActions is non-empty only
when currentActor is self.
Success response schemas and request schemas use additionalProperties: false
so authoritative-looking fields cannot be smuggled into otherwise valid
payloads.
Legal client intents
Every mutation carries a UUID submissionId. State-changing invitation and
match intents also carry expectedVersion. Replaying the same submission with
the same authenticated actor, operation, resource, and canonical payload
returns the stored outcome. Reusing the ID with a different payload returns
PVP_IDEMPOTENCY_CONFLICT.
The complete first-delivery endpoint set is:
| Method and path | Success | Response schema | Intent |
|---|---|---|---|
GET /api/v1/matches/pvp/players?playerName={name} | 200 | ChallengePlayerResponse | Exact challenge lookup |
POST /api/v1/matches/pvp/invitations | 201 | PvpInvitationResponse | Create one invitation |
GET /api/v1/matches/pvp/invitations | 200 | PvpInvitationListResponse | List a bounded page of the viewer's invitations |
POST /api/v1/matches/pvp/invitations/:invitationId/accept | 200 | AcceptPvpInvitationResponse | Accept with a deck and initial Creature |
POST /api/v1/matches/pvp/invitations/:invitationId/decline | 200 | PvpInvitationResponse | Decline a pending invitation |
GET /api/v1/matches/pvp | 200 | PvpMatchListResponse | List a bounded active-match page |
GET /api/v1/matches/pvp/history | 200 | PvpHistoryResponse | List a bounded completed-history page |
GET /api/v1/matches/pvp/:matchId | 200 | AsyncPvpMatchResponse | Read one viewer-safe match |
POST /api/v1/matches/pvp/:matchId/actions | 200 | AsyncPvpMatchResponse | Submit one legal battle intent |
Static /history routing must be registered without allowing it to be captured
as a match identifier.
Every success envelope requires requestId and uses
additionalProperties: false:
| Schema | Required content |
|---|---|
ChallengePlayerResponse | playerName, requestId |
PvpInvitationResponse | invitation, requestId |
PvpInvitationListResponse | invitations[], nextCursor, requestId |
AcceptPvpInvitationResponse | terminal accepted invitation, created match, requestId |
PvpMatchListResponse | viewer-relative active matches[], nextCursor, requestId |
AsyncPvpMatchResponse | the complete viewer-relative match fields defined above |
PvpHistoryResponse | completed viewer-relative matches[], nextCursor, requestId |
The list item components are also closed and viewer-relative:
| Component | Required fields |
|---|---|
PvpMatchSummary | matchId, mode, status, version, opponent.playerName, currentActor, turnDeadline, updatedAt |
PvpHistorySummary | matchId, mode, status, opponent.playerName, outcome, completionReason, startedAt, completedAt |
An active list returns only PvpMatchSummary values with
mode = "asynchronous" and status = "active". History returns only
PvpHistorySummary values with mode = "asynchronous" and
status = "completed"; a deleted opponent is represented by the literal safe
display value Deleted player, not an internal identifier.
Bounded collection queries
All three collection routes use keyset cursor pagination. Their query objects are closed and accept:
| Route | Query fields |
|---|---|
| Invitations | cursor?, limit?, direction?, status? |
| Active matches | cursor?, limit? |
| Match history | cursor?, limit? |
limit is an integer from 1 through 50 and defaults to 20. The server
never returns more than the effective limit. direction is incoming or
outgoing; status is one invitation status. Omitting either invitation
filter includes both directions or all statuses respectively. This lets the
Web application request a bounded actionable page such as pending incoming
invitations without loading retained terminal invitations.
cursor is an opaque server-issued string with a maximum length of 512
characters. It binds the authenticated viewer, endpoint, filters, effective
limit, ordering fields, and final row of the preceding page. A malformed,
tampered, cross-viewer, cross-endpoint, or filter-mismatched cursor returns the
safe VALIDATION_ERROR; the client never constructs or edits one and repeats
the same filters and limit when requesting the next page.
Ordering and cursor comparisons are deterministic:
| Collection | Order |
|---|---|
| Invitations | createdAt DESC, then invitationId DESC |
| Active matches | updatedAt DESC, then matchId DESC |
| Match history | completedAt DESC, then matchId DESC |
The opaque cursor advances strictly after that tuple. UUIDs are the stable
tie-breakers and are already visible identifiers for the corresponding
participant-owned resources. Each list response includes required
nextCursor: string | null; null means that query currently has no later page.
The implementation may fetch one extra row internally to decide whether to
emit a cursor, but it does not return that row in the current page.
Active-match ordering can change while a player traverses pages. Consumers
deduplicate by matchId, keep the greatest version, and restart from the
first page for the next refresh; a cursor traversal is not a long-lived
database snapshot. Background polling refreshes only the visible first page or
an open match detail. It must not automatically drain every cursor page on each
interval. Explicit navigation or recovery may request later pages one at a
time.
A safe first-page envelope is:
{
"matches": [],
"nextCursor": null,
"requestId": "server-generated-request-id"
}
The lookup query requires exactly one playerName and rejects unknown query
properties. Invitation and match path parameters require UUID format. List
operations accept no browser-supplied player identifier. A first-page request
omits cursor; an empty page still returns nextCursor: null.
Invitation request schemas
Create invitation:
{
"opponentPlayerName": "wits_wanderer",
"deckId": "11111111-1111-4111-8111-111111111111",
"initialCreatureId": "22222222-2222-4222-8222-222222222222",
"submissionId": "33333333-3333-4333-8333-333333333333"
}
Required fields are exactly opponentPlayerName, deckId,
initialCreatureId, and submissionId. The three identifiers use UUID format;
Player Name uses the shared account-policy length and syntax.
Accept invitation:
{
"deckId": "44444444-4444-4444-8444-444444444444",
"initialCreatureId": "55555555-5555-4555-8555-555555555555",
"submissionId": "66666666-6666-4666-8666-666666666666",
"expectedVersion": 0
}
Decline invitation:
{
"submissionId": "77777777-7777-4777-8777-777777777777",
"expectedVersion": 0
}
The accept body requires exactly deckId, initialCreatureId, submissionId,
and expectedVersion. The decline body requires exactly submissionId and
expectedVersion. Both version values are non-negative integers, and all shown
identifiers use UUID format.
PvpInvitationView is a closed object:
| Field | Type | Required |
|---|---|---|
invitationId | UUID string | Yes |
direction | "incoming" | "outgoing" | Yes |
status | "pending" | "accepted" | "declined" | "expired" | Yes |
version | non-negative integer | Yes |
opponent.playerName | string | Yes |
createdAt | RFC 3339 timestamp | Yes |
expiresAt | RFC 3339 timestamp | Yes |
respondedAt | RFC 3339 timestamp or null | Yes |
matchId | UUID string or null | Yes |
respondedAt is null only while pending. matchId is non-null only when the
invitation was accepted. Both incoming and outgoing views omit deck and initial
selection data. direction and opponent are relative to the authenticated
viewer.
Match action schema
The action request is:
{
"submissionId": "88888888-8888-4888-8888-888888888888",
"expectedVersion": 4,
"action": {
"type": "use_move",
"moveId": "99999999-9999-4999-8999-999999999999"
}
}
action is exactly one of:
{ type: "select_replacement", creatureId: UUID }
{ type: "use_move", moveId: UUID }
{ type: "use_power_card", powerCardId: UUID }
Initial selection is captured during invitation creation and acceptance.
select_initial_creature and timeout are therefore never accepted at the
match-action endpoint. The body cannot contain effects, card statistics,
damage, HP, Power, randomness, target results, winner, completion reason,
deadline, or progression fields.
The server still validates the requested identifier against the current
authoritative legalActions; returning an action earlier does not reserve it
or bypass version and deadline checks.
Polling and history
GET /matches/pvp and GET /matches/pvp/:matchId are the recovery authority
after refresh, restart, or an ambiguous network response. Local storage,
guessed state, and browser simulation are not authoritative.
Polling uses the returned version and updatedAt to recognize a new committed
snapshot. The contract does not require a fixed polling interval; the Web
implementation must use bounded polling only while the relevant page is
visible, stop on unmount, and back off after failures or rate limiting.
Collection polling follows the bounded first-page rules above; match-detail
polling remains the authority while a battle is open.
An ambiguous mutation response is reconciled by reading the affected
invitation or match and then replaying the same submissionId only when
necessary. The client must not manufacture a new submission identifier merely
because the first response was lost.
History is participant-only. List rows contain the opponent Player Name or
Deleted player, viewer-relative outcome, completion reason, and timestamps.
Detail may include the legally revealed action history, but never unplayed
opponent cards or private persistence snapshots.
Idempotency and concurrency boundary
Persistence issue #179 must reinforce this contract with foreign keys,
terminal-state constraints, unique submission receipts, snapshot immutability,
and indexes for pending invitations, active participant matches, history, and
overdue work.
Invitation creation has no existing resource row to lock. Its transaction must serialize the canonical unordered player pair, enforce the one-pending-pair constraint, and use an actor-and-operation-scoped unique submission receipt. A same-submission or same-pair unique race is resolved with a fresh receipt and pending-invitation lookup so an identical replay returns its committed result and a different request receives the appropriate conflict.
Each mutation of an existing invitation or match follows this order:
- begin a transaction and lock the invitation or match through a participant-scoped lookup, returning the safe not-found response for a missing resource or wrong actor;
- load a previously committed submission receipt in a fresh statement after acquiring the lock;
- return that result for an identical replay or reject a payload conflict;
- compare the authoritative server time with the persisted deadline;
- finalize overdue state before considering client intent;
- validate version, lifecycle, ownership, and legal intent;
- apply the shared deterministic transition and any server randomness;
- persist state, version, action/history, terminal result, submission receipt, and progression hook atomically; and
- commit before returning the response.
The fresh post-lock receipt lookup preserves the existing CPU-match lesson: a statement snapshot created while waiting for another writer may not observe that writer's newly committed receipt through a subquery in the locking statement.
Two requests with one expected version cannot both advance a match. The first
commit increments the version; the second returns the stored receipt when it is
a replay or PVP_MATCH_VERSION_CONFLICT when it is a different intent.
Deterministic deadline processing
The deadline processor is an at-least-once convergence mechanism, not a source of authority. PostgreSQL timestamps and terminal constraints decide outcomes.
The future processor must:
- run one sweep after API startup and repeat on a configurable interval;
- select bounded overdue batches ordered by deadline and stable identifier;
- lock candidates with
FOR UPDATE SKIP LOCKEDor an equivalent non-overlapping pattern; - recheck status and deadline after locking;
- finalize each invitation expiry or match forfeit transactionally;
- write no duplicate history or progression under retry;
- contain one-item failures without exposing sensitive state in logs;
- stop cleanly during API shutdown; and
- catch up all overdue work after restart.
The current single Azure API instance with Always On can host the processor in
the API process, following the account-deletion retry scheduler pattern.
Database locking and idempotency remain mandatory so correctness survives a
recycle or future scale-out. Issue #180 must add safe interval configuration
and update the deployment guide when the processor is actually implemented;
this contract does not claim that configuration exists today.
Progression boundary
Asynchronous PvP completion creates one stable server-internal progression source per participant, keyed by the authoritative match and player. It records the viewer-independent result, completion reason, and completion timestamp in the same transaction as the terminal match.
The source is processed exactly once even when an action response is replayed, a deadline worker retries, or two workers race. Zero-point outcomes must still be recordable when the approved progression rules require activity evidence.
Merged issue #171 and migration 0026 support exactly two source kinds:
challenge_completion and cpu_match. Open issue #172 owns HTTP exposure
and transaction wiring for those approved sources only; it is not the owner of
an asynchronous PvP rule or schema.
The concrete extension gate is recorded in the
progression rules
and
progression persistence design.
The Product Lead must approve the outcome values and activity/achievement
effects there. Persistence issue #179 must then add the forward schema,
constraints, and one transaction-aware
recordAsynchronousPvpCompletion(client, matchId) adapter operation for the
asynchronous_pvp source kind, keyed uniquely by match and participant. Issue
#180 depends on both merged gates and must call that operation in the terminal
match transaction.
Until both gates exist, no implementation may invent a point value, spendable
currency, coin result, achievement, or client-provided award. Issue #180
cannot claim its exactly-once progression criterion is complete, even if match
completion itself is implemented.
Safe errors
Errors use the existing problem-style envelope with type, title, status,
code, safe detail, and requestId. The proposed domain codes are:
| Status | Code | Use |
|---|---|---|
400 | VALIDATION_ERROR | Malformed parameter or closed request schema |
404 | PVP_PLAYER_UNAVAILABLE | Exact target cannot safely be challenged |
404 | PVP_INVITATION_NOT_FOUND | Missing invitation or wrong participant |
404 | PVP_MATCH_NOT_FOUND | Missing match or non-participant access |
409 | PVP_INVITATION_ALREADY_PENDING | One-pair pending constraint |
409 | PVP_INVITATION_EXPIRED | Accept or decline lost to expiry |
409 | PVP_INVITATION_VERSION_CONFLICT | Stale invitation version |
409 | PVP_MATCH_COMPLETED | New action against a terminal match |
409 | PVP_MATCH_VERSION_CONFLICT | Stale match version |
409 | PVP_ACTION_ILLEGAL | Intent is not currently legal |
409 | PVP_IDEMPOTENCY_CONFLICT | Submission ID reused with another payload |
429 | RATE_LIMITED | Existing authenticated per-process limit |
Authentication continues to use the existing 401 boundary. Object-level
authorization uses the same 404 as absence so a caller cannot probe another
player's invitations or matches. Unknown failures remain generic 500
responses; internal SQL, state, tokens, and stack traces are not returned.
A safe invitation-expired example is:
{
"type": "https://wits.world/problems/pvp_invitation_expired",
"title": "PvP invitation expired",
"status": 409,
"code": "PVP_INVITATION_EXPIRED",
"detail": "The invitation is no longer available.",
"requestId": "server-generated-request-id"
}
Downstream verification matrix
Issues #179 through #181 must turn these scenarios into proportionate unit,
route, database, component, and integration tests:
Invitation and identity
- nonexistent, self, null-name, deletion-pending, and deleted lookup targets;
- absence of email, Auth0 subject, role, UUID, location, rank, and private history in lookup and invitation responses;
- same-direction and reverse-direction concurrent invitations;
- invalid, unowned, stale, or incomplete challenger and recipient decks;
- challenger snapshot stability after saved-deck or catalogue edits;
- accept versus decline, accept versus expiry, and duplicate accept replay;
- identical replay versus submission-ID/payload conflict;
- account deletion during pending invitations; and
- invitation filters, limit bounds, invalid/cross-viewer cursors, stable tie-break ordering, and insertion between pages.
Match authority and privacy
- wrong-player read, action, polling, and history access;
- hidden initial selections and unrevealed Creature and Power cards;
- legal reveal of active/defeated Creatures, played Power cards, and effects;
- rejection of result fields and the
timeoutaction; - stale legal actions after another committed transition;
- two different actions submitted against one version;
- coherent reads before or after, but never across, a concurrent commit;
- mandatory replacement followed by a fresh normal-turn deadline;
- immutable completed state and history;
- anonymous completed history after account deletion; and
- active/history page bounds, deduplication after mutable ordering, and first-page refresh without automatic cursor draining.
Deadlines, restart, and progression
- actions received immediately before, exactly at, and after the deadline;
- request-driven finalization before the periodic sweep runs;
- two deadline workers selecting the same due population;
- processor restart and overdue catch-up;
- rollback and retry without duplicate terminal history;
- completion action racing a forfeit sweep;
- exactly-once per-participant progression sources for defeat and forfeiture;
- replay of persisted outcomes without duplicate points or achievements; and
- safe logging when a sweep item fails.
Web behavior
- bounded visible-page polling, recovery after refresh, and lost-response reconciliation;
- repeated-click protection using the same submission ID;
- incoming, outgoing, accepted, declined, expired, active, forfeited, completed, loading, empty, forbidden, and retry states;
- no rendering or storage of unrevealed opponent cards; and
- responsive, keyboard, focus, screen-reader, non-color, and deadline text behavior.
Review and delivery gates
Before issue #178 is closed:
- an API/security reviewer must approve identity exposure, object authorization, idempotency, safe errors, hidden-state projections, and deadline concurrency;
- a game-rule reviewer must approve initial selection, first-side randomness, mandatory replacement, legal actions, forfeiture, and the separation from live timeout behavior;
- the OpenAPI-ready shapes and examples must be checked against Fastify's runtime-schema conventions;
- formatting, linting, type checking, tests, and builds must pass;
- CI and the documentation build must pass;
- the pull request must link issue
#178; and - unresolved social/privacy decisions and downstream progression work must remain visible rather than being presented as implemented protections.
This document was planned, generated, edited, and reviewed with the assistance of Codex[GPT-5].