`tickets.merged_into_id` is `nullOnDelete()` and `destroy()` was a one-liner, so
deleting the surviving ticket of a merge blanked the pointer on every ticket
merged into it and left `status = 'merged'` behind. The row that came out was
read-only for good (#920 guards on the status), carried a banner naming a ticket
that no longer existed, reported `merged_inconsistent = false` — the flag that
exists to catch exactly this — and dropped out of the Merged view, which was
scoped by the pointer, leaving it visible only under Resolved.
`StrandedMerge::handleTargetDeletion()` runs inside destroy()'s transaction,
before the row goes: sources are RE-POINTED at the doomed ticket's own merge
target when it has one (the messages really did travel that far up the chain),
otherwise UN-MERGED to `closed` — out of the work queues, as they were, but
editable, reopenable and answerable again — with a system note naming what was
deleted and why. #938's dangling pointers are simply cleared: those rows are
somebody's open work. The flash names every ticket that changed, and the delete
confirm names them before the click instead of the generic "cannot be undone".
The shape prod already holds gets a name: `scopeStrandedMerge()`, reported as
`merged_inconsistent` (now the union of both broken shapes) plus
`merged_stranded` so a page can tell which fix to offer, listed by the Merged
view via `scopeEverMerged()`, and released one at a time by `unmerge`. #938's
repair scope is deliberately untouched — it writes `status = 'merged'`, which a
stranded row already has, and matching it would break its idempotency.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#920 made a merged ticket read-only on the server and shipped NO data change,
deliberately: its guard reads `status === 'merged'` and not
`merged_into_id !== null`, so the rows the bug had already produced — the
pointer set, the status back to `open` — stayed EDITABLE and could be corrected
in the product instead of in SQL. What it left behind was rows that are
simultaneously live in the All Open queue and merged into another ticket, with
nothing on any screen saying so and nothing offering to fix them.
Both halves of the card ship, because option A is a BEHAVIOUR RULE and a
behaviour rule is a setting:
B — the office fixes them one at a time. `merged_inconsistent` rides on the
model (Ticket::$appends), so the list row, the index detail pane and the
Show header all draw the same amber badge — "merged_into set but status
open — fix?" — off one predicate rather than three copies of an
expression. The badge IS the button: it opens a modal that says what will
change and what will not, and posts to a new `mark-merged` verb that
writes `status` and nothing else. A new "Merged" view lists every ticket
folded into another one BY THE POINTER, so the consistent and the broken
appear side by side; a status filter would show one or the other and
never the pair worth comparing.
A — the migration re-marks all of them at once, and it is INERT unless
`ticketing.auto_repair_merged_on_deploy` is on. It defaults OFF, which is
also what a box that has never heard of the setting does, so this can
ship ahead of the operator's decision and change nothing unasked. The
toggle is on the Automation tab with the live count beside it, because
nobody should arm a bulk data change without seeing how many rows it
touches.
`MergedTicketRepair` is one implementation with three callers for one reason:
"which rows are broken" and "what fixing one means" must not drift, or the
badge offers a fix on a row the migration skips.
THE PROD COUNT THE CARD ASKS FOR IS NOT IN HERE, and it could not be: this
worker box holds no ssh private key, so central-prod is unreachable from it and
there is no prod container locally. `php artisan ticketing:repair-merged
--dry-run` is the read-only answer for whoever does have access — it prints the
card's own SELECT COUNT and lists the rows, and `--force` (not the bare form)
is the only spelling that writes.
Repairing NEVER reverts. Messages, participants, assignee and title edits made
to a row while it was wrongly editable are somebody's work and the only copy of
it; there is no record of what the fields held before the bad save, so
"reverting" would invent a previous state rather than restore one.
Proved by tests/Feature/Ticketing/MergedTicketInconsistentRowsTest.php (22
tests) in dashboard-shell, over the fixture the card names — one inconsistent
row beside a genuinely merged one, both produced by the real merge endpoint.
The NEGCTRL half is what keeps it honest: the consistent row and an ordinary
open ticket must come out of every path untouched, and a repaired row must keep
the reply and the rename it was given after the merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Setting a ticket's Due Date returned 303 with "Ticket updated." and never saved
the value. Reproduced by the QA sweep on all three paths that expose the field —
the edit form, the update endpoint and creation.
A success message covering a silent discard is worse than an error: nobody goes
looking, and the operator believes a date is set that no query will ever find.
2,862 passed across the affected suites. Committed by the lead — the authoring
agent stopped without reporting, so the code is the account of record.