Character sheets: Update to version n, with what changes (#958) #1364

Merged
claude-bot merged 8 commits from feat/958-update-to-version into main 2026-11-07 08:19:25 +00:00
Collaborator

Closes the "Update to version n" half of #958 (IS-423, D83).

A sheet made under an older version of its system's definition keeps reading with that version (a sheet's definition_id is its own row; definitions are deprecated, never deleted). When a newer active version exists and ships a migration, the GM is offered Update to version n, with what changes. Never automatic, never forced.

What it does

Typed migration ops (definition_model.py, migrations: list[Migration]; the schema file is regenerated). A definition carries the migrations that arrive at its own version: {from, to, note?, ops: [...]}, to == sheet.version.

op keys does
rename from_path, to_path value, override, audience, lock and listed rows follow the field
move from_path, to_path, kinds? a value to another path, or only rows of those entry kinds to another list
default path, value set only if missing
drop path value leaves; listed, and kept in the version made first
compute path, expr the formula evaluator (no dice) over the stored values as earlier ops left them

Validated on pack upload (validator._check_migrations, pack_sheets now passes the stored versions as previous): to is the definition's own version, no duplicate from/to, from is a stored version, write paths exist in this definition, read paths exist in the version migrated from, compute paths are values someone types (not computed fields), expressions parse, name no dice, and name real tables.

Engine (app/sheets/migrate.py, pure): fewest-steps chain (v1 to v2 to v3 in order, or a direct v1 to v3 if shipped). Nothing is overwritten silently: a value already at a rename/move destination stays and the source is listed as dropped. An op with nothing to do on this sheet is not listed.

Service (app/services/sheet_upgrade.py): preview_upgrade saves nothing; apply_upgrade keeps the sheet as it was as a version first (so a drop is recoverable), migrates the source half, switches definition_id/definition_version, and writes a version of kind migration ("Updated to version n: 1 renamed, 1 dropped"). Putting either version back from history also puts the old definition back (sheet_service.restore; GM only across a definition version).

API (docs/API.md):

  • GET /api/campaigns/{id}/sheets/{sheet_id}/upgrade?to=N preview
  • POST /api/campaigns/{id}/sheets/{sheet_id}/upgrade?to=N&expected_version=V apply, returns the sheet
  • 403 not the GM, 404 no such active version, 409 {detail, current} stale, 422 no migration path / not above current / still being started / an expression that fails.
  • SheetOut.access.can_upgrade (new, GM) and the existing definition.latest_version drive the UI.

Who: the GM only. D83 says "the GM applies them when ready"; the drawing (olderVersion()) shows the notice without naming a viewer, and the catalogue row listed "GM, char-owner" while deferred. I went with the decision text. Letting the sheet's player apply it is one line (can_upgrade in access()); say if you want it.

Frontend (pages/sheet/UpdateToVersion.jsx, wired into SheetPage): the drawing's warning banner with Update to version 2 and What changes (the shared WhatChanges table, Version 1 / Version 2 columns, the reason under the label on a phone), a confirm modal, then the sheet is read again on its new definition and a quiet "Updated to version 2" banner with a Versions button. Shown only when can_upgrade, a newer version exists, and the preview answers (a 422 or 404 shows nothing, since there is nothing to offer). What is typed is saved first.

Every built-in definition is still version 1, so nothing offers an update on a real campaign yet; the tests carry a test-only v1/v2/v3 with one op of each kind.

Wording for review

