Backend
- New POST /api/executive-meetings/rotate-content (zod-validated) rotates
ONLY meeting content through fixed (start_time, end_time, daily_number)
slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale;
incomplete day (missing visible row) → 400.
- ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts).
- 6 backend tests cover happy path, stale, different_dates, 401, 403,
incomplete_day. Existing /swap-times tests still pass.
Frontend (artifacts/tx-os)
- Whole <tr> is now the drag handle (the dedicated GripVertical button is
retired). useSortable is gated on canMutate; safeRowDragListeners
filters drags whose target is an interactive descendant (button, input,
edit/time cells, row-actions, bulk-select). useSortable `attributes`
are spread only when canMutate so view-mode rows stay clickable
(otherwise aria-disabled blocked the popover trigger).
- onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns
each chronological slot's tuple to the new occupant; rolls back + toast
on failure.
- Quick-actions popover now contains only Postpone (#486 Move up/down
buttons removed).
Tests (artifacts/tx-os)
- New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie
position by # cell, asserts rotate-content fires and slots stay
anchored.
- tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down
cases, keeps Postpone + skip-surfaces + viewer.
- tests/executive-meetings-schedule-features.spec.mjs: two legacy grip
drag tests rewritten to drag the row body and target /rotate-content
(the legacy /reorder route + tests are intentionally untouched).
Drift / notes
- Architect flagged a medium-severity hardening note: rotate-content
FOR UPDATE locks orderedIds but not the day-scope completeness query.
Out of #489 scope; no follow-up created (proposeFollowUpTasks was
already consumed on #486).