1bbaf84d42
Red-team finding #9: two opposing moves racing each other — A drags X under Y while B drags Y under X — each passed a cycle check built from a stale snapshot and both committed, leaving X.parent=Y AND Y.parent=X. That cycle has no path to root, so the recursive ancestor CTEs break and both subtrees disappear from the sidebar. The cycle guard (breadcrumb read) and the parent-update write were not in a transaction. For a genuine re-parent under a concrete parent (the only cycle-capable path), wrap the guard + update in one executeTx transaction and: - lock the moved page and the destination parent rows FOR UPDATE (pageRepo.findById(id, { withLock: true, trx })) in a canonical, id-sorted order so the two opposing moves serialize instead of deadlocking; - re-run the ancestor cycle check INSIDE the transaction, so the loser sees the winner's committed re-parent and is rejected with the existing "Cannot move a page into its own subtree" error. Same-parent reorder (parentPageId undefined) and move-to-root (null) keep the plain non-transactional update — neither can create a cycle. The phantom- broadcast gate and PAGE_MOVED emit are unchanged. getPageBreadCrumbs gains an optional trx so its recursive CTE can run inside the locked transaction. Scope: this closes the two-party opposing-move race (finding #9). A longer 3-party chain (A→B, B→C, C→A) is out of scope and left as a known limitation. Tests: - unit (page.service.spec.ts): assert the lock wiring — findById called with { withLock: true, trx } for both ids in sorted order, getPageBreadCrumbs and updatePage receive the trx — while keeping the self-move / cycle-reject and legitimate-move guarantees. - integration (page-move-cycle-concurrency.int-spec.ts, real Postgres in CI): two opposing concurrent moves resolve to exactly one success + one rejection and never persist a cycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>