feat(wiki): let a GM roll an entry back to an earlier version (#408) #476

Merged
claude-bot merged 1 commit from feat/408-lore-version-restore into main 2026-09-01 14:52:30 +00:00
Contributor

The restore half of #408. Meets acceptance criteria 2 and 3; #408 stays open for the soft-delete half (see below).

The gap

Every edit to a lore entry has snapshotted the previous state since d2e3f4a5b6c7, and that history has always been read-onlylist_lore_versions was the only version endpoint there was. So recovering from a bad edit meant copying an old body out of the history view and pasting it back into the editor field by field, with the sidebar and timeline JSON left to reconstruct by hand or simply given up on.

The data to undo the mistake was already sitting there. Nothing could apply it.

What lands

POST /campaigns/{id}/lore/{entry_id}/versions/{version_id}/restore

Routed through update_lore_entry rather than assigning the fields directly. That function is what snapshots the current state before changing anything, so a restore is itself recorded as a version and is itself undoable — the difference between an undo and a second way to lose the text you had. It also means restore inherits the rest of that path (search vector, summary regeneration, alias handling) instead of quietly diverging from it, which is how a second write path to the same table usually goes wrong.

get_lore_version scopes the lookup to the entry, so a version id belonging to another entry — or another campaign — cannot be written over this one. GM-only, matching every other write to an entry, and audited as lore.version_restored.

One trap worth knowing about

The endpoint re-fetches through get_lore_entry after committing rather than calling refresh. The commit expires the instance, and _lore_response reads relationships that a refresh does not reload — touching one then raises MissingGreenlet from a lazy load with no greenlet to run in. Same shape as the beat-notes bug in #403, and it cost a debugging round here too.

Why the soft-delete half is not in this PR

#408's other half — stopping a deletion from cascading the version history — is considerably larger than the issue suggests. LoreEntry is referenced in 80 places across 20 files (routers, bot API, shelf, import/export, account deletion, lore generation, campaign service), so soft-delete means auditing every read path; miss one and a "deleted" entry keeps appearing somewhere. Plus a migration, a purge task, and a restore-entry flow.

It is also the same work as #405's campaign soft-delete. Both are trash-and-grace-then-purge, #427 already set that precedent for audio, and LootEntry.deleted_at is a column precedent. Doing them as one convention applied twice is better than two implementations that drift apart, so they will be tackled together.

Verification

9 tests. 6 mutations, all caught: skipping the pre-restore snapshot, an unscoped version lookup, dropping the structured fields, dropping gm_notes, a missing version that is not a 404, and a restore absent from the audit log.

Full backend suite 1567 passed; ruff check and ruff format --check clean.

🤖 Generated with Claude Code

The restore half of #408. Meets acceptance criteria 2 and 3; #408 stays open for the soft-delete half (see below). ## The gap Every edit to a lore entry has snapshotted the previous state since `d2e3f4a5b6c7`, and that history has always been **read-only** — `list_lore_versions` was the only version endpoint there was. So recovering from a bad edit meant copying an old body out of the history view and pasting it back into the editor field by field, with the sidebar and timeline JSON left to reconstruct by hand or simply given up on. The data to undo the mistake was already sitting there. Nothing could apply it. ## What lands ``` POST /campaigns/{id}/lore/{entry_id}/versions/{version_id}/restore ``` **Routed through `update_lore_entry` rather than assigning the fields directly.** That function is what snapshots the current state before changing anything, so a restore is itself recorded as a version and is itself undoable — the difference between an undo and a second way to lose the text you had. It also means restore inherits the rest of that path (search vector, summary regeneration, alias handling) instead of quietly diverging from it, which is how a second write path to the same table usually goes wrong. `get_lore_version` scopes the lookup to the entry, so a version id belonging to another entry — or another campaign — cannot be written over this one. GM-only, matching every other write to an entry, and audited as `lore.version_restored`. ## One trap worth knowing about The endpoint re-fetches through `get_lore_entry` after committing rather than calling `refresh`. The commit expires the instance, and `_lore_response` reads relationships that a refresh does not reload — touching one then raises `MissingGreenlet` from a lazy load with no greenlet to run in. Same shape as the beat-notes bug in #403, and it cost a debugging round here too. ## Why the soft-delete half is not in this PR #408's other half — stopping a deletion from cascading the version history — is considerably larger than the issue suggests. `LoreEntry` is referenced in **80 places across 20 files** (routers, bot API, shelf, import/export, account deletion, lore generation, campaign service), so soft-delete means auditing every read path; miss one and a "deleted" entry keeps appearing somewhere. Plus a migration, a purge task, and a restore-entry flow. It is also **the same work as #405's campaign soft-delete**. Both are trash-and-grace-then-purge, #427 already set that precedent for audio, and `LootEntry.deleted_at` is a column precedent. Doing them as one convention applied twice is better than two implementations that drift apart, so they will be tackled together. ## Verification 9 tests. **6 mutations, all caught:** skipping the pre-restore snapshot, an unscoped version lookup, dropping the structured fields, dropping `gm_notes`, a missing version that is not a 404, and a restore absent from the audit log. Full backend suite **1567 passed**; `ruff check` and `ruff format --check` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(wiki): let a GM roll an entry back to an earlier version (#408)
All checks were successful
CI / Docker image build (pull_request) Successful in 28s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 49s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m20s
CI / Bot/backend version sync (pull_request) Successful in 1m24s
CI / Backend lint (ruff) (pull_request) Successful in 1m32s
CI / Bot tests and audit (pull_request) Successful in 3m3s
CI / Backend migration, tests, and audit (pull_request) Successful in 5m13s
db8568add0
Every edit to a lore entry has snapshotted the previous state since
`d2e3f4a5b6c7`, and that history has always been read-only. `list_lore_versions`
was the only version endpoint there was, so recovering from a bad edit meant
copying an old body out of the history view and pasting it back into the editor
field by field — with the sidebar and timeline JSON left to reconstruct by hand,
or simply given up on. The data to undo the mistake was already there; nothing
could apply it.

POST /campaigns/{id}/lore/{entry_id}/versions/{version_id}/restore

Deliberately routed through `update_lore_entry` rather than assigning the fields
directly. That function is what snapshots current state before changing
anything, so a restore is itself recorded as a version and is itself undoable —
the difference between an undo and a second way to lose the text you had. It
also means restore inherits the rest of that path (search vector, summary
regeneration, alias handling) instead of quietly diverging from it, which is
how a second write path to the same table usually goes wrong.

`get_lore_version` scopes the lookup to the entry, so a version id belonging to
another entry — or another campaign — cannot be written over this one. GM-only,
matching every other write to an entry, and audited as `lore.version_restored`.

One thing worth knowing for the next person: the endpoint re-fetches through
`get_lore_entry` after committing rather than calling `refresh`. The commit
expires the instance, and `_lore_response` reads relationships that a refresh
does not reload — touching one then raises MissingGreenlet from a lazy load
with no greenlet to run in. Same shape as the beat-notes bug in #403.

This is the smaller half of #408. The soft-delete half — stopping a deletion
cascading the version history — is much larger than the issue suggests:
`LoreEntry` is referenced in 80 places across 20 files, so it means auditing
every read path, plus a migration, a purge task and a restore-entry flow. It is
also the same work as #405's campaign soft-delete, so both are better done as
one trash-and-grace convention applied twice than as two implementations that
drift apart. #408 stays open for it.

Verification: 9 tests, 6 mutations all caught — skipping the pre-restore
snapshot, an unscoped version lookup, dropping the structured fields, dropping
gm_notes, a missing version that is not a 404, and a restore absent from the
audit log. Full backend suite 1567 passed; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/408-lore-version-restore 2026-09-01 14:52:30 +00:00
Sign in to join this conversation.
No description provided.