Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
699720c
feat(persistence): replace studies.variant CHECK constraint with FK t…
hessiun710 Aug 31, 2026
55ba0b6
test(persistence): isolate variant deletion test to custom variant fo…
hessiun710 Aug 31, 2026
883ef9c
fix(persistence): split FK addition and validation into migrations 00…
hessiun710 Aug 31, 2026
de8d872
test(persistence): harden test variant insertion and deletion cleanup…
hessiun710 Aug 31, 2026
0be6410
fix(scripts): refine variant foreign-key tracking by table/inline for…
hessiun710 Aug 31, 2026
3206735
fix(scripts): capture explicit inline foreign key constraint name and…
hessiun710 Aug 31, 2026
c0bd734
fix(scripts): enforce foreign key verification in parity CLI and rest…
hessiun710 Aug 31, 2026
52d740e
fix(scripts): track active foreign keys as a Set of constraint names
hessiun710 Aug 31, 2026
70bf768
fix(scripts): support multi-drop constraint statements and preserve c…
hessiun710 Aug 31, 2026
7794db2
fix(scripts): support comma-separated ADD CONSTRAINT in parity checke…
hessiun710 Aug 31, 2026
62d910c
fix(scripts): clear active constraints on studies.variant column rena…
hessiun710 Aug 31, 2026
091361b
fix(scripts): clear constraints on table lifecycle events in parity c…
hessiun710 Aug 31, 2026
75ab07c
fix(scripts): support quoted FK identifiers, unnamed FK multiplicity,…
hessiun710 Aug 31, 2026
a1bdbd2
fix(scripts): require exact variant identifier in inline FK regex and…
hessiun710 Aug 31, 2026
e93b3a0
fix(scripts): clear active foreign keys on DROP TABLE variants CASCAD…
hessiun710 Aug 31, 2026
6790c7f
fix(scripts): select lowest available implicit FK constraint name fro…
hessiun710 Aug 31, 2026
19159a7
fix(scripts): support multi-table cascaded DROP TABLE and add test
hessiun710 Aug 31, 2026
3c80a9f
fix(scripts): implement SQL tokenizer and schema replayer for variant…
hessiun710 Aug 31, 2026
578ef3f
fix(scripts): support arbitrary inline FK column types, collect all a…
hessiun710 Aug 31, 2026
14c52b4
fix(scripts): release dropped constraint names from namespace on casc…
hessiun710 Aug 31, 2026
439b110
fix(scripts): support multi-constraint column definitions and depende…
hessiun710 Aug 31, 2026
55920a6
fix(scripts): support conditional creation and enforce compound check…
hessiun710 Aug 31, 2026
01fba65
fix(scripts): skip ALTER TABLE IF EXISTS actions when studies table i…
hessiun710 Aug 31, 2026
22a5b9d
fix(scripts): fail loudly on unsupported check predicate shapes on st…
hessiun710 Aug 31, 2026
9983b6e
fix(scripts): parse strict IN-list literals rejecting operators and e…
hessiun710 Aug 31, 2026
9af7cda
fix(scripts): preserve constraint state across table rename round-trips
hessiun710 Aug 31, 2026
2ef10e7
test(scripts): add column constraint permutation and lifecycle regres…
hessiun710 Aug 31, 2026
3fdb278
fix(scripts): isolate table schema state and preserve quoted identifi…
hessiun710 Aug 31, 2026
88e7319
fix(scripts): qualify table state keys by schema to isolate public.st…
hessiun710 Aug 31, 2026
5d53e29
fix(scripts): support ALTER TABLE SET SCHEMA during schema replay
hessiun710 Aug 31, 2026
6aeeb97
fix(scripts): support dollar-quoted SQL bodies and structured table i…
hessiun710 Aug 31, 2026
286d2e1
fix(scripts): support Unicode dollar tags and fail loudly on procedur…
hessiun710 Sep 1, 2026
7e3d384
fix(scripts): enforce fail-closed procedural DO policy with cryptogra…
hessiun710 Sep 1, 2026
446d3dc
fix(persistence): close variant catalog domain
hessiun710 Sep 2, 2026
913330a
fix(persistence): harden variant transition review
hessiun710 Sep 2, 2026
172df30
fix(scripts): parse variant constraint options
hessiun710 Sep 2, 2026
f4e3c19
fix(scripts): reject unenforced study checks
hessiun710 Sep 2, 2026
ad8089d
fix(scripts): bound create table replay
hessiun710 Sep 2, 2026
6745f8b
fix(scripts): reject unenforced inline foreign keys
hessiun710 Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ constraints**, and *deliberately not* native Postgres `ENUM` types.
enum ordering is definition-order, not semantic. For a platform that will add
variants and refine terminations over years, this rigidity is a liability.
- **Lookup tables** (a `code TEXT PRIMARY KEY` catalog + FK) for vocabularies that
**evolve or carry metadata**: `variants` (add a variant → one seed row, referential
integrity everywhere it's used) and `terminations`. This gives FK enforcement,
a natural place for display names/flags, and trivial extension.
carry metadata: `variants` and `terminations`. The variants catalog also has a
canonical-domain `CHECK`, so adding a variant is a coordinated application +
constraint + seed migration, not an arbitrary catalog insert. This keeps FK
enforcement and display metadata without allowing database/application drift.
- **`CHECK` constraints** for **small, fixed, security- or protocol-defined** sets
where a whole table is overkort and the set changes only with a code+migration
change anyway: `speed`, `result`, `role`, credential `kind`. A `CHECK` is easy to
Expand Down Expand Up @@ -236,10 +237,14 @@ game was recorded, and keeps upgrades backward-compatible and reversible.
### 4.1 Lookup / catalog tables

```sql
CREATE TABLE variants ( -- evolving vocabulary (add a variant = 1 seed row)
CREATE TABLE variants ( -- closed application vocabulary, migration-evolved
code TEXT PRIMARY KEY, -- 'standard','chess960','kingofthehill',...
name TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true
enabled BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT variants_code_check CHECK (code IN (
'standard', 'chess960', 'kingofthehill', 'atomic',
'crazyhouse', 'threecheck', 'horde', 'racingkings'
))
);
CREATE TABLE terminations ( -- evolving/annotated vocabulary
code TEXT PRIMARY KEY, -- 'checkmate','resignation','timeout',...
Expand Down
29 changes: 28 additions & 1 deletion docs/PROJECT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,34 @@
> to read **only this file** and continue immediately. Updated after every
> milestone and every significant architectural step.

_Last updated: 2026-08-29 — M15 Increment 41: Chess960 production integration (ADR-0137)._
_Last updated: 2026-09-02 — M15 Increment 42: Closed variant catalog and studies FK integrity._

## M15 Increment 42 — Closed variant catalog and studies FK integrity

`variants(code)` is now a closed database domain matching the application's eight canonical
variants, and `studies.variant` derives from that catalog via `studies_variant_fk`. Migration `0028`
idempotently restores missing canonical catalog rows and adds `variants_code_check NOT VALID`;
`0029` validates the catalog before `0030` installs the studies FK and removes the duplicated inline
CHECK from migration `0022`; `0031` validates the FK.

This completes the database integrity conversion candidate originally deferred in M15 Increment 10
("Decided and not done: studies.variant stays a CHECK, for now"). Historical entries in earlier
increment logs record the pre-migration state when the column was governed by a CHECK constraint.

All database variant columns (`games.variant`, `ratings.variant`, `seeks.variant`, and
`studies.variant`) now share identical relational integrity semantics:
- Inserting a noncanonical `variants.code` is rejected with SQLSTATE `23514`; a lookup insert cannot
silently broaden the application's variant domain.
- Inserting an unsupported variant code into `studies.variant` is rejected by PostgreSQL with SQLSTATE
`23503` (`foreign_key_violation`) referencing `studies_variant_fk`.
- Missing canonical lookup rows are reconstructed without overwriting existing metadata. Existing
noncanonical rows fail catalog validation and are neither deleted nor rewritten.
- Existing study rows retain `NOT NULL DEFAULT 'standard'`.
- Foreign key semantics use default `NO ACTION` to protect `variants(code)` against accidental deletions
while referenced by active studies.
- `scripts/check-variant-parity.mjs` verifies the lookup seed and catalog CHECK against `Variant`,
requires the catalog CHECK and studies FK to be validated in later migrations, rejects unsafe
ordering, and forbids a surviving studies-specific CHECK mirror.

## M15 Increment 41 — Chess960 production integration (ADR-0137)

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The correctness-critical foundation everything else depends on.
- **Perft suites for each variant (RESOLVED in Increment 32 / ADR-0098 and completed in Increment 42).** All eight variants now have perft verification against published reference vectors or equality/divergence invariants. `horde` and `racingkings` coverage added from official `lichess-org/scalachess` perft resources (ADR-0098), resolving variant-rule defects in Horde rank-1 pawn double pushes and Racing Kings 8th-rank goal turn semantics.
- **Chess960 was a label with nothing behind it (RESOLVED in M15 Increment 41 / ADR-0137; rules in ADR-0136).** Bigger than the "castling-by-file" wording suggested, and verified by running the code. (1) `Position.initial('chess960')` returns the standard array on every call, and `packages/game/src/game.ts:92` uses it for any seek without an explicit FEN — so a Chess960 game was ordinary chess. (2) `generateCastles` in `packages/chess-core/src/movegen.ts` pins the king to e1/e8 and looks for rooks at fixed offsets, so castling generates for exactly one of the 960 start positions, and that one is standard chess: king on b1 with rooks a1/h1 produces 0 castling moves, as does king g1 with rooks f1/h1. (3) `packages/chess-core/src/fen.ts` discards file-letter castling rights, so `HAha` on kiwipete gives `perft(1) = 46`, identical to no rights, against 48 for `KQkq`. **Withheld in Increment 33 (ADR-0099):** removed from the lobby's offered variants so nobody receives a mislabelled standard game; still accepted by the API and still a rule set in `chess-core`. **Open:** implementing it — 960-position generation, castling from arbitrary king and rook squares, Shredder/X-FEN in and out, the UCI king-takes-rook encoding, SAN, and perft against published values. **Server contract closed in M15 Increment 14 (ADR-0123):** withholding it in the lobby only protected browser users — `OFFERED_VARIANTS` is a list in the web bundle, and every other client (script, bot, mobile, curl) still reached `Game.create`, which wrote `variant: 'chess960'` beside a standard `initialFen` into an append-only event store. That is a durable falsehood, not a UI wart: afterwards nothing can tell such a row from a real Chess960 game. `Game.create` now refuses the variant outright — the one place every game is born, so seek acceptance, the bot route and the tournament launcher all inherit it — and `CREATABLE_VARIANTS` carries the same rule at the three creation routes so the refusal arrives as the API’s ordinary 422 rather than the 500 an unmapped `GameError` would produce. Seek acceptance re-checks the stored variant (409) because that value comes from a row, not a request. `chess960` remains valid everywhere that reads — the enum, the `variants` table, and every View schema — and only the three Request schemas narrowed. **Rules implemented in ADR-0136:** all 960 arrangements from the Scharnagl numbering, castling from arbitrary king and rook squares, Shredder-FEN in and canonical X-FEN out, the UCI king-takes-rook encoding, SAN unchanged, and perft against all 960 published reference positions — with the refusal deliberately kept, because the engine could now play any arrangement but nothing could yet *tell* it which one. **RESOLVED in M15 Increment 41 (ADR-0137):** `GameCreated` carries an optional `chess960StartId`, and the server draws it — `crypto.randomInt` at seek acceptance and on the bot route, derived from the launch identity for tournaments, so racing replicas agree on the arrangement instead of each drawing their own. Replay validates the stored id against the stored FEN rather than trusting either alone, and a legacy `chess960` event with no id replays from its FEN and reports its start as unknown, never as 518 — the guess that would look plausible. `Game.create` requires the id for the variant and refuses it for every other; `CREATABLE_VARIANTS` and `OFFERED_VARIANTS` admit `chess960`; `openapi.json` is regenerated. The seek-accept 409 is *kept* rather than removed as the checklist said, because `seek.variant` is read from a database column and the type system does not span the SQL (ADR-0137 §6). Move *input* needed no Chess960 logic in the browser — `BoardInteraction` is oracle-driven and the server's legal-move map already spells castling king-takes-rook — but move *projection* did: `applyMove` advances the client's own board between snapshots and recognised castling only at exactly two files, projecting `d1a1` as a king on a1 with the rook deleted. It now treats a king landing on a friendly rook as a castle (ADR-0137 §8). **Nothing left open on the variant.**
- **`Position.snapshot()` lost three-check state (RESOLVED in M15 Increment 8).** `snapshot()` in `packages/chess-core/src/position.ts` round-tripped through `parseFen(this.fen(), variant)`, and `toFen` does not serialise `checkCount`, so both counters reset to zero. Found during the Increment 33 audit (ADR-0099 §4) and recorded there as "latent, not live" on the grounds that a repetition key uses only the first four FEN fields. **That assessment was wrong.** `packages/chess-core/src/repetition.ts` had appended the delivered-check counters to the key for `threecheck` since 2026-07-13 — three weeks before the audit — and `packages/game/src/game.ts` builds that key from the lossy snapshot on both the live and replay paths. Every three-check position therefore reported `0+0`, and a board that repeated while the check counts climbed was treated as a repetition: `Re1+ Kf8 Rd1 Ke8 Re1+ Kf8 Rd1 Ke8` was declared a threefold draw with White one check from winning. **Resolved in M15 Increment 8:** `snapshot()` returns `cloneState(this.state)`, the existing authoritative deep copy, so no `PositionState` field is dropped; the line above now continues and White wins `1-0` on the third check. Serialising three-check counters into FEN was deferred to M15 Increment 9 and is **RESOLVED** there (ADR-0120): `toFen` emits the canonical Fairy-Stockfish field — `N+M` remaining, in field five — and `parseFen` accepts that, the trailing `+N+M` delivered form, and the legacy six-field form. The engine defect it was hiding is closed with it: Fairy-Stockfish 14 reads a missing counter field as `1+1`, so every three-check analysis had been scored as though one check won the game.
- **The supported-variant list is written out seven times (GUARDED in M15 Increment 10).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, the `variants` lookup table, and the `CHECK` on `studies.variant` added by Increment 9 — seven hand-maintained copies, none derived from another. The type system does not span the SQL: with a ninth variant added to every TypeScript site and to the `variants` lookup table but not to the `CHECK`, `npm run build` exits 0 and `npm run lint` is clean; the test suite then reports exactly one failure, and it is the wrong one — a stale `openapi.json`, which says "regenerate me" rather than "the database will reject this". After the regeneration a developer obviously runs, the suite passes with nothing red, and the variant fails as a constraint violation in production on the first study created with it. `scripts/check-variant-parity.mjs` compares the six mirrors to `chess-core`'s `Variant` and runs in CI beside the other static guards. It replays the migration directory rather than reading 0001 and 0022, because applied migrations are checksummed and immutable and a new variant arrives in a new file; it strips comments before matching, so a commented-out entry cannot pass as live. Both properties are pinned by `scripts/test/check-variant-parity.test.mjs`. **Open:** converting `studies.variant` to `REFERENCES variants(code)` like every other variant column, which would remove one copy — deferred deliberately, since it does not change the failure mode the guard closes.
- **The supported-variant list is written out across mirrors (GUARDED in M15 Increment 10; RESOLVED in M15 Increment 42).** `Variant` in `chess-core`, `VARIANTS` in the API, `StudyVariant` in studies, `SUPPORTED_VARIANTS` in ai-features, `VARIANTS` in the web client, and the `variants` lookup table — originally seven hand-maintained copies (including an inline `CHECK` on `studies.variant`), none derived from another. The type system does not span the SQL, so `scripts/check-variant-parity.mjs` replays the immutable migration history and compares every active mirror to `Variant`. **Resolved in M15 Increment 42:** migrations `0028`–`0031` restore missing canonical catalog rows, install and validate a closed eight-value CHECK on `variants(code)`, then replace the duplicated studies CHECK with a separately validated FK. Unsupported legacy catalog rows stop validation without being rewritten or deleted; a catalog insert alone can no longer broaden any FK consumer. The guard now verifies the seed, catalog CHECK, safe validation order, final studies FK, and absence of the obsolete studies CHECK.
- **CI depends on the Ubuntu package mirror for Stockfish (RESOLVED in M15 Increment 11 / ADR-0121).** The `analysis smoke` job apt-installs Stockfish, and that step has now stalled indefinitely three times: once on PR #140 (cancelled and re-run successfully) and twice post-merge on `cbe6bce` (jobs `96156044656` and `96200357632`, the rerun cancelled after ~34 minutes). Each stall was in the mirror step, before Fairy-Stockfish was installed and before any smoke test ran, so it proves nothing about the code and costs the full job timeout. Fairy-Stockfish in the same job is already a pinned, checksummed release download and has never stalled. **Resolved in M15 Increment 11 (ADR-0121):** Stockfish now comes from release `sf_16`, asset `stockfish-ubuntu-x86-64.tar`, pinned by SHA-256 `efca1c60ec11fd9628425f3ee40644ad1618535ddf881c16385a86f7fc9e0983`, extracted one member by exact path, `chmod`ed only after verification, and asserted to report `id name Stockfish 16` before the suite runs. `sf_16` is the version apt was already serving, so the engine under test is unchanged. `apt-get` no longer appears in any executable line of any workflow, and the job now carries `timeout-minutes: 15` so a future stall is capped rather than inheriting the six-hour default. Production Docker images used apt until **M15 Increment 12**, which closed the last of it. Before that, `release.yml` built `Dockerfile.api` and `Dockerfile.gateway` on a `v*` tag push and those builds ran the apt layers — meaning production shipped Debian bookworm's `stockfish 15.1-4` while CI proved the engine boundary against 16, and a base-image move to trixie would have made it 17 with no commit of ours. Both images now take the binary from a pinned `stockfish` artefact stage using the same release, asset and digest as CI, with the licence and corresponding source copied beside it for GHCR redistribution, and a `docker-images` CI job builds both before merge instead of first exercising them at release time. `scripts/check-engine-pin-parity.mjs` fails if the four copies of the pin ever disagree.
- **The web image was published but never built before the tag (RESOLVED in M15 Increment 12).** `release.yml` pushes three images to GHCR on a `v*` tag — `Dockerfile.api`, `Dockerfile.gateway` and `Dockerfile.web` — but the `docker-images` CI job as first written built only the two that carry the pinned engine. `Dockerfile.web` copies `docker/web/nginx.conf.template` into `/etc/nginx/templates/`, where the image entrypoint runs `envsubst` over it at container start, so a broken template is not a build error at all: the image builds clean and the container dies on boot, and nothing before the release tag rendered it. Raised in the Qodo review of PR #143. **Resolved in the same increment:** the job builds all three images and checks the web one for what can actually break in it — the entrypoint renders the template with representative loopback upstreams (`nginx -t` resolves a literal `proxy_pass` host at config-load time, so the compose defaults would fail on a runner for the wrong reason), `nginx -t` must accept the result, both upstreams must appear substituted, and `$http_host` and `$uri` must survive, which is what `NGINX_ENVSUBST_FILTER` exists to guarantee and nothing tested. `docker/` joins the `images` path filter so the filter and the job cover the same set.
- **A study movetext walker that never asked which variant it was reading (RESOLVED in M15 Increment 13 / ADR-0122).** `importGame` in `packages/studies/src/import.ts` resolved every SAN through `resolveSan(reader, fen, san)` and `reader.play(fen, san)` with no variant, and `resolveSan` defaults to standard rather than failing — so a Crazyhouse or Three-Check game imported through it would have been validated against standard chess, rejecting legal moves and accepting illegal ones with no error to say why. Latent, not live: a whole-repository search found it referenced only by its own definition and its own test file, and both real import paths (`InMemoryStudiesRepository.buildTreeFromMovetext` via `appendNode`, and `PgStudiesRepository.buildTreeFromMovetextInternal` via a required parameter) already thread the study variant correctly. It was also a third implementation of a descent the two adapters already have, and ADR-0091 §10 records what happened the last time two copies of this walk diverged. **Resolved in M15 Increment 13 (ADR-0122):** deleted, together with the types and the `START_FEN` alias that existed only to serve it; `chapterNameFor` stays, because both adapters import it. `resolveSan`’s standard default is kept and now pinned by a test that states why — `@chess-platform/learning` relies on it and lessons carry no variant of their own. The coverage that was only reachable through the dead function moved onto `resolveSan` itself, and variant propagation through side variations — previously unverified, since the existing three-check test had no variations — is now a mutation-checked regression test.
Expand Down
26 changes: 26 additions & 0 deletions packages/persistence/migrations/0028_studies_variant_fk.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Migration 0028: Restore the canonical variant catalog and install its closed domain constraint.

INSERT INTO variants (code, name) VALUES
('standard', 'Standard'),
('chess960', 'Chess960'),
('kingofthehill', 'King of the Hill'),
('atomic', 'Atomic'),
('crazyhouse', 'Crazyhouse'),
('threecheck', 'Three-check'),
('horde', 'Horde'),
('racingkings', 'Racing Kings')
ON CONFLICT (code) DO NOTHING;

-- NOT VALID closes the domain for new writes immediately while deferring the legacy-row scan.
ALTER TABLE variants
ADD CONSTRAINT variants_code_check
CHECK (code IN (
'standard',
'chess960',
'kingofthehill',
'atomic',
'crazyhouse',
'threecheck',
'horde',
'racingkings'
)) NOT VALID;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Migration 0029: Reject unsupported legacy variant catalog rows before changing studies.

ALTER TABLE variants
VALIDATE CONSTRAINT variants_code_check;
8 changes: 8 additions & 0 deletions packages/persistence/migrations/0030_studies_variant_fk.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Migration 0030: Replace the duplicated studies.variant CHECK with the canonical catalog FK.

ALTER TABLE studies
ADD CONSTRAINT studies_variant_fk
FOREIGN KEY (variant) REFERENCES variants(code) NOT VALID;

ALTER TABLE studies
DROP CONSTRAINT studies_variant_check;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Migration 0031: Validate the studies.variant foreign key after its non-blocking installation.

ALTER TABLE studies
VALIDATE CONSTRAINT studies_variant_fk;
Loading
Loading