Authorization in the query: 404 not 403, and the draft rule
Last lesson you could upload a document; you could not yet look at one. This lesson builds the owner's surface — view, download, delete a draft — and it is really a lesson about where authorization lives.
Put the ownership check in the query, not after it. The tempting shape is: fetch the document by id, then if doc.OwnerID != me { 403 }. It works, but the check is a separate step a future edit can forget. The stronger shape folds ownership into the WHERE:
-- name: GetDocumentForOwner :one
SELECT * FROM documents
WHERE id = $1 AND owner_id = $2;
Now another user's document simply is not in the result set — the query returns pgx.ErrNoRows, exactly as it would for an id that does not exist. There is no fetched-but-forbidden state to mishandle. Authorization is a property of the data access, not a guard bolted on afterward.
404, not 403 — and why the difference matters. When someone requests a document they do not own, the honest-looking answer is 403 Forbidden. But 403 confirms the document exists — an attacker probing ids learns which ones are real. 404 Not Found reveals nothing: "there is no such document for you" is the same answer whether the id is fake or simply someone else's. Because GetDocumentForOwner collapses both cases into ErrNoRows, returning 404 for both is natural — the handler cannot even tell them apart.
The first real business rule: only a draft can be deleted. A document's lifecycle is draft → sent → completed. Once it has been sent for signature, its record is immutable — you cannot pull it out from under people who were asked to sign it. That rule lives in the delete query itself:
-- name: DeleteDraftDocument :execrows
DELETE FROM documents
WHERE id = $1 AND owner_id = $2 AND status = 'draft';
:execrows returns the number of rows deleted. A sent document, a foreign document, a missing id — all delete zero rows. The handler keeps a friendly pre-check (a sent document gets a clear 422), but the enforcement is the WHERE: even if you deleted the Go check, the database would still refuse.
HTMX again — the list heals itself. Delete posts with hx-post and targets #doc-list; the handler replies with the refreshed list partial, which swaps in place. No reload, no manual DOM surgery — the server re-renders the truth and htmx drops it in. That is the whole track's argument in one interaction: the server owns the HTML, and the page still feels live.
This lesson: the two owner-scoped queries, the view / download / delete handlers built on them, the document detail page, and row actions on the dashboard.