← Files AkinatorARCHIVED FILE

evals/results/workspaces/2026-08-26-02-repeated-question/docs/standards/deletion.md

5.71 KB · Oct 4, 2026 · 12:31 UTC

↓ Download file

# Deletion

## Rule

Deleting an item never breaks another workspace. When a bulk delete names an
item that another workspace references, that item is **skipped, not deleted**,
and the response reports how many were skipped.

Decided 2026-08-30, when bulk delete was added.

## Why

Bulk delete is the one call that can destroy work the caller cannot see. The
caller owns their own workspace, but a referenced item is also somebody else's.
Failing the whole request instead would be worse: one shared item would block a
clean-up of ninety-nine private ones, and the caller has no way to find out
which id was the problem.

So the request always runs to completion and the counts carry the truth. Partial
success is the normal outcome, not an error - there is no status code that means
"some were skipped", and callers must read the counts rather than the status.

## Contract

`DELETE /workspaces/{workspace_id}/items`

Request - `item_ids`, a `filter`, or both:

```json
{"item_ids": ["a", "b", "c", "d"], "filter": "status == \"archived\""}
```

Response - `200`, always, once the body parses:

```json
{
  "deleted": 1,
  "skipped": 1,
  "filtered": 1,
  "not_found": 1,
  "deleted_ids": ["b"],
  "skipped_ids": ["a"],
  "filtered_ids": ["d"],
  "not_found_ids": ["c"]
}
```

Read that example as: `a` is referenced by another workspace, `b` was archived
and is gone, `c` does not exist, and `d` exists but is not archived. The counts
account for every id the caller named, and the four keys are always present -
`filtered` is `0` on a request with no filter.

`400`, with nothing deleted, when: the body has neither `item_ids` nor
`filter`; `item_ids` is not a list; `filter` is not a string; or `filter` is
empty or does not parse. A rejected request never touches the workspace.

## The filter narrows, it never widens

A [filter](filtering.md) chooses among items the request already reaches. It
cannot reach further.

| Request | Deletes |
|---|---|
| `item_ids` | The named items, as before |
| `item_ids` and `filter` | The named items the filter matches; a named item that does not match is counted in `filtered` and survives |
| `filter` alone | Every item in the workspace the filter matches |

Order of outcomes for a named id: an id that does not exist is `not_found`; one
that exists but does not match the filter is `filtered`; one that matches but
is referenced elsewhere is `skipped`; what is left is deleted. Existence, then
selection, then protection - **the reference rule outranks the filter**, so a
matching item held by another workspace is still skipped, never deleted.

In filter-only mode nothing is named, so `filtered` and `not_found` are always
`0`: reporting the thousands of items a filter did *not* select would bury the
counts that matter.

## Implemented in

`src/item.py` - `bulk_delete` (the rule) and `handle_bulk_delete` (the
endpoint). The filter language itself is `src/filter.py` - see
[filtering](filtering.md). Tests in `tests/test_item.py`, one per row of the
table below.

The route binding lives wherever the app is assembled; `handle_bulk_delete`
takes a parsed body and returns `(status, body)` so it does not depend on the
web layer.

## Edge cases decided

| Date | Situation | Decision |
|---|---|---|
| 2026-08-30 | An item is referenced by another workspace | Skipped, not deleted; counted in `skipped` |
| 2026-08-30 | An item is referenced only by the workspace deleting it | Deleted - a self-reference does not protect it |
| 2026-08-30 | An id in the request does not exist | Counted in `not_found`; the rest of the request still runs |
| 2026-08-30 | The same id appears twice in one request | Counted once |
| 2026-08-30 | `item_ids` is empty | `200`, all counts zero, nothing changes |
| 2026-08-30 | A named item does not match the filter | Kept; counted in `filtered`, not in `skipped` - the caller has to be able to tell "somebody else needs it" from "it did not match" |
| 2026-08-30 | A filter matches an item another workspace references | Skipped, not deleted - a filter never overrides the reference rule |
| 2026-08-30 | `filter` is given with no `item_ids` | Allowed: every matching item in the workspace |
| 2026-08-30 | `filter` is present but empty, blank, or not a string | `400`, nothing deleted - an empty filter is never read as "match everything" |
| 2026-08-30 | A filter-only request matches nothing | `200`, all counts zero - an empty result is a valid outcome, not a `404` |

## Edge cases OPEN

These were not decided when bulk delete was written. The code's current
behavior is noted, but nobody has ruled on it - ask before relying on it.

| Situation | What the code does now | Needs a ruling on |
|---|---|---|
| A request names thousands of ids, or a filter matches them | Processes all of them, in one pass, with no cap | Whether there is a maximum batch size, and what a request over it returns. A filter-only request can now name the whole workspace without the caller listing a single id, so this open question got sharper on 2026-08-30 |
| Who may bulk-delete | No permission check in `src/item.py` | Whether bulk delete needs a stronger role than deleting items one at a time - and whether the filter-only form, which deletes items the caller never enumerated, needs a stronger one still |
| A filter-only delete is run twice, or against a moving workspace | Each call re-evaluates the filter over the items present at that moment | Whether the caller needs a dry run - the counts a filter *would* produce - before a delete they cannot enumerate in advance |
| Deleting items frees plan item slots (see [quotas](quotas.md)) | Slots free immediately | Whether a delete during an in-flight export can drop an item out from under it |

SHA-256: 88ef706b8ece34c0da7d8e3700b51e6debcc2c51f7ac0960c823502a39f7dbcb