fix(webapp): refuse to approve a trashed lore proposal (#630) #631

Merged
claude-bot merged 1 commit from fix/630-approve-trashed-proposal into main 2026-09-12 20:07:04 +00:00
Collaborator

Follow-up to #629, where this was found and deliberately left out because it is a write path rather than a count.

The defect

POST /campaigns/{id}/lore/{entry_id}/approve is one of the few proposal paths that does not funnel through lore_service.get_lore_entry, which applies deleted_at IS NULL for everyone else. approve_lore_proposal clears proposed_by_bot but leaves deleted_at set, so approving a trashed proposal produced an entry that was neither a proposal nor a visible entry: in the trash, counting down to _purge_trashed_lore_entries_async, with the GM shown a success response and nothing to see anywhere.

Silent in both directions — no error, and nothing appears in the wiki. Reachable from a stale tab, a double submit or a replayed request; the listing filters correctly, so the id only has to be slightly out of date.

The fix

One predicate on the lookup, so it answers 404 like every other read path does for a trashed entry. The entry stays trashed and still a proposal, which matters: restoring it puts it back in the review queue to be approved properly, rather than restoring an entry the GM never knowingly approved.

I chose the 404 over the "undelete and approve" reading the issue offered as an alternative. Approving something you deleted is far more likely to be a stale request than a deliberate act, and the restore flow already exists for when it is deliberate.

The siblings the issue asked about

  • Reject/dismiss — DELETE /lore/{entry_id} already goes through get_lore_entry, so a trashed proposal 404s there today. No change needed.
  • Relationship proposals — LoreRelationship has no deleted_at column at all; relationships are hard-deleted. There is no equivalent gap.

Tests

Both added to test_lore_soft_delete.py, whose header says there is deliberately a test per surface rather than one test for the flag — approve was a surface it was missing:

  • a trashed proposal cannot be approved (404, still trashed, still proposed_by_bot) — confirmed to fail against the pre-fix source;
  • a live proposal can still be approved — passes either way by design, as the guard against over-correcting.

Backend suite: 3432 passed, 13 skipped. ruff check and ruff format --check clean under CI's pinned 0.4.4.

Closes #630

🤖 Generated with Claude Code

Follow-up to #629, where this was found and deliberately left out because it is a write path rather than a count. ## The defect `POST /campaigns/{id}/lore/{entry_id}/approve` is one of the few proposal paths that does **not** funnel through `lore_service.get_lore_entry`, which applies `deleted_at IS NULL` for everyone else. `approve_lore_proposal` clears `proposed_by_bot` but leaves `deleted_at` set, so approving a trashed proposal produced an entry that was neither a proposal nor a visible entry: in the trash, counting down to `_purge_trashed_lore_entries_async`, with the GM shown a **success** response and nothing to see anywhere. Silent in both directions — no error, and nothing appears in the wiki. Reachable from a stale tab, a double submit or a replayed request; the listing filters correctly, so the id only has to be slightly out of date. ## The fix One predicate on the lookup, so it answers 404 like every other read path does for a trashed entry. The entry stays trashed **and** still a proposal, which matters: restoring it puts it back in the review queue to be approved properly, rather than restoring an entry the GM never knowingly approved. I chose the 404 over the "undelete and approve" reading the issue offered as an alternative. Approving something you deleted is far more likely to be a stale request than a deliberate act, and the restore flow already exists for when it is deliberate. ## The siblings the issue asked about - **Reject/dismiss** — `DELETE /lore/{entry_id}` already goes through `get_lore_entry`, so a trashed proposal 404s there today. No change needed. - **Relationship proposals** — `LoreRelationship` has no `deleted_at` column at all; relationships are hard-deleted. There is no equivalent gap. ## Tests Both added to `test_lore_soft_delete.py`, whose header says there is deliberately a test per surface rather than one test for the flag — approve was a surface it was missing: - a trashed proposal cannot be approved (404, still trashed, still `proposed_by_bot`) — **confirmed to fail against the pre-fix source**; - a live proposal can still be approved — passes either way by design, as the guard against over-correcting. Backend suite: `3432 passed, 13 skipped`. `ruff check` and `ruff format --check` clean under CI's pinned 0.4.4. Closes #630 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(webapp): refuse to approve a trashed lore proposal (#630)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 25s
CI / Docker image build (pull_request) Successful in 50s
CI / Backend lint (ruff) (pull_request) Successful in 1m16s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m59s
CI / Bot tests and audit (pull_request) Successful in 2m52s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m13s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 13m21s
CI / Backend migration, tests, and audit (pull_request) Successful in 14m7s
8f8f088bf3
The approve endpoint is one of the few proposal paths that does not funnel
through `lore_service.get_lore_entry`, which applies `deleted_at IS NULL` for
everyone else. `approve_lore_proposal` clears `proposed_by_bot` but not
`deleted_at`, so approving a trashed proposal produced an entry that was
neither a proposal nor a visible entry: sitting in the trash, counting down to
`_purge_trashed_lore_entries_async`, with the GM shown a success response and
nothing to see anywhere.

Reachable from a stale tab, a double submit or a replayed request — the
listing filters correctly, so the id only has to be slightly out of date — and
silent in both directions when it happened.

Now a 404, matching the answer every other read path gives for a trashed
entry, and the entry stays trashed *and* still a proposal, so restoring it puts
it back in the review queue to be approved properly.

Checked while in there: the reject path (`DELETE /lore/{entry_id}`) already
goes through `get_lore_entry` and is correct, and `LoreRelationship` has no
`deleted_at` at all, so the relationship-proposal approve path has no
equivalent gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch fix/630-approve-trashed-proposal 2026-09-12 20:07:05 +00:00
Sign in to join this conversation.
No description provided.