Banner (vocabulary.js and UpdateToVersion.jsx):

  • Title: "Saved against version {n} of the {system} sheet"
  • Body: "Version {n} is ready. It would change: {summary}. Nothing is lost: the sheet reads as it is until you update it." (the "It would change" sentence is left out when nothing does)
  • Buttons: "Update to version {n}", "What changes"
  • Table caption: "What changes in version {n}"; columns "What", "Version {a}", "Version {b}", "Why"
  • Empty: "Nothing on this sheet needs to change."
  • Why column: "Renamed from {label}." / "Moved from {label}." / "Version {n} starts it here." / "Worked out from the sheet." / "It is not on version {n}. It stays in the version the update is made from." (plus an op's own note); cell "Dropped"; list cells "{n} row(s) in {label}"
  • Under the table: "The dropped value stays in the sheet's history, under Versions, and can be put back." / "The dropped values stay …"
  • Summary words: "{n} renamed", "moved", "filled in", "dropped", "worked out"
  • Check failed: "The update could not be checked" / "The sheet is unchanged and reads as it is." / "Try again"
  • Confirm: title "Update to version {n}?"; "The sheet moves onto version {n}. {summary}. How it is now is kept as a version first[, with the value(s) that is/are dropped], so this can be put back from Versions."; button "Update to version {n}"
  • Refusals, shown inline in the confirm: "What you typed is not saved yet. Save it first, then update the sheet." and the server's sentence; fallback "The sheet could not be updated. It is unchanged."
  • Done banner: "Updated to version {n}" / "The sheet as it was is kept in its versions, and can be put back." / "Versions"
  • History line for the version kind: "updated"; version summaries "Version 1, before updating to version 2" and "Updated to version 2: …"
  • Server: "Only a GM can update a sheet to a new version." / "Finish making this character before updating it." / "This sheet is already on version {n} or a later one." / "There is no version {n} of this sheet's rules to update to." / "Version {n} doesn't say how to update a sheet from version {have}. The sheet reads as it is." / "Only a GM can put back a version from a different version of the rules."

Verification (docker-test, committed code via git archive)

  • lint (ruff 0.4.4 format + check, three paths): clean after ruff format on my three files
  • checks: version sync OK, changelog check OK
  • backend selector (tests/sheets/, the reader guards, pack sheet definitions, sheet export): 1082 passed
  • backend full: 10864 passed, 14 skipped, 121 deselected
  • frontend: eslint 0 errors; src/pages/sheet/UpdateToVersion*.test.jsx 15 passed; full vitest 295 files, 5777 passed, 1 skipped
  • Not done: rendering the notice and preview at 390 and 1280 on the seeded journeys stack. No seeded sheet is on an older version, and the host was at load 20 to 45 for the runs.

Notes

  • Version kind is the existing migration (already in VERSION_KINDS and the CHECK), not a new upgrade, so there is no Alembic migration. No bot API change.
  • Migrations touch the source half only. The hot half (resources) is unversioned, so a migration that renamed a resource would not be recoverable; it is left alone.
  • A compute reads stored values (@path), not other computed fields.

🤖 Generated with Claude Code

Closes the "Update to version n" half of #958 (IS-423, D83). A sheet made under an older version of its system's definition keeps reading with that version (a sheet's `definition_id` is its own row; definitions are deprecated, never deleted). When a newer active version exists and ships a migration, the GM is offered **Update to version n**, with what changes. Never automatic, never forced. ## What it does **Typed migration ops** (`definition_model.py`, `migrations: list[Migration]`; the schema file is regenerated). A definition carries the migrations that *arrive at* its own version: `{from, to, note?, ops: [...]}`, `to == sheet.version`. | `op` | keys | does | |---|---|---| | `rename` | `from_path`, `to_path` | value, override, audience, lock and listed rows follow the field | | `move` | `from_path`, `to_path`, `kinds?` | a value to another path, or only rows of those entry kinds to another list | | `default` | `path`, `value` | set only if missing | | `drop` | `path` | value leaves; listed, and kept in the version made first | | `compute` | `path`, `expr` | the formula evaluator (no dice) over the stored values as earlier ops left them | Validated on pack upload (`validator._check_migrations`, `pack_sheets` now passes the stored versions as `previous`): `to` is the definition's own version, no duplicate `from`/`to`, `from` is a stored version, write paths exist in this definition, read paths exist in the version migrated from, `compute` paths are values someone types (not computed fields), expressions parse, name no dice, and name real tables. **Engine** (`app/sheets/migrate.py`, pure): fewest-steps chain (v1 to v2 to v3 in order, or a direct v1 to v3 if shipped). Nothing is overwritten silently: a value already at a rename/move destination stays and the source is listed as dropped. An op with nothing to do on this sheet is not listed. **Service** (`app/services/sheet_upgrade.py`): `preview_upgrade` saves nothing; `apply_upgrade` keeps the sheet as it was as a version first (so a drop is recoverable), migrates the source half, switches `definition_id`/`definition_version`, and writes a version of kind `migration` ("Updated to version n: 1 renamed, 1 dropped"). Putting either version back from history also puts the old definition back (`sheet_service.restore`; GM only across a definition version). **API** (`docs/API.md`): - `GET /api/campaigns/{id}/sheets/{sheet_id}/upgrade?to=N` preview - `POST /api/campaigns/{id}/sheets/{sheet_id}/upgrade?to=N&expected_version=V` apply, returns the sheet - `403` not the GM, `404` no such active version, `409 {detail, current}` stale, `422` no migration path / not above current / still being started / an expression that fails. - `SheetOut.access.can_upgrade` (new, GM) and the existing `definition.latest_version` drive the UI. **Who:** the GM only. D83 says "the GM applies them when ready"; the drawing (`olderVersion()`) shows the notice without naming a viewer, and the catalogue row listed "GM, char-owner" while deferred. I went with the decision text. Letting the sheet's player apply it is one line (`can_upgrade` in `access()`); say if you want it. **Frontend** (`pages/sheet/UpdateToVersion.jsx`, wired into `SheetPage`): the drawing's warning banner with **Update to version 2** and **What changes** (the shared `WhatChanges` table, Version 1 / Version 2 columns, the reason under the label on a phone), a confirm modal, then the sheet is read again on its new definition and a quiet "Updated to version 2" banner with a Versions button. Shown only when `can_upgrade`, a newer version exists, and the preview answers (a 422 or 404 shows nothing, since there is nothing to offer). What is typed is saved first. Every built-in definition is still version 1, so nothing offers an update on a real campaign yet; the tests carry a test-only v1/v2/v3 with one op of each kind. ## Wording for review Banner (`vocabulary.js` and `UpdateToVersion.jsx`): - Title: "Saved against version {n} of the {system} sheet" - Body: "Version {n} is ready. It would change: {summary}. Nothing is lost: the sheet reads as it is until you update it." (the "It would change" sentence is left out when nothing does) - Buttons: "Update to version {n}", "What changes" - Table caption: "What changes in version {n}"; columns "What", "Version {a}", "Version {b}", "Why" - Empty: "Nothing on this sheet needs to change." - Why column: "Renamed from {label}." / "Moved from {label}." / "Version {n} starts it here." / "Worked out from the sheet." / "It is not on version {n}. It stays in the version the update is made from." (plus an op's own `note`); cell "Dropped"; list cells "{n} row(s) in {label}" - Under the table: "The dropped value stays in the sheet's history, under Versions, and can be put back." / "The dropped values stay …" - Summary words: "{n} renamed", "moved", "filled in", "dropped", "worked out" - Check failed: "The update could not be checked" / "The sheet is unchanged and reads as it is." / "Try again" - Confirm: title "Update to version {n}?"; "The sheet moves onto version {n}. {summary}. How it is now is kept as a version first[, with the value(s) that is/are dropped], so this can be put back from Versions."; button "Update to version {n}" - Refusals, shown inline in the confirm: "What you typed is not saved yet. Save it first, then update the sheet." and the server's sentence; fallback "The sheet could not be updated. It is unchanged." - Done banner: "Updated to version {n}" / "The sheet as it was is kept in its versions, and can be put back." / "Versions" - History line for the version kind: "updated"; version summaries "Version 1, before updating to version 2" and "Updated to version 2: …" - Server: "Only a GM can update a sheet to a new version." / "Finish making this character before updating it." / "This sheet is already on version {n} or a later one." / "There is no version {n} of this sheet's rules to update to." / "Version {n} doesn't say how to update a sheet from version {have}. The sheet reads as it is." / "Only a GM can put back a version from a different version of the rules." ## Verification (docker-test, committed code via git archive) - lint (ruff 0.4.4 format + check, three paths): clean after `ruff format` on my three files - checks: version sync OK, changelog check OK - backend selector (tests/sheets/, the reader guards, pack sheet definitions, sheet export): 1082 passed - backend full: 10864 passed, 14 skipped, 121 deselected - frontend: eslint 0 errors; `src/pages/sheet/UpdateToVersion*.test.jsx` 15 passed; full vitest 295 files, 5777 passed, 1 skipped - Not done: rendering the notice and preview at 390 and 1280 on the seeded journeys stack. No seeded sheet is on an older version, and the host was at load 20 to 45 for the runs. ## Notes - Version kind is the existing `migration` (already in `VERSION_KINDS` and the CHECK), not a new `upgrade`, so there is no Alembic migration. No bot API change. - Migrations touch the source half only. The hot half (`resources`) is unversioned, so a migration that renamed a resource would not be recoverable; it is left alone. - A `compute` reads stored values (`@path`), not other computed fields. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test: update-to-version table row text (#958)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m1s
CI / Does this change touch the recording path? (pull_request) Successful in 30s
CI / Backend lint (ruff) (pull_request) Successful in 30s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 53s
CI / Which journeys does this change need? (pull_request) Successful in 22s
CI / Bot tests and audit (pull_request) Successful in 2m10s
CI / Feedback site tests (pull_request) Successful in 3m24s
CI / Frontend (shard 2/2) (pull_request) Successful in 7m30s
CI / Docker image build (pull_request) Successful in 7m8s
CI / Frontend (shard 1/2) (pull_request) Successful in 10m42s
CI / End-to-end journeys (desktop) (pull_request) Successful in 14m13s
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
88a26a6b93
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(webapp): Update to version: one named region, and the empty state says so (#958)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 32s
CI / Backend lint (ruff) (pull_request) Successful in 45s
CI / Does this change touch the recording path? (pull_request) Successful in 21s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Which journeys does this change need? (pull_request) Successful in 19s
CI / Feedback site tests (pull_request) Successful in 1m56s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m11s
CI / Docker image build (pull_request) Successful in 3m21s
CI / Bot tests and audit (pull_request) Successful in 4m2s
CI / Frontend (shard 1/2) (pull_request) Successful in 12m0s
CI / Frontend (shard 2/2) (pull_request) Successful in 16m0s
CI / End-to-end journeys (desktop) (pull_request) Successful in 12m16s
CI / Backend migration, tests, and audit (pull_request) Successful in 1h2m24s
eb86f6d891
The render check found axe's landmark-unique when What changes is open
(the section and the table's scroll box both named "What changes in
version 2"), and that "Nothing on this sheet needs to change." never
showed: with no changes the banner now says it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge main into feat/958-update-to-version
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m2s
CI / Bot/backend version sync (pull_request) Successful in 29s
CI / Backend lint (ruff) (pull_request) Successful in 1m3s
CI / Which journeys does this change need? (pull_request) Successful in 32s
CI / Does this change touch the recording path? (pull_request) Successful in 40s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot tests and audit (pull_request) Successful in 2m21s
CI / Feedback site tests (pull_request) Successful in 1m51s
CI / Docker image build (pull_request) Successful in 3m10s
CI / Frontend (shard 2/2) (pull_request) Successful in 9m48s
CI / Frontend (shard 1/2) (pull_request) Successful in 24m15s
CI / End-to-end journeys (desktop) (pull_request) Successful in 12m52s
CI / Backend migration, tests, and audit (pull_request) Successful in 1h6m10s
2faa058ed5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.