From f0307f062584f744a7972e134e1677a0ad9607ac Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Mon, 9 Feb 2026 12:03:58 +0100 Subject: [PATCH] Guard genlocke data integrity edge cases Block deletion of runs linked to a genlocke leg, prevent reactivating completed/failed genlocke-linked runs, and guard encounter deletion against genlocke transfer references with clear 400 errors. Co-Authored-By: Claude Opus 4.6 --- ...tracker-lsc2--genlocke-lineage-tracking.md | 4 +-- ...locke-tracker-pm9f--genlocke-edge-cases.md | 30 ++++++++++++++----- backend/src/app/api/encounters.py | 13 ++++++++ backend/src/app/api/runs.py | 21 +++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md b/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md index 882c49b..df0563a 100644 --- a/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md +++ b/.beans/nuzlocke-tracker-lsc2--genlocke-lineage-tracking.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-lsc2 title: Genlocke lineage tracking -status: in-progress +status: completed type: feature priority: normal created_at: 2026-02-09T07:42:41Z -updated_at: 2026-02-09T10:51:22Z +updated_at: 2026-02-09T10:58:46Z parent: nuzlocke-tracker-25mh --- diff --git a/.beans/nuzlocke-tracker-pm9f--genlocke-edge-cases.md b/.beans/nuzlocke-tracker-pm9f--genlocke-edge-cases.md index 5cb072f..196820f 100644 --- a/.beans/nuzlocke-tracker-pm9f--genlocke-edge-cases.md +++ b/.beans/nuzlocke-tracker-pm9f--genlocke-edge-cases.md @@ -1,17 +1,33 @@ --- # nuzlocke-tracker-pm9f title: Genlocke edge cases -status: draft +status: in-progress type: task +priority: normal created_at: 2026-02-09T08:48:46Z -updated_at: 2026-02-09T08:48:46Z +updated_at: 2026-02-09T11:03:35Z parent: nuzlocke-tracker-25mh --- -Collect and evaluate edge cases for genlocke tracking. Review periodically to decide if any need dedicated handling. +Guard genlocke-related data integrity edge cases. Audited existing endpoints for gaps in validation when runs, encounters, or legs interact with genlocke tracking. -## Edge Cases +## Checklist -- [ ] Prevent run deletion if the run is linked to a genlocke leg. The `DELETE /runs/{id}` endpoint should check for a `GenlockeLeg` with matching `run_id` and return 400 if found, telling the user to remove the run from the genlocke first. -- [ ] What happens if a user tries to advance a leg twice? (Currently guarded by "next leg already has a run" check) -- [ ] What if the user edits a completed run back to active after the genlocke has already been marked completed/failed? \ No newline at end of file +### Critical + +- [x] **Block deletion of genlocke-linked runs.** `DELETE /runs/{id}` now returns 400 if the run belongs to a genlocke leg. (`runs.py`) + +- [x] **Block reactivation of completed/failed genlocke-linked runs.** `PATCH /runs/{id}` now returns 400 when trying to set status back to `active` on a genlocke-linked run. (`runs.py`) + +### High + +- [x] **Guard encounter deletion against transfer references.** `DELETE /encounters/{id}` now checks for GenlockeTransfer references and returns 400 instead of a raw FK constraint violation. (`encounters.py`) + +### Already handled (verified during audit) + +- [x] Advance leg twice — guarded by "next leg already has a run" check in `advance_leg` +- [x] Transfer eggs blocking starter route — transfer-target encounters are excluded from route-lock checks (`encounters.py:90-96`) +- [x] Shiny flag preservation during transfers — `is_shiny` is copied to egg encounter (`genlockes.py`) +- [x] Genlocke deletion cascading properly — CASCADE on FK for both `GenlockeLeg` and `GenlockeTransfer`, runs properly unlinked +- [x] Duplicate source transfers — prevented in practice by the "next leg already has a run" guard; `target_encounter_id` has a UNIQUE constraint +- [x] Empty transfer list — valid behavior, advances leg without transfers \ No newline at end of file diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index b0d1668..aecaf9c 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -159,6 +159,19 @@ async def delete_encounter( if encounter is None: raise HTTPException(status_code=404, detail="Encounter not found") + # Block deletion if encounter is referenced by a genlocke transfer + transfer_result = await session.execute( + select(GenlockeTransfer.id).where( + (GenlockeTransfer.source_encounter_id == encounter_id) + | (GenlockeTransfer.target_encounter_id == encounter_id) + ) + ) + if transfer_result.scalar_one_or_none() is not None: + raise HTTPException( + status_code=400, + detail="Cannot delete an encounter that is part of a genlocke transfer.", + ) + await session.delete(encounter) await session.commit() return Response(status_code=204) diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index 2947c65..4793971 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -173,6 +173,17 @@ async def update_run( ) update_data["completed_at"] = datetime.now(timezone.utc) + # Block reactivating a completed/failed run that belongs to a genlocke + if "status" in update_data and update_data["status"] == "active" and run.status != "active": + leg_result = await session.execute( + select(GenlockeLeg).where(GenlockeLeg.run_id == run_id) + ) + if leg_result.scalar_one_or_none() is not None: + raise HTTPException( + status_code=400, + detail="Cannot reactivate a genlocke-linked run. The genlocke controls leg progression.", + ) + for field, value in update_data.items(): setattr(run, field, value) @@ -211,6 +222,16 @@ async def delete_run( if run is None: raise HTTPException(status_code=404, detail="Run not found") + # Block deletion if run is linked to a genlocke leg + leg_result = await session.execute( + select(GenlockeLeg).where(GenlockeLeg.run_id == run_id) + ) + if leg_result.scalar_one_or_none() is not None: + raise HTTPException( + status_code=400, + detail="Cannot delete a run that belongs to a genlocke. Remove the leg or delete the genlocke first.", + ) + # Delete associated boss results first boss_results = await session.execute( select(BossResult).where(BossResult.run_id == run_id)