# 006 — A `POST` that ultimately fails with 413 still permanently deletes unkept artifacts **Status:** Fixed **Severity:** High — irreversible data loss on a failed request ## Description `make_room_for()` (`artifacts/storage.py`) is called from `create_text_artifact` /`create_binary_artifact` *before* the new artifact is persisted. When the incoming artifact doesn't fit even after evicting every unkept artifact, it raises `QuotaExceeded` and the view returns `413`. But the eviction loop inside `make_room_for` deletes each unkept artifact **as it iterates**, *before* it can know whether the eventual total will succeed: ```python for artifact in unkept: if used + incoming_bytes <= quota: break used -= artifact_total_bytes(artifact) delete_artifact_files(artifact) artifact.delete() evicted.append(artifact) if used + incoming_bytes > quota: raise QuotaExceeded("Quota is full of kept artifacts; free space to post.") ``` If the loop runs out of unkept artifacts to evict and still hasn't freed enough room (because the remaining `is_kept=True` artifacts alone exceed the new/current quota), it raises *after* having already deleted every unkept artifact — there's no rollback. The caller gets a `413` that reads like "nothing happened, free up space and retry," but in reality **every unkept artifact the user had was just destroyed** for a request that still failed. Reproduced against the `test` account (quota temporarily lowered to demonstrate an unwinnable state — 1 kept artifact at 851 bytes vs. an 800-byte quota): ``` BEFORE: 1 kept artifact (851B) + 5 unkept artifacts (582/321/311/401/428B) POST small markdown -> 413 "Quota is full of kept artifacts; free space to post." AFTER: only the 1 kept artifact remains — all 5 unkept artifacts are gone (both DB rows and on-disk files, confirmed via Django shell) ``` This is a normal, reachable state — not just an artificially shrunk quota. Any user whose kept artifacts alone approach their quota will hit this the moment they try to post anything that doesn't fit, and lose every unkept artifact they had in the process, with a `413` response giving no indication that a destructive side effect occurred. ## Suggested Fix `make_room_for` should compute whether eviction *can* succeed before deleting anything — e.g. sum `unkept` sizes up front and compare `used - unkept_total + incoming_bytes` against `quota`; raise `QuotaExceeded` immediately if even a hypothetical full eviction wouldn't fit, without touching any artifact. Only perform actual deletions once it's known they'll be sufficient. This makes the failure path side-effect free, matching what a `413` response implies. ## Resolution Fixed in `artifacts/storage.py`'s `make_room_for`: computes the total recoverable bytes across all unkept artifacts up front and raises `QuotaExceeded` immediately if even a full eviction wouldn't fit — no artifact is touched on that path. The incremental eviction loop only runs once success is already guaranteed. While testing this fix, found and fixed a related latent bug: the function returned the evicted `Artifact` *objects* after calling `.delete()` on them, but Django clears an instance's pk on delete, so every returned object's `.id` was silently `None`. Nothing currently reads this return value, so it had no live impact, but it broke the function's own documented contract. Now returns the evicted ids (captured before deletion) instead of objects. Regression tests added in `tests/unit/test_storage.py`: an unwinnable eviction leaves all unkept artifacts intact, a successful eviction only evicts as many (oldest-first) as needed, and an over-quota single artifact deletes nothing.