The Show page announced "This ticket has been merged and is read-only.",
hid the reply composer and withheld the Merge button — and that was the
whole guard. It was presentation, on one page.
The Edit link and the Split button were still rendered, `edit()` still
served the form (with its Status select blank, because `merged` was not
one of the five slugs the form hard-coded), and `update()` took the save
with `errors: {}`: status went back to `open` while `merged_into_id` kept
pointing at the merge target. The row that came out was live and
assignable in the All Open queue AND merged into another ticket, holding
none of its own messages because the merge had moved them — an empty
shell that reads as a real duplicate of the ticket it was folded into.
So the rule now lives on the server, once — `Ticket::isMerged()` and
`refuseIfMerged()` — and every write the page presents as unavailable
calls it: update, edit, split, participants add/remove, and a reply or an
attachment (both hidden by the banner, neither refused by its route).
Two deliberate choices:
- the guard reads `status === 'merged'`, NOT `merged_into_id !== null`.
Reading the pointer would look stricter and would freeze every row
this bug already minted (`status = open, merged_into_id = 56`), so
the only way to correct one would be SQL.
- it refuses with a redirect carrying the reason, not `abort(403)`.
The realistic way to arrive here is a page that was already open when
somebody else merged the ticket, and that operator is better served
by the sentence than by an error screen.
The blank Status select was the report's second symptom, and it was the
same defect one layer up: the five hard-coded options meant any ticket
whose status was not among them — `merged`, or any status added on the
Statuses settings tab — rendered with nothing selected, and the first
save silently rewrote the status to whatever happened to be showing. The
options now come from the same table `update()` validates against.