Guest history: a Guest holds the Seat and owns its decided Games, listed on this browser, read and exported by link #355

Closed
opened 2026-10-11 13:43:33 +00:00 by root · 24 comments
root commented 2026-10-11 13:43:33 +00:00 (Migrated from gitlab.core-project.space)

Imported from GitHub amaurycoudr/chess-project#355, opened by @amaurycoudr on 2026-10-11 13:43 UTC.


Problem Statement

When an Online game ends, it is gone: the Room keeps only its current Game, a restart replaces it, and an eviction or a redeploy wipes the Room. A Player cannot find a game they won, show it to a friend, or take it to an analysis tool. There are no accounts yet, so the history has to belong to someone who has none.

Meanwhile a browser proves its place in a Room with a per-Room Seat token. That secret is worth one Room and cannot gather a browser's Games across Rooms. Keeping it beside a second, browser-wide secret would give every Player two identities and leave the accounts to come a transfer flow to build (ADR 0034).

Solution

A browser that creates a Room or takes a Seat without a Guest is given one by the server: a secret it keeps and sends with every call. That Guest is the only proof of a Seat, in every Room; the Seat token is gone. Reloading, or opening the Room in another tab, still lands the Player back in their Seat.

When an Online game is decided (by Checkmate, a Draw of any reason, an agreed Draw or a Resignation), the server stores it once. The stored Game keeps the Moves as one UCI line, who won and how it ended, when it started and ended, and the Guest in each Seat at that moment. A Player opens "My games" to see the decided Games of this browser, newest first, the opponent shown as "Guest". Every stored Game has a page that anyone with its link can read (board, move list, review controls, result) and export as PGN. A Game cut short (restart mid-game, Room evicted, redeploy) is not stored, and Local games stay in the browser.

User Stories

  1. As a Player creating a Room or taking a Seat with no Guest, I want the server to give my browser one, so that one identity holds my Seats and owns my Games across Rooms and visits.
  2. As a Player, I want my browser to keep its Guest and send it with every call in a header, never in a URL, so that sharing a link never shares my identity.
  3. As a Spectator, I want to get no Guest, even though opening a full Room asks it for a Seat, so that watching never makes me the owner of anything.
  4. As a Player, I want a reload or a second tab of the same browser to land back in my Seat, with no secret kept per Room, so that the Guest alone proves where I sit.
  5. As a Player, I want a browser that knows only the Room code to be unable to act for my Seat, so that the invite never plays my Moves.
  6. As a Player whose Guest already holds a Seat in a Room, I want opening that Room again to give me that Seat back, never the other one, so that I hold at most one Seat per Room.
  7. As a Player, I want a decided Online game stored once with the Guest in each Seat when it ended, so that both of us own it, even if a Seat changed hands mid-game.
  8. As a Player, I want a Game that ended by Checkmate, by any Draw reason, by an agreed Draw or by Resignation stored with how it ended and who won, so that the record says what happened.
  9. As a Player, I want a Game cut short by a restart, an eviction or a redeploy not stored, so that "My games" holds only decided Games.
  10. As a Player whose opponent's Seat was vacated or never taken when the Game ended, I want the Game stored with only my side owned, so that the Game still counts for me.
  11. As a Player, I want the Room to carry on even if storing the Game fails, so that a database problem never interrupts play.
  12. As either Player, I want the Game to be in "My games" as soon as the Room shows it decided, so that opening the list right after the end never shows it missing.
  13. As a Player, I want "My games" to list this browser's decided Games, newest first, so that I can find a recent one.
  14. As a Player, I want each row to show when it ended, my Color, the opponent as "Guest" with their Color, my result (won, lost, drawn) and how it ended, so that I recognise the Game without opening it.
  15. As a Player with no Games yet, or whose browser has no Guest, I want an empty "My games" that says why, so that an empty list doesn't look broken.
  16. As a Player, I want "My games" reachable from the site's navigation, so that I can get to it from any page.
  17. As anyone with a Game's link, I want to read it (board, move list, review controls and result) without a Guest, so that a Player can share a Game with a friend.
  18. As a Player opening my own Game, I want the board oriented to my Color, my side labelled "You" and the other "Guest"; anyone else gets White at the bottom and both sides "Guest", so that it reads as it was played.
  19. As anyone opening a link to an unknown Game, I want a clear not-found page, so that a typo doesn't look like a crash.
  20. As anyone reading a Game, I want to export it as a PGN file whose players are "Guest" and whose result matches how it ended (resignation and agreed draw included), so that I can open it in any chess tool.
  21. As a Player, I want to copy a Game's link from its page, so that sharing takes one click.
  22. As the owner, I want the server to store only a hash of each Guest secret, so that a stolen dump or the Gateway shows no secret that could hold a Seat or claim Games later.

