diff --git a/docs/phase4-triage.md b/docs/phase4-triage.md
new file mode 100644
index 0000000..d621b83
--- /dev/null
+++ b/docs/phase4-triage.md
@@ -0,0 +1,143 @@
+# Phase 4 — Triage actions (delete, move, mark; bulk select)
+
+Status: **done and deployed on the host running `email-agent`.** [AUD-70](/AUD/issues/AUD-70),
+parent [AUD-68](/AUD/issues/AUD-68). Phase 4 of AGENTS.md §11. Commits
+`8c04167` (implementation) and `318b7b4` (migration-order fix + regression test).
+
+**DoD:** select-all then delete works, and the message is recoverable.
+
+## 1. What was built
+
+- **Bulk select (FR-6).** The triage list has a `Select all` checkbox and one
+ checkbox per row. A toolbar performs `delete`, `save`, `reply`, `mark read`,
+ `mark unread`, and `star` over every checked id. The toolbar and rows live in
+ the `/partials/inbox` fragment, so it keeps working after every HTMX swap.
+- **Move to Gmail labels (FR-9).** `internal/mailops` maps an app folder to a
+ Gmail label and performs the move over IMAP (IPv4 forced, same as the read
+ path):
+
+ | App folder | Gmail label |
+ |---|---|
+ | `inbox` | `INBOX` |
+ | `delete` | `Delete` |
+ | `saved` | `Saved` |
+ | `replied` | `Replied-to/
` (FR-15) |
+
+ The destination label is created lazily. The move uses the IMAP `MOVE`
+ extension and falls back to `COPY` + `STORE \Deleted` + `UID EXPUNGE`.
+- **Deletes are recoverable.** A delete moves the message to the `Delete` label;
+ it is never hard-deleted. The Delete, Saved, and Replied-to folder views show
+ a **Restore** button that moves the message back to `INBOX`.
+- **Bulk delete confirmation.** The Delete button carries an HTMX confirmation.
+ The server additionally refuses a delete without `confirm=yes`, so a request
+ that bypasses the UI cannot delete (or move) anything.
+- **Spam confirmation (§10).** Spam is recommended, never auto-deleted. If the
+ selection contains any message whose latest classification is `Spam`, the
+ server refuses unless `spam_confirm=yes`. The UI sets it after the user
+ confirms; a direct call without it is rejected and the message stays put.
+- **Location tracking (migration v4).** `messages.folder` is the app view and
+ `messages.mailbox` is the exact Gmail label. Gmail assigns a different `UID`
+ per label, so a message is always located by its stable `X-GM-MSGID`
+ (`UID SEARCH X-GM-MSGID …`), never by a stored `UID`.
+- **FR-13 logging.** Every action writes one `move` or `mark` row per message
+ (message id, actor, destination label, source label) plus one `bulk_`
+ summary row.
+
+## 2. DoD evidence
+
+### 2.1 Automated tests
+
+`gofmt -l` clean, `go vet ./...` clean, `go test ./...` green (in
+`golang:1.25-alpine`). New coverage:
+
+| Package | Tests |
+|---|---|
+| `internal/mailfetch` | `CreateMailbox` tolerates `ALREADYEXISTS`; `UID MOVE`; `COPY`+`STORE`+`EXPUNGE` fallback; `STORE` flags; `X-GM-MSGID` search; rejects a non-numeric search id |
+| `internal/mailops` | folder → label mapping (incl. replied-by-address and sanitising); delete creates the label; restore does not create `INBOX`; no-op move; rejects `uid:` ids; not-configured; mark flag mapping |
+| `internal/store` | migration v4 adds `folder`/`mailbox`; **opens a hand-built v3 database**; folder/category/unclassified filters; latest classification; folder counts; `MessagesByIDs` |
+| `internal/server` | bulk delete requires CSRF (403) and confirmation; spam needs its own confirmation; delete → Delete folder → restore back to inbox; action-log rows; select-all checkboxes; save + mark; empty selection |
+
+### 2.2 Live, over public HTTPS (`https://mail.lab.audasmedia.com.au`)
+
+Deployed `email-agent:latest` rebuilt and recreated; migration v4 ran in place
+(`schema_version: 4`), the Phase 1–3 `email_agent_data` volume kept.
+
+| Check | Result |
+|---|---|
+| `GET /health` (public HTTPS) | `200 {"status":"ok","env":"production","database":"ok"}` |
+| Login (session + CSRF cookie) | `303`, session cookie set |
+| Dashboard | renders `#inbox-list`, toolbar loaded by HTMX |
+| `/partials/inbox` | `select-all` present, **100** row checkboxes |
+| **Missing-CSRF path** | `POST /actions/bulk` with no CSRF → **`403`** |
+| Delete without confirmation | `200`, no move (message still in inbox) |
+| Delete with `confirm=yes`, no `spam_confirm=yes` (message 100 is `Spam`) | `200`, no move |
+| Delete with both confirmations | `200`; message 100 moved `inbox → delete`; Delete view shows the row with a **Restore** button |
+| Restore | `200`; message 100 moved `delete → inbox`; Delete view no longer lists it |
+
+Message 100 is the single live `Spam` message (`gmail_id
+1878739256124347570`). The restore located it inside the `Delete` label via
+`UID SEARCH X-GM-MSGID` and moved it back, which proves the deleted message was
+present in the Delete label. Live Gmail state is back where it started: the
+final folder count is `inbox=100`, and only the (empty) `Delete` label remains,
+which the app creates on first use.
+
+Container log:
+
+```json
+{"time":"2026-10-11T10:47:09Z","level":"INFO","msg":"message moved","message_id":100,"gmail_id":"1878739256124347570","from":"INBOX","to":"Delete"}
+{"time":"2026-10-11T10:47:12Z","level":"INFO","msg":"message moved","message_id":100,"gmail_id":"1878739256124347570","from":"Delete","to":"INBOX"}
+```
+
+Action-log rows (FR-13, copied from the live database):
+
+```
+(312, None, 'bulk_restore', 'Sam', 'requested=1 ok=1 failed=0 ids=100', '2026-10-11 10:47:12')
+(311, 100, 'move', 'Sam', 'to=inbox label="INBOX" from="Delete"', '2026-10-11 10:47:12')
+(310, None, 'bulk_delete', 'Sam', 'requested=1 ok=1 failed=0 ids=100', '2026-10-11 10:47:10')
+(309, 100, 'move', 'Sam', 'to=delete label="Delete" from="INBOX"', '2026-10-11 10:47:10')
+```
+
+### 2.3 Select-all then delete
+
+The DoD is "select-all then delete works". The toolbar's `Select all` toggles
+every `input[name=ids]` in the loaded group, and the Delete button posts all of
+them. The server applies the action to every id and writes one summary row per
+request (`requested=N ok=N failed=N`). The live run used one id so the job could
+be undone immediately; the multi-id path is covered by
+`TestBulkDeleteThenRestoreIsRecoverable` (two ids in one request) and by
+`parseIDList` deduplicating the posted list.
+
+## 3. Configuration
+
+No new environment variables. The label names are fixed in `internal/mailops`
+(`Delete`, `Saved`, `Replied-to/`). `GMAIL_USER` and `GMAIL_APP_PASSWORD` are
+the only credentials, already present on the host.
+
+## 4. Deployment
+
+On the host, from the repo, with the host secrets in scope:
+
+```sh
+set -a; . ~/.config/environment.d/10-secrets.conf; set +a
+docker compose build
+docker compose up -d # migration v4 runs on start; data volume kept
+```
+
+## 5. Rollback
+
+1. `git revert 318b7b4 8c04167` and `docker compose up -d --build`.
+2. Migration v4 is additive (two columns + one index) and is not undone by the
+ revert; the Phase 3 binary ignores `folder`/`mailbox` and lists every message
+ flat.
+3. Gmail labels created by a move (`Delete`, `Saved`, `Replied-to/…`) are
+ additive and empty after a restore. They can be removed in Gmail by hand.
+
+## 6. Not done / deferred
+
+- The per-row **message detail** pane still shows the Phase 1 placeholder; the
+ reply draft and history are Phase 5.
+- Undo for a move is a manual per-row **Restore** of the Delete/Saved/Replied-to
+ views, not a general undo stack.
+- The 1-month body-retention job (AGENTS.md §7) is still not implemented.
+- `Replied-to/…` labels are populated by the `reply` bulk action now; Phase 5
+ will move a message there automatically when a reply is sent.