/request` against a live H2D paused on a wrong-plate HMS: the `err`-bearing shape held PAUSE → PAUSE for the full window, the plain `{"print":{"command":"stop","param":"","sequence_id":"0"}}` transitioned PAUSE → FAILED in 1.7s, the same plain `resume` transitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, no `err`, no `job_id`, no `param:"reserve"`. **(2) `IGNORE_RESUME` mapped to the wrong command for paused prints.** The original mapping dispatched `idle_ignore` for both `IGNORE_RESUME` and `NO_REMINDER_NEXT_TIME`. `idle_ignore` is BambuStudio's "dismiss this warning" command and only works for non-pause warnings — verified against the H2D, idle_ignore on a paused print is silently rejected regardless of `err`. `hms_ignore()` now branches on `self.state.gcode_state == "PAUSE"`: paused → dispatch plain `resume` (which is what the button actually means on a paused print), running/idle → keep `idle_ignore` with the `type=0/1` persistence flag. `DONT_REMIND_NEXT_TIME` on PAUSE degrades to resume too — the "don't remind" flag can't ride along on a resume but the user's clicked-action intent (continue printing) is honoured. **(3) 64-bit `hms[]`-array faults truncated to a non-matching `err` (#1830 §(1)).** The hms[] parser at line 2740 built the short code as `f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}"`, discarding 32 of the 64 bits of the fault identifier. For codes whose full form is e.g. `0C00_0300_0002_000C`, the truncated `0C00000C` doesn't match what the firmware compares against in `idle_ignore`. New `HMSError.full_code` field carries the canonical hex identifier — 16 chars `f"{attr:08X}{code:08X}"` for hms[]-sourced faults, 8 chars `f"{print_error:08X}"` for print_error-sourced faults (which are already 32-bit). Catalog lookup tries the 16-char form first and falls back to the 8-char short code so existing entries keep matching. Frontend echoes `error.full_code` back as `HmsActionBody.print_error` instead of recomputing the short code; the schema's pattern relaxes to `^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$` to accept both lengths. **(4) Masking failure — publish-success returned as printer-ack (#1830 §(3)).** `execute_hms_action` returned True the moment the publish succeeded, so any of the three bugs above produced `200 OK` while the printer ignored the command and the modal kept popping. The `/hms/execute-action` route now snapshots `(gcode_state, print_error, hms_errors count)` before dispatch, awaits `HMS_ACTION_ACK_WAIT_SECONDS` (default 2.5s, module-level so tests override), and returns `502 "Printer did not acknowledge HMS action within 2.5s"` if none of those moved. Every accepted HMS action mutates at least one of the three, so this is a clean signal. **Empirical verification.** A test harness on `device/0948BB540200427/request` confirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raises `print_error=0x05008051` ("Detected build plate is not the same as the Gcode file"), the printer enters `gcode_state=PAUSE`, and the new command shapes transition out correctly. The current Bambuddy code (before this fix) failed to act on every button. **Tests.** `test_hms_actions.py` shape assertions rewritten — `test_resume_is_plain_no_err_no_job_id`, `test_stop_is_plain_no_err_no_job_id`, `test_ignore_resume_dispatches_resume_when_print_paused`, `test_ignore_resume_uses_idle_ignore_when_not_paused`, `test_dont_remind_dispatches_resume_when_paused`, `test_dont_remind_uses_idle_ignore_type_one_when_not_paused`, `test_idle_ignore_accepts_16_char_full_code`. New `TestHMSFullCode` class in `test_bambu_mqtt.py` pins the parser contract — `test_hms_array_path_populates_16_char_full_code`, `test_print_error_path_populates_8_char_full_code`, `test_hms_array_catalog_lookup_tries_16_char_first`, `test_hms_array_catalog_falls_back_to_8_char`. New integration cases in `test_printers_api.py` — `test_execute_hms_action_no_printer_ack_returns_502`, `test_execute_hms_action_accepts_16_char_full_code`. The malformed-input test now covers 9- and 15-char rejections (the relaxed pattern accepts 8 OR 16, nothing in between). `pytest -n 30 backend/tests/unit/services/test_hms_actions.py backend/tests/unit/services/test_bambu_mqtt.py backend/tests/unit/services/test_printer_manager.py backend/tests/integration/test_printers_api.py` green (509 + 181). `ruff check` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existing `hmsErrors.actionFailed` string, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". The `HMSError.full_code` field defaults to `""` so old in-memory state surviving a backend upgrade (without an MQTT reconnect) degrades to the existing 8-char short code via the frontend's `||` fallback.
-- **Queue Start/Stop permission gates + ASAP race + /reorder validator (#1625-followup)** — Three issues caught in the post-merge audit of the unified-dispatch PR; all pre-existed on `dev` but became more impactful once every print routes through the queue. **(1) Start/Stop ownership gates.** `POST /queue/{id}/stop` required `QUEUE_UPDATE_ALL` (admin-only) and `POST /queue/{id}/start` required `QUEUE_UPDATE_OWN` with no actual ownership check. Net result: Operators saw the Stop button in the queue UI but got 403 on click; meanwhile any _OWN holder could start anyone's queue item via direct API. Both routes now use `require_ownership_permission(QUEUE_UPDATE_ALL, QUEUE_UPDATE_OWN)` with explicit ownership matching, mirroring `/cancel`. Stop is strict (mirrors cancel — _OWN cannot stop unowned items because stop is destructive and there's no claim semantic). Start is softer (preserves #1670's VP-import flow — _OWN can start NULL-owner items and claim ownership at click-time). Frontend `QueuePage.tsx` Start and Stop buttons flip from `hasPermission('printers:control')` to `canModify('queue', 'update', item.created_by_id)` so the FE matches the BE behaviour exactly. **(2) ASAP TOCTOU race.** Concurrent ASAP inserts to the same printer scope could both compute `MAX(position)` from before the other commits — in a non-empty scope, Postgres's row-level locks on the UPDATE shift serialise naturally, but the empty-scope path has no rows to lock, so both transactions inserted at `position=1` (duplicate). The fix wraps the read+update in a Postgres `pg_advisory_xact_lock(1625, scope_key)` where `scope_key = printer_id or 0`. Transaction-scoped, released automatically at commit/rollback, namespaced by 1625 so it can't collide with other advisory locks elsewhere in the codebase. Different printers don't contend. SQLite serializes writes implicitly so this is a no-op there. **(3) /reorder duplicate-position validator.** `POST /queue/reorder` set `item.position = reorder_item.position` in a loop without uniqueness validation — a buggy drag-drop client sending two items at the same position would leave the queue with ambiguous ordering (the scheduler's `ORDER BY (printer_id, position)` ties break by physical row order, making dispatch non-deterministic). New `model_validator(mode="after")` on `PrintQueueReorder` rejects the payload at the schema layer with 422 + "Duplicate positions in reorder request: [N, …]" so the FE can surface the actionable detail. Uniqueness is enforced WITHIN the payload only — cross-printer reorders that intentionally share positions across different printer queues are a non-goal of the drag-drop UI, so this is the right scope. **Tests.** 7 new integration cases in `test_ownership_permissions.py::TestQueueOwnershipPermissions`: operator can start own item, operator cannot start others' item, operator can start unowned item and claims ownership (#1670 regression guard), operator can stop own printing item, operator cannot stop others' printing item, operator cannot stop unowned printing item, admin can stop unowned printing item. 2 new integration cases in `test_print_queue_api.py::TestReorderEndpoint`: 422 on duplicate positions with "duplicate" surfaced in the detail; 200 on unique positions with positions actually updated in the DB. **Scope.** No DB migration, no new permission, no i18n string change (existing `noStopPrint` / `noStartPrint` keys cover the new ownership-mismatch case verbatim). The advisory lock is Postgres-only and held inside the existing request transaction; SQLite path is unchanged. The /reorder validator runs before the DB session opens any rows.
-- **AMS drying "Rotate spool" toggle no longer offered when any tray is threaded out** — The drying popover's "Rotate spool during drying" toggle was always clickable, but rotation is mechanically impossible whenever any tray in the targeted AMS has its filament threaded out into the feed tube. The whole AMS rotates as one mechanism (all 4 spools turn together), so a single loaded slot locks the entire unit. The firmware enforces this (rejects with `dry_sf_reason=[3]` "ConsumableAtAmsOutlet", surfaced as a 409 toast in `routes/printers.py:1754`), but the user got to the failure only after clicking Start. The new mid-print drying path (above) makes it worse — the temptation to click rotate during a print is now reachable. **Gate signal.** Per-tray Bambu `state`: `9` = empty, `10` = spool present but NOT loaded into tube (rotation possible), `11` = loaded into tube (rotation impossible). `PrintersPage.tsx` derives `trayLoadedInThisAms = (targetAms?.tray ?? []).some(t => t.state === 11)` using the existing `amsData` array (already cached against MQTT flicker). **Why `tray.state === 11` and not the printer-level `tray_now`.** A first cut of this gate keyed on `tray_now` (the global slot currently feeding the toolhead) — but on the H2D, after a print finishes the firmware resets `tray_now` to 255 (nothing actively feeding) while leaving the filament threaded into the feed tube. The tray's `state` stays at `11` in that idle-but-threaded condition; `tray_now` does not. Reported live by a user with all AMS units showing loaded spools and the rotate toggle still active. **Per-AMS isolation preserved.** AMS-A having a tray in state 11 does NOT disable rotation on AMS-B — both can dry, and AMS-B's mechanism is still free. **Submission clamp.** The Start handler also clamps `rotateTray: dryingRotateTray && !trayLoadedInThisAms` before mutating, so a sequence of "user enables rotate on AMS-B → user loads filament from a slot in AMS-B while popover is open → user clicks Start" can't leak `rotate_tray=true` through to the firmware. Without the clamp, the firmware-side rejection would still catch it, but the user would see a 409 toast for a mistake they couldn't have known about. **Conservative on missing state.** Trays with `state === undefined` (older firmware that doesn't populate the field) are treated as not-loaded — rotation stays available and the firmware-side `dry_sf_reason` check remains the safety net. **i18n.** 1 new key (`printers.drying.rotateUnavailableReason`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5355 leaves per locale, no English fallback. **Tests.** 9 new cases in `PrintersPageDrying.test.ts::rotate tray gate` cover: null `targetAmsId` (modal closed) → false; AMS id not in amsData → false; all trays empty (state=9) → false; all trays spool-present-not-loaded (state=10) → false (the case the user reported was firing incorrectly); any tray loaded (state=11) → true; per-AMS isolation (loaded AMS-A leaves AMS-B free); missing `tray` array → false; missing `state` field → false (conservative default-allow); submission clamp collapses to false when gate active, passes through when inactive. Vitest 41/41 green. Frontend `npm run build` clean.
-- **Archives drag-and-drop overlay stuck after cancel (#1510, reported by @maikolscripts)** — Cancelling a drag on the Archives page — by dragging back out of the browser window, releasing outside the page, or pressing Escape mid-drag — left the full-screen "Drop .3mf files here" overlay visible until the user refreshed. **Cause.** The old inline `handleDragLeave` only hid the overlay when `e.currentTarget === e.target` (i.e. the dragLeave event fired on the wrapper itself, not a child). That condition was structurally safe for crossing internal element boundaries but rarely held for the three cancel paths above — drag-out-of-window fires dragLeave with `target` at the nearest child to the cursor; Escape and drag-abort fire no leave event at all on the wrapper. **Fix.** Moved the page-wide drop handling into the new `usePageFileDrop` hook (also consumed by File Manager — see the linked Added entry). The hook checks `relatedTarget` containment instead of `currentTarget === target`, and adds document-level `drop` / `dragend` / `keydown(Escape)` listeners that only register while `isDraggingOver === true` so the cancel paths all reset uniformly. Three of the 13 new hook test cases pin the cancel paths explicitly so a future regression on any one of them fails its own case. Also moved the previously-hardcoded English "Drop .3mf files here" string in `ArchivesPage.tsx:3202` to the existing `archives.page.dropFilesHere` i18n key (which already had translations in all 11 locales) so the overlay localises correctly — same change of behaviour as `archives.releaseToUpload` already had.
-- **File Manager list-view column headers misaligned with their body cells** — Both the header row and each list row used the same `grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]` template — looked correct at the CSS level — but the two `