Seams

  • The Room, through its public methods with room.fixtures.ts, unit. The Room seats occupants by Guest, and reports a decided Game exactly once, for every ending, with its line, how it ended, its timestamps and the Guest in each Seat at the end. Covers 6, 7, 8, 9, 10.
  • The RPC wire, through the real app in process with rpc.fixtures.ts on the in-process database the Postgres spec gives it, integration. Minting on create and take and never on a refusal, acting for a Seat with and without its Guest, storage failure, the list and its read-after-decide guarantee, reading by id, and what the database holds. Covers 1, 3, 5, 11, 12, 13, 22.
  • The front views, beside each view with its harness and MSW, view tests: the new "My games" and Game views, and the existing Room view tests for keeping and sending the Guest. Covers 2, 14, 15, 16, 18, 19, 20, 21.
  • e2e (apps/e2e): one new spec, and the existing reload and second-tab specs kept green on the Guest. Two Players play; one resigns; each opens "My games" and sees the Game with their own result; one opens it; a third browser context opens its link and reads the result and Moves. Covers 4, 17, and walks 1, 7, 13 end to end.

The engine's PGN change (below) is tested at the engine barrel, in the existing PGN tests. It covers no story of its own; it is what makes story 20 true.

Implementation Decisions

The Guest replaces the Seat token (ADR 0034, the Guest half)

  • The Seat token is removed everywhere: its contract schema and header, the token field of room.create and seat.take, the per-Room storage in the front, and the API's rule that names its header. Every code comment pointing at ADR 0016 is repointed at ADR 0034 or dropped.
  • A redeploy drops every Room (they live in memory, ADR 0015), so the switch needs no migration of live Seats. Leftover per-Room keys in browsers are ignored.
  • The contracts gain a branded Guest secret with no format check, and the header it travels in. An unknown secret is a Guest with nothing to its name, never a validation error (contracts rule 3).
  • The API's RPC context turns the header into the caller's occupant, hashing the secret with SHA-256 on arrival so nothing past the context sees it. The occupant is a union with one variant today, a Guest known by its hash; the Accounts v1 spec adds the Account variant and its precedence over the Guest. Hashing needs no I/O, so resolving the caller stays synchronous and no call reads the database to find out who is calling.
  • room.create and seat.take mint a Guest only when the call carries none. Minting is a constructor option, like every source of non-determinism (API rule 2): at least 128 random bits, URL-safe. The response says which Guest it minted, or that it minted none; the secret crosses the wire only in the response that minted it.
  • seat.take mints only when the Room has a Seat to give. A refused take (NO_FREE_SEAT) returns no Guest, so a Spectator never gets one.
  • The Room keys each occupant by its Guest. Taking a Seat with a Guest that already holds one in that Room hands that Seat back and mints nothing. The Room itself no longer mints anything: it is always handed its occupant.
  • The front keeps the Guest in localStorage under one key of its own, sends it in its header on every call, and replaces it with whatever a response minted. Storage that throws means no lasting Guest: play works, but a reload loses the Seat and history is empty, the same stance as the Seat token had.
  • Entering a Room always asks seat.take (deduplicated per Room code, as today), since the browser can no longer tell from its own storage whether it holds a Seat there. The answer hands back the held Seat, takes a free one, or refuses, and a refusal makes the browser a Spectator (ADR 0015).
  • Leaving a Room forgets nothing: the Guest belongs to the browser, not to the Room.

Recording a decided Game

  • The Room stamps when its current Game started, at open and at each restart, from its injected clock.
  • The Room gains one more constructor option: a callback it calls exactly once when the current Game becomes decided:
    • by a Move whose resulting Status is checkmate or draw;
    • by resign;
    • by accepting a draw Offer.
  • The record passed to the callback holds the UCI line from STARTPOS, how it ended (checkmate or resignation with the winner, or draw with its reason, agreed included), started and ended timestamps, and for each Color the Guest of the occupant of the Seat holding it at that moment, or none for a never-taken or vacated Seat. room.ts stays framework-free (API rule 1).
  • A new games/ feature module in the API owns storage and the game procedures. RoomService wires the Room's callback to it.
  • The write is asynchronous, and the Room never waits on it: the deciding call answers and fans out at once. A failure is logged and dropped; a lost record is accepted, as a lost Room is.
  • The games module keeps its writes still in flight, and game.list waits for them to settle before reading. One API process serves each stack, so this is enough to guarantee story 12 without the Room waiting on the database.
  • The Game id is minted server-side: unguessable, at least 128 random bits, branded GameId, minted through a constructor option.

Schema (on top of the Postgres spec's baseline migration)

  • guests: an id, the secret's hash (unique), created at. A row is written the first time a Guest owns a decided Game, inside the same transaction as that Game, upserted by hash. A Guest that never owns a Game leaves no row, so minting needs no database.
  • games:
    • id;
    • the white Guest and the black Guest, each a nullable reference to guests, emptied if that Guest is ever deleted;
    • line;
    • winner, nullable, and set exactly when the ending is not a draw;
    • ending;
    • draw reason, nullable and set exactly for draw;
    • started at and ended at, as timestamptz.
  • An index on each owner column with ended at, for "My games".
  • No Room code and nothing derived (ADR 0014, ADR 0021).
  • Owner columns are named per occupant kind and per Color, so the Accounts v1 spec adds an Account column beside each Guest column, with its own rule of at most one owner per side, and Claim a Guest rewrites one into the other (ADR 0035). One owner per side keeps "My games" free of joins.

Contract: a new game router in @chess/contracts (ADR 0027).

  • game.list, GET /games: the caller's decided Games, newest first, capped at the 50 most recent. Each item has the id, started and ended, the caller's Color, how it ended, the other side's occupant and the line, from which the front counts Moves. A call with no Guest, or with a Guest that owns nothing, gets an empty list, not an error.
  • game.get, GET /games/{id}: the line, how it ended, both timestamps, each side's occupant, and the viewer: the owner of a Color, or a reader. An unknown id, malformed ones included, is GAME_NOT_FOUND (404); the API treats an id it could not have minted as unknown before it reaches the database.
  • A side's occupant crosses the wire as its kind only, guest or none; the Accounts v1 spec adds account with its username. No hash and no secret ever crosses the wire.
  • Both are reads, so the origin guard passes them without an Origin (ADR 0028), and they change no state (ADR 0030's invariant).
  • How a Game ended is defined once in the contracts, reusing the engine's Draw reasons plus agreed, and shared with RoomOutcome and the front's existing ending type where it fits (root rule 6).

Engine

  • Game.pgn takes the result token from its caller for an ending Status cannot express (Resignation, agreed Draw), since the engine can never produce those (ADR 0017). Without one it derives the token from Status, as today.

Front

  • A new vertical, games/, holding both views, their services and their query options (ADR 0011). Routes: /games ("My games") and /games/$id (the Game page). A "My games" link joins the site navigation.
  • The Guest store sits where both room/ and games/ may import it, since a vertical never imports a sibling. room/'s client sends the Guest header and stores what create and take mint; games/ has its own client, which only sends it.
  • The Game page reuses game/'s game screen read-only: board, player rows, move list, review controls, captured trays and result line. Player rows name the viewer's own side "You" and every other side "Guest" (Last product decisions, point 6). It adds "Export PGN" and "Copy link".
  • PGN export, built in the browser from the line: White and Black tags are "Guest"; the Date tag is when the Game ended; the Site tag is the page URL; the result comes from how it ended; the file is named after the id.
  • Every new string goes in the Paraglide catalogue, in English and French (ADR 0025).
  • The Room screen's player rows keep "You" and "Opponent"; see Further Notes.

ADRs and docs

  • No new ADR: ADR 0034 already decides the Guest, its minting, its hash and the retirement of the Seat token, and its row in the ADR index already governs games/ in both apps. The glossary already has Guest and the Seat token flagged ambiguity.
  • Three rule files still name the Seat token: the API's rule on its header, the e2e rule on each Player holding their own token, and the contracts' example of a branded identifier. Rule edits do not belong in feature work, so the PR body proposes their rewording to Guest for a dedicated PR.

Testing Decisions

  • Prior art:
    • room-play.test.ts and room-offer.test.ts for Room endings, and room.test.ts for seating;
    • rpc.integration.test.ts with rpc.fixtures.ts for the wire, its seatedGame and clientFor moving from tokens to Guests;
    • the Room view tests with their harness and MSW handlers, including the one on what the client sends on a refetch;
    • resign.spec.ts, reload.spec.ts, presence.spec.ts and room.fixtures.ts (seatedPair, spectator, openAnotherTab) for e2e;
    • the engine's PGN tests for the result override.
  • Edge and unhappy paths:
    • every ending: Checkmate, each Draw reason, agreed Draw, Resignation by either Color;
    • a decided Game reported once even if an Offer resolves after;
    • a restart mid-game reports nothing, and a restart after a decided Game starts the next one with a fresh start time;
    • a Seat that changed hands mid-game (the occupant at the end owns it);
    • a vacated or never-taken Seat at the end;
    • create and take with no Guest mint; with a known or unknown Guest they mint nothing; a refused take mints nothing;
    • a Guest taking again in a Room where it already sits gets the same Seat;
    • a call with another browser's Guest missing, or with no header, is not seated;
    • storage failure leaves the deciding call answering and the Room playable;
    • the opponent's list, read the moment the decision lands, already holds the Game;
    • list with no header, with an unknown Guest, and with more than 50 Games;
    • get with an unknown id and with a malformed one;
    • the database holds no secret, only its hash;
    • front storage that throws on read or write.

Out of Scope

  • Accounts, Sessions, an Account holding a Seat, the Account owner columns and usernames on any surface: Accounts v1.
  • Claiming a Guest, its link to an Account, rewriting its Games, and what a claimed Guest may still do: Claim a Guest (ADR 0035).
  • Public listing of Games: Public profile.
  • Names next to each Seat in the Room.
  • Pagination beyond the 50 most recent, search or filters.
  • Local games in the history.
  • Storing Games cut short, or treating abandoned Games and Seats that changed hands specially (revisit with Elo).
  • A link from a finished Room to its stored Game.

Further Notes

> _Imported from GitHub [amaurycoudr/chess-project#355](https://github.com/amaurycoudr/chess-project/issues/355), opened by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-10-11 13:43 UTC._ <!-- github-issue:355 --> --- ## Problem Statement When an **Online game** ends, it is gone: the **Room** keeps only its current **Game**, a restart replaces it, and an eviction or a redeploy wipes the **Room**. A **Player** cannot find a game they won, show it to a friend, or take it to an analysis tool. There are no accounts yet, so the history has to belong to someone who has none. Meanwhile a browser proves its place in a **Room** with a per-**Room** **Seat token**. That secret is worth one **Room** and cannot gather a browser's **Games** across **Rooms**. Keeping it beside a second, browser-wide secret would give every **Player** two identities and leave the accounts to come a transfer flow to build (ADR 0034). ## Solution A browser that creates a **Room** or takes a **Seat** without a **Guest** is given one by the server: a secret it keeps and sends with every call. That **Guest** is the only proof of a **Seat**, in every **Room**; the **Seat token** is gone. Reloading, or opening the **Room** in another tab, still lands the **Player** back in their **Seat**. When an **Online game** is decided (by **Checkmate**, a **Draw** of any reason, an agreed **Draw** or a **Resignation**), the server stores it once. The stored **Game** keeps the **Moves** as one **UCI** line, who won and how it ended, when it started and ended, and the **Guest** in each **Seat** at that moment. A **Player** opens "My games" to see the decided **Games** of this browser, newest first, the opponent shown as "Guest". Every stored **Game** has a page that anyone with its link can read (board, move list, review controls, result) and export as PGN. A **Game** cut short (restart mid-game, **Room** evicted, redeploy) is not stored, and **Local games** stay in the browser. ## User Stories 1. As a **Player** creating a **Room** or taking a **Seat** with no **Guest**, I want the server to give my browser one, so that one identity holds my **Seats** and owns my **Games** across **Rooms** and visits. 2. As a **Player**, I want my browser to keep its **Guest** and send it with every call in a header, never in a URL, so that sharing a link never shares my identity. 3. As a **Spectator**, I want to get no **Guest**, even though opening a full **Room** asks it for a **Seat**, so that watching never makes me the owner of anything. 4. As a **Player**, I want a reload or a second tab of the same browser to land back in my **Seat**, with no secret kept per **Room**, so that the **Guest** alone proves where I sit. 5. As a **Player**, I want a browser that knows only the **Room code** to be unable to act for my **Seat**, so that the invite never plays my **Moves**. 6. As a **Player** whose **Guest** already holds a **Seat** in a **Room**, I want opening that **Room** again to give me that **Seat** back, never the other one, so that I hold at most one **Seat** per **Room**. 7. As a **Player**, I want a decided **Online game** stored once with the **Guest** in each **Seat** when it ended, so that both of us own it, even if a **Seat** changed hands mid-game. 8. As a **Player**, I want a **Game** that ended by **Checkmate**, by any **Draw** reason, by an agreed **Draw** or by **Resignation** stored with how it ended and who won, so that the record says what happened. 9. As a **Player**, I want a **Game** cut short by a restart, an eviction or a redeploy not stored, so that "My games" holds only decided **Games**. 10. As a **Player** whose opponent's **Seat** was vacated or never taken when the **Game** ended, I want the **Game** stored with only my side owned, so that the **Game** still counts for me. 11. As a **Player**, I want the **Room** to carry on even if storing the **Game** fails, so that a database problem never interrupts play. 12. As either **Player**, I want the **Game** to be in "My games" as soon as the **Room** shows it decided, so that opening the list right after the end never shows it missing. 13. As a **Player**, I want "My games" to list this browser's decided **Games**, newest first, so that I can find a recent one. 14. As a **Player**, I want each row to show when it ended, my **Color**, the opponent as "Guest" with their **Color**, my result (won, lost, drawn) and how it ended, so that I recognise the **Game** without opening it. 15. As a **Player** with no **Games** yet, or whose browser has no **Guest**, I want an empty "My games" that says why, so that an empty list doesn't look broken. 16. As a **Player**, I want "My games" reachable from the site's navigation, so that I can get to it from any page. 17. As anyone with a **Game**'s link, I want to read it (board, move list, review controls and result) without a **Guest**, so that a **Player** can share a **Game** with a friend. 18. As a **Player** opening my own **Game**, I want the board oriented to my **Color**, my side labelled "You" and the other "Guest"; anyone else gets White at the bottom and both sides "Guest", so that it reads as it was played. 19. As anyone opening a link to an unknown **Game**, I want a clear not-found page, so that a typo doesn't look like a crash. 20. As anyone reading a **Game**, I want to export it as a PGN file whose players are "Guest" and whose result matches how it ended (resignation and agreed draw included), so that I can open it in any chess tool. 21. As a **Player**, I want to copy a **Game**'s link from its page, so that sharing takes one click. 22. As the owner, I want the server to store only a hash of each **Guest** secret, so that a stolen dump or the Gateway shows no secret that could hold a **Seat** or claim **Games** later. ## Seams - **The `Room`**, through its public methods with `room.fixtures.ts`, unit. The **Room** seats occupants by **Guest**, and reports a decided **Game** exactly once, for every ending, with its line, how it ended, its timestamps and the **Guest** in each **Seat** at the end. Covers 6, 7, 8, 9, 10. - **The RPC wire**, through the real app in process with `rpc.fixtures.ts` on the in-process database the Postgres spec gives it, integration. Minting on create and take and never on a refusal, acting for a **Seat** with and without its **Guest**, storage failure, the list and its read-after-decide guarantee, reading by id, and what the database holds. Covers 1, 3, 5, 11, 12, 13, 22. - **The front views**, beside each view with its harness and MSW, view tests: the new "My games" and **Game** views, and the existing **Room** view tests for keeping and sending the **Guest**. Covers 2, 14, 15, 16, 18, 19, 20, 21. - **e2e** (`apps/e2e`): one new spec, and the existing reload and second-tab specs kept green on the **Guest**. Two **Players** play; one resigns; each opens "My games" and sees the **Game** with their own result; one opens it; a third browser context opens its link and reads the result and **Moves**. Covers 4, 17, and walks 1, 7, 13 end to end. The engine's PGN change (below) is tested at the engine barrel, in the existing PGN tests. It covers no story of its own; it is what makes story 20 true. ## Implementation Decisions **The Guest replaces the Seat token** (ADR 0034, the Guest half) - The **Seat token** is removed everywhere: its contract schema and header, the `token` field of `room.create` and `seat.take`, the per-**Room** storage in the front, and the API's rule that names its header. Every code comment pointing at ADR 0016 is repointed at ADR 0034 or dropped. - A redeploy drops every **Room** (they live in memory, ADR 0015), so the switch needs no migration of live **Seats**. Leftover per-**Room** keys in browsers are ignored. - The contracts gain a branded **Guest** secret with no format check, and the header it travels in. An unknown secret is a **Guest** with nothing to its name, never a validation error (contracts rule 3). - The API's RPC context turns the header into the caller's occupant, hashing the secret with SHA-256 on arrival so nothing past the context sees it. The occupant is a union with one variant today, a **Guest** known by its hash; the Accounts v1 spec adds the **Account** variant and its precedence over the **Guest**. Hashing needs no I/O, so resolving the caller stays synchronous and no call reads the database to find out who is calling. - `room.create` and `seat.take` mint a **Guest** only when the call carries none. Minting is a constructor option, like every source of non-determinism (API rule 2): at least 128 random bits, URL-safe. The response says which **Guest** it minted, or that it minted none; the secret crosses the wire only in the response that minted it. - `seat.take` mints only when the **Room** has a **Seat** to give. A refused take (`NO_FREE_SEAT`) returns no **Guest**, so a **Spectator** never gets one. - The **Room** keys each occupant by its **Guest**. Taking a **Seat** with a **Guest** that already holds one in that **Room** hands that **Seat** back and mints nothing. The **Room** itself no longer mints anything: it is always handed its occupant. - The front keeps the **Guest** in `localStorage` under one key of its own, sends it in its header on every call, and replaces it with whatever a response minted. Storage that throws means no lasting **Guest**: play works, but a reload loses the **Seat** and history is empty, the same stance as the **Seat token** had. - Entering a **Room** always asks `seat.take` (deduplicated per **Room code**, as today), since the browser can no longer tell from its own storage whether it holds a **Seat** there. The answer hands back the held **Seat**, takes a free one, or refuses, and a refusal makes the browser a **Spectator** (ADR 0015). - Leaving a **Room** forgets nothing: the **Guest** belongs to the browser, not to the **Room**. **Recording a decided Game** - The `Room` stamps when its current **Game** started, at open and at each restart, from its injected clock. - The `Room` gains one more constructor option: a callback it calls exactly once when the current **Game** becomes decided: - by a **Move** whose resulting **Status** is `checkmate` or `draw`; - by `resign`; - by accepting a `draw` **Offer**. - The record passed to the callback holds the UCI line from **STARTPOS**, how it ended (`checkmate` or `resignation` with the winner, or `draw` with its reason, `agreed` included), started and ended timestamps, and for each **Color** the **Guest** of the occupant of the **Seat** holding it at that moment, or none for a never-taken or vacated **Seat**. `room.ts` stays framework-free (API rule 1). - A new `games/` feature module in the API owns storage and the `game` procedures. `RoomService` wires the **Room**'s callback to it. - The write is asynchronous, and the **Room** never waits on it: the deciding call answers and fans out at once. A failure is logged and dropped; a lost record is accepted, as a lost **Room** is. - The games module keeps its writes still in flight, and `game.list` waits for them to settle before reading. One API process serves each stack, so this is enough to guarantee story 12 without the **Room** waiting on the database. - The **Game** id is minted server-side: unguessable, at least 128 random bits, branded `GameId`, minted through a constructor option. **Schema** (on top of the Postgres spec's baseline migration) - `guests`: an id, the secret's hash (unique), created at. A row is written the first time a **Guest** owns a decided **Game**, inside the same transaction as that **Game**, upserted by hash. A **Guest** that never owns a **Game** leaves no row, so minting needs no database. - `games`: - id; - the white **Guest** and the black **Guest**, each a nullable reference to `guests`, emptied if that **Guest** is ever deleted; - line; - winner, nullable, and set exactly when the ending is not a draw; - ending; - draw reason, nullable and set exactly for `draw`; - started at and ended at, as timestamptz. - An index on each owner column with ended at, for "My games". - No **Room code** and nothing derived (ADR 0014, ADR 0021). - Owner columns are named per occupant kind and per **Color**, so the Accounts v1 spec adds an **Account** column beside each **Guest** column, with its own rule of at most one owner per side, and Claim a Guest rewrites one into the other (ADR 0035). One owner per side keeps "My games" free of joins. **Contract**: a new `game` router in `@chess/contracts` (ADR 0027). - `game.list`, `GET /games`: the caller's decided **Games**, newest first, capped at the 50 most recent. Each item has the id, started and ended, the caller's **Color**, how it ended, the other side's occupant and the line, from which the front counts **Moves**. A call with no **Guest**, or with a **Guest** that owns nothing, gets an empty list, not an error. - `game.get`, `GET /games/{id}`: the line, how it ended, both timestamps, each side's occupant, and the viewer: the owner of a **Color**, or a reader. An unknown id, malformed ones included, is `GAME_NOT_FOUND` (404); the API treats an id it could not have minted as unknown before it reaches the database. - A side's occupant crosses the wire as its kind only, `guest` or `none`; the Accounts v1 spec adds `account` with its username. No hash and no secret ever crosses the wire. - Both are reads, so the origin guard passes them without an Origin (ADR 0028), and they change no state (ADR 0030's invariant). - How a **Game** ended is defined once in the contracts, reusing the engine's **Draw** reasons plus `agreed`, and shared with `RoomOutcome` and the front's existing ending type where it fits (root rule 6). **Engine** - `Game.pgn` takes the result token from its caller for an ending **Status** cannot express (**Resignation**, agreed **Draw**), since the engine can never produce those (ADR 0017). Without one it derives the token from **Status**, as today. **Front** - A new vertical, `games/`, holding both views, their services and their query options (ADR 0011). Routes: `/games` ("My games") and `/games/$id` (the **Game** page). A "My games" link joins the site navigation. - The **Guest** store sits where both `room/` and `games/` may import it, since a vertical never imports a sibling. `room/`'s client sends the **Guest** header and stores what create and take mint; `games/` has its own client, which only sends it. - The **Game** page reuses `game/`'s game screen read-only: board, player rows, move list, review controls, captured trays and result line. Player rows name the viewer's own side "You" and every other side "Guest" (Last product decisions, point 6). It adds "Export PGN" and "Copy link". - PGN export, built in the browser from the line: White and Black tags are "Guest"; the Date tag is when the **Game** ended; the Site tag is the page URL; the result comes from how it ended; the file is named after the id. - Every new string goes in the Paraglide catalogue, in English and French (ADR 0025). - The **Room** screen's player rows keep "You" and "Opponent"; see Further Notes. **ADRs and docs** - No new ADR: ADR 0034 already decides the **Guest**, its minting, its hash and the retirement of the **Seat token**, and its row in the ADR index already governs `games/` in both apps. The glossary already has **Guest** and the **Seat token** flagged ambiguity. - Three rule files still name the **Seat token**: the API's rule on its header, the e2e rule on each **Player** holding their own token, and the contracts' example of a branded identifier. Rule edits do not belong in feature work, so the PR body proposes their rewording to **Guest** for a dedicated PR. ## Testing Decisions - Prior art: - `room-play.test.ts` and `room-offer.test.ts` for **Room** endings, and `room.test.ts` for seating; - `rpc.integration.test.ts` with `rpc.fixtures.ts` for the wire, its `seatedGame` and `clientFor` moving from tokens to **Guests**; - the **Room** view tests with their harness and MSW handlers, including the one on what the client sends on a refetch; - `resign.spec.ts`, `reload.spec.ts`, `presence.spec.ts` and `room.fixtures.ts` (`seatedPair`, `spectator`, `openAnotherTab`) for e2e; - the engine's PGN tests for the result override. - Edge and unhappy paths: - every ending: **Checkmate**, each **Draw** reason, agreed **Draw**, **Resignation** by either **Color**; - a decided **Game** reported once even if an **Offer** resolves after; - a restart mid-game reports nothing, and a restart after a decided **Game** starts the next one with a fresh start time; - a **Seat** that changed hands mid-game (the occupant at the end owns it); - a vacated or never-taken **Seat** at the end; - create and take with no **Guest** mint; with a known or unknown **Guest** they mint nothing; a refused take mints nothing; - a **Guest** taking again in a **Room** where it already sits gets the same **Seat**; - a call with another browser's **Guest** missing, or with no header, is not seated; - storage failure leaves the deciding call answering and the **Room** playable; - the opponent's list, read the moment the decision lands, already holds the **Game**; - list with no header, with an unknown **Guest**, and with more than 50 **Games**; - get with an unknown id and with a malformed one; - the database holds no secret, only its hash; - front storage that throws on read or write. ## Out of Scope - **Accounts**, **Sessions**, an **Account** holding a **Seat**, the **Account** owner columns and usernames on any surface: Accounts v1. - Claiming a **Guest**, its link to an **Account**, rewriting its **Games**, and what a claimed **Guest** may still do: Claim a Guest (ADR 0035). - Public listing of **Games**: Public profile. - Names next to each **Seat** in the **Room**. - Pagination beyond the 50 most recent, search or filters. - **Local games** in the history. - Storing **Games** cut short, or treating abandoned **Games** and **Seats** that changed hands specially (revisit with Elo). - A link from a finished **Room** to its stored **Game**. ## Further Notes - Map: [Wayfinder: accounts](https://github.com/amaurycoudr/chess-project/issues/346). Decisions: [What a finished Game keeps, and which guest identity owns it](https://github.com/amaurycoudr/chess-project/issues/348), [How does an account relate to the Seat token?](https://github.com/amaurycoudr/chess-project/issues/351), [Last product decisions before the account specs](https://github.com/amaurycoudr/chess-project/issues/362), and for what the schema leaves room for, [How are guest games merged into an account?](https://github.com/amaurycoudr/chess-project/issues/352). - Revised in place; the earlier version was written before ADR 0034 retired the **Seat token** and before the names decision. - Blocked by [Postgres in place: a database per stack, migrations, backup and Drizzle Gateway](https://github.com/amaurycoudr/chess-project/issues/354). - Left to Accounts v1: the **Session** as a second proof of a **Seat** that wins over the **Guest**; minting no **Guest** when a **Session** is present; the **Account** variant of the occupant and of a side on the wire; the **Account** owner columns; "My games" for an **Account**; usernames in "My games", on the **Game** page, in PGN tags and next to each **Seat** in the **Room**; a deleted **Account**'s side reading as none. - A **Guest** whose browser storage is cleared loses its **Seats** and its "My games": its **Games** stay stored but can no longer be claimed. Accepted in [What a finished Game keeps, and which guest identity owns it](https://github.com/amaurycoudr/chess-project/issues/348); accounts are the fix. - Likely more than one PR (the **Guest** replacing the **Seat token**; recording and the `game` wire; front views; e2e). Split with `to-tickets`.
root commented 2026-10-11 13:43:33 +00:00 (Migrated from gitlab.core-project.space)

GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links).

> _GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links)._ <!-- github-relations --> - Sub-issues: #372, #373, #374, #375, #376, #377 - Blocked by: #354
root commented 2026-10-11 16:10:28 +00:00 (Migrated from gitlab.core-project.space)

Imported from a GitHub comment by @amaurycoudr on 2026-10-11 16:10 UTC.

Revised in place to follow ADR 0034, which supersedes ADRs 0016 and 0033, and Last product decisions before the account specs. The title changed to match. What changed:

  • Seat token retired: it is removed from the contract, the header, front storage and the create/take output. The Guest is now the only proof of a Seat. Entering a Room always calls seat.take.
  • Minting: the server mints the Guest in create and take, only when the call carries none, and never on a refused take, so Spectators get none. The front no longer mints one.
  • Schema: a guests table (id, unique hash, created at) is written when a Guest first owns a decided Game. games now references a Guest per Color instead of storing hashes inline, so Accounts v1 can add an Account owner beside it.
  • Wire: each side carries its occupant kind (guest, or none for an ownerless side; Accounts v1 adds account). you is now a viewer union. A malformed id answers GAME_NOT_FOUND.
  • New guarantee: "My games" lists a Game as soon as the Room shows it decided.
  • Names: "You" for your own side and "Guest" for every other side, including an ownerless one. The Room keeps "You" and "Opponent" until Accounts v1.
  • New stories: the Guest proves a Seat, the Room code alone can't act for one, a Guest holds at most one Seat per Room, and read-after-decide.
> _Imported from a [GitHub comment](https://github.com/amaurycoudr/chess-project/issues/355#issuecomment-6110982722) by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-10-11 16:10 UTC._ <!-- github-comment:6110982722 --> Revised in place to follow [ADR 0034](../blob/main/docs/adr/0034-a-guest-or-an-account-holds-a-seat-and-owns-its-games.md), which supersedes ADRs 0016 and 0033, and [Last product decisions before the account specs](https://github.com/amaurycoudr/chess-project/issues/362). The title changed to match. What changed: - **Seat token retired:** it is removed from the contract, the header, front storage and the create/take output. The **Guest** is now the only proof of a **Seat**. Entering a **Room** always calls `seat.take`. - **Minting:** the server mints the **Guest** in create and take, only when the call carries none, and never on a refused take, so **Spectators** get none. The front no longer mints one. - **Schema:** a `guests` table (id, unique hash, created at) is written when a **Guest** first owns a decided **Game**. `games` now references a **Guest** per **Color** instead of storing hashes inline, so Accounts v1 can add an **Account** owner beside it. - **Wire:** each side carries its occupant kind (`guest`, or `none` for an ownerless side; Accounts v1 adds `account`). `you` is now a viewer union. A malformed id answers `GAME_NOT_FOUND`. - **New guarantee:** "My games" lists a **Game** as soon as the **Room** shows it decided. - **Names:** "You" for your own side and "Guest" for every other side, including an ownerless one. The **Room** keeps "You" and "Opponent" until Accounts v1. - **New stories:** the **Guest** proves a **Seat**, the **Room** code alone can't act for one, a **Guest** holds at most one **Seat** per **Room**, and read-after-decide.
root commented 2026-10-13 08:39:44 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #372

mentioned in issue #372
root commented 2026-10-13 08:39:45 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #373

mentioned in issue #373
root commented 2026-10-13 08:39:45 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #374

mentioned in issue #374
root commented 2026-10-13 08:39:45 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #375

mentioned in issue #375
root commented 2026-10-13 08:39:46 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #376

mentioned in issue #376
root commented 2026-10-13 08:39:46 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #377

mentioned in issue #377
root commented 2026-10-13 08:41:20 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #354

mentioned in issue #354
root commented 2026-10-13 08:42:26 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #354

marked this issue as related to #354
root commented 2026-10-13 08:42:26 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #372

marked this issue as related to #372
root commented 2026-10-13 08:42:27 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #373

marked this issue as related to #373
root commented 2026-10-13 08:42:27 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #374

marked this issue as related to #374
root commented 2026-10-13 08:42:27 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #375

marked this issue as related to #375
root commented 2026-10-13 08:42:28 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #376

marked this issue as related to #376
root commented 2026-10-13 08:42:28 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #377

marked this issue as related to #377
root commented 2026-10-13 10:19:24 +00:00 (Migrated from gitlab.core-project.space)

mentioned in commit 6ba8bbcfa7

mentioned in commit 6ba8bbcfa7d87b07619ab4837e686e74ce3440df
root commented 2026-10-13 14:34:16 +00:00 (Migrated from gitlab.core-project.space)

mentioned in merge request !515

mentioned in merge request !515
root commented 2026-10-13 14:51:32 +00:00 (Migrated from gitlab.core-project.space)

mentioned in commit 57d73a71bc

mentioned in commit 57d73a71bccf87ce8bcb7821e169782f9154d4e9
root commented 2026-10-13 15:31:19 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #393

mentioned in issue #393
root commented 2026-10-13 15:31:31 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #393

marked this issue as related to #393
root commented 2026-10-14 07:52:24 +00:00 (Migrated from gitlab.core-project.space)

mentioned in commit b613f6452d

mentioned in commit b613f6452dcdff6f4fe1f91e40b5333e89573201
root commented 2026-10-14 09:31:44 +00:00 (Migrated from gitlab.core-project.space)

mentioned in commit dc15bc4fb6

mentioned in commit dc15bc4fb60c5bb510825cb92d369f8be60ba3ed
root (Migrated from gitlab.core-project.space) closed this issue 2026-10-14 09:31:57 +00:00
root commented 2026-10-14 09:55:14 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #394

mentioned in issue #394
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amaurycoudr/chess-project#355
No description provided.