diff --git a/core/src/Controllers/MoveDocument.php b/core/src/Controllers/MoveDocument.php
index 2d9d91bc71..8416c3e6a8 100644
--- a/core/src/Controllers/MoveDocument.php
+++ b/core/src/Controllers/MoveDocument.php
@@ -55,12 +55,11 @@ protected function handle()
$document = $this->getDocument($documentID);
- // the form (a=51) checks the source document; the action must too, or the check is bypassable
- $this->checkDocumentPermission($document->getKey(), 'access_permission_denied');
-
- $parents = $this->managerTheme->getCore()->getParentIds($newParentID);
- if (\in_array($document->getKey(), $parents, true)) {
- $this->managerTheme->alertAndQuit('error_movedocument2');
+ // the form (a=51) checks the source document; the action must too, or the check is bypassable
+ $this->checkDocumentPermission($document->getKey(), 'access_permission_denied');
+
+ if (MoveDocumentTargetGuard::isInsideItself($document->getKey(), $newParentID)) {
+ $this->managerTheme->alertAndQuit('error_movedocument1');
}
// check user has permission to move document to chosen location
@@ -87,10 +86,9 @@ protected function handle()
if (MoveDocumentTargetGuard::blocksParent($parentDocument)) {
$this->managerTheme->alertAndQuit('error_parent_deleted');
};
- $children = allChildren($document->getKey());
- if (\in_array($parentDocument->getKey(), $children, true)) {
- $this->managerTheme->alertAndQuit('You cannot move a document to a child document!', false);
- }
+ if (MoveDocumentTargetGuard::isInsideItself($document->getKey(), $parentDocument->getKey())) {
+ $this->managerTheme->alertAndQuit('error_movedocument1');
+ }
$parentDocument->isfolder = true;
$parentDocument->save();
diff --git a/core/src/Support/MoveDocumentTargetGuard.php b/core/src/Support/MoveDocumentTargetGuard.php
index c3fc792eb3..5767bea161 100644
--- a/core/src/Support/MoveDocumentTargetGuard.php
+++ b/core/src/Support/MoveDocumentTargetGuard.php
@@ -2,6 +2,7 @@
namespace EvolutionCMS\Support;
+use EvolutionCMS\Legacy\Permissions;
use EvolutionCMS\Models\SiteContent;
class MoveDocumentTargetGuard
@@ -10,4 +11,40 @@ public static function blocksParent(?SiteContent $parentDocument): bool
{
return $parentDocument === null || (int)$parentDocument->deleted === 1;
}
+
+ /**
+ * True when the target is the document itself or one of its descendants (a move would create a cycle).
+ * Walks the parent column, not the alias listing or closure table, so a stale cache cannot let a cycle through.
+ * @since 3.5.8
+ */
+ public static function isInsideItself(int $documentId, int $parentId): bool
+ {
+ $seen = [];
+ while ($parentId > 0 && !isset($seen[$parentId])) {
+ if ($parentId === $documentId) {
+ return true;
+ }
+ $seen[$parentId] = true;
+ $parentId = (int)(SiteContent::withTrashed()->where('id', $parentId)->value('parent') ?? 0);
+ }
+
+ return false;
+ }
+
+ /**
+ * True when document permissions (use_udperms) deny the current manager user the target folder.
+ * @since 3.5.8
+ */
+ public static function deniedForUser(int $parentId): bool
+ {
+ if (!evo()->getConfig('use_udperms')) {
+ return false;
+ }
+ $udperms = new Permissions();
+ $udperms->user = evo()->getLoginUserID('mgr');
+ $udperms->document = $parentId;
+ $udperms->role = $_SESSION['mgrRole'] ?? 0;
+
+ return !$udperms->checkPermissions();
+ }
}
diff --git a/core/tests/Unit/MoveDocumentTargetGuardTest.php b/core/tests/Unit/MoveDocumentTargetGuardTest.php
index 1b18ed2e7e..32866fe151 100644
--- a/core/tests/Unit/MoveDocumentTargetGuardTest.php
+++ b/core/tests/Unit/MoveDocumentTargetGuardTest.php
@@ -14,3 +14,77 @@
->and(MoveDocumentTargetGuard::blocksParent($deletedParent))->toBeTrue()
->and(MoveDocumentTargetGuard::blocksParent($activeParent))->toBeFalse();
});
+
+class MoveGuardCoreStub
+{
+ public array $config = [];
+ public function getConfig($name = '', $default = null) { return $this->config[$name] ?? $default; }
+ public function getLoginUserID($context = '') { return 1; }
+}
+
+function moveGuardTree(): void
+{
+ $capsule = new Illuminate\Database\Capsule\Manager();
+ $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']);
+ $capsule->setAsGlobal();
+ $capsule->bootEloquent();
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($capsule->getDatabaseManager());
+ $capsule->getConnection()->getSchemaBuilder()->create('site_content', function (Illuminate\Database\Schema\Blueprint $t) {
+ $t->increments('id');
+ $t->unsignedInteger('parent')->default(0);
+ $t->unsignedInteger('deleted')->default(0);
+ $t->boolean('privatemgr')->default(0);
+ });
+ // 1 > 2 > 3 > 4, 5 at root, 6 > 7 forms a corrupt cycle
+ $capsule->table('site_content')->insert([
+ ['id' => 1, 'parent' => 0], ['id' => 2, 'parent' => 1], ['id' => 3, 'parent' => 2], ['id' => 4, 'parent' => 3],
+ ['id' => 5, 'parent' => 0], ['id' => 6, 'parent' => 7], ['id' => 7, 'parent' => 6],
+ ]);
+}
+
+test('a document cannot be moved onto itself or into its own subtree', function () {
+ moveGuardTree();
+
+ expect(MoveDocumentTargetGuard::isInsideItself(2, 2))->toBeTrue()
+ ->and(MoveDocumentTargetGuard::isInsideItself(2, 3))->toBeTrue()
+ ->and(MoveDocumentTargetGuard::isInsideItself(2, 4))->toBeTrue()
+ ->and(MoveDocumentTargetGuard::isInsideItself(2, 1))->toBeFalse()
+ ->and(MoveDocumentTargetGuard::isInsideItself(2, 5))->toBeFalse()
+ ->and(MoveDocumentTargetGuard::isInsideItself(2, 0))->toBeFalse()
+ ->and(MoveDocumentTargetGuard::isInsideItself(3, 999))->toBeFalse()
+ ->and(MoveDocumentTargetGuard::isInsideItself(5, 6))->toBeFalse();
+});
+
+test('target permission check is skipped without udperms and passes for administrators', function () {
+ moveGuardTree();
+ defined('IN_MANAGER_MODE') || define('IN_MANAGER_MODE', false);
+ defined('IN_INSTALL_MODE') || define('IN_INSTALL_MODE', false);
+ defined('EVO_API_MODE') || define('EVO_API_MODE', true);
+ defined('EVO_CLASS') || define('EVO_CLASS', MoveGuardCoreStub::class);
+ require_once dirname(__DIR__, 2) . '/functions/preload.php';
+ global $evo;
+ $evo = new MoveGuardCoreStub();
+ $_SESSION['mgrRole'] = 2;
+
+ expect(MoveDocumentTargetGuard::deniedForUser(1))->toBeFalse();
+
+ $evo->config['use_udperms'] = 1;
+ $_SESSION['mgrRole'] = 1;
+ expect(MoveDocumentTargetGuard::deniedForUser(1))->toBeFalse();
+
+ $evo = null;
+ unset($_SESSION['mgrRole']);
+});
+
+test('tree drag-and-drop and the move action both run the cycle and target permission guards', function () {
+ $root = dirname(__DIR__, 3);
+ $ajax = file_get_contents("$root/manager/media/style/default/ajax.php");
+ $move = substr($ajax, strpos($ajax, "case 'movedocument'"), strpos($ajax, "case 'getLockedElements'") - strpos($ajax, "case 'movedocument'"));
+ $controller = file_get_contents("$root/core/src/Controllers/MoveDocument.php");
+
+ expect($move)->toContain('MoveDocumentTargetGuard::isInsideItself($id, $parent)')->toContain('MoveDocumentTargetGuard::deniedForUser($parent)')
+ ->and(strpos($move, 'isInsideItself'))->toBeGreaterThan(strpos($move, '$parent = $eventParent;'))
+ ->and(strpos($move, 'isInsideItself'))->toBeLessThan(strpos($move, '$document->parent = $parent;'))
+ ->and(substr_count($controller, 'MoveDocumentTargetGuard::isInsideItself('))->toBe(2)
+ ->and($controller)->not->toContain('allChildren(')->not->toContain('getParentIds(');
+});
diff --git a/manager/media/style/default/ajax.php b/manager/media/style/default/ajax.php
index 482542a28b..790a432382 100755
--- a/manager/media/style/default/ajax.php
+++ b/manager/media/style/default/ajax.php
@@ -628,6 +628,10 @@
$parentDeleted = $parent > 0 && MoveDocumentTargetGuard::blocksParent($parentDocument);
if ($parentDeleted) {
$json['errors'] = $_lang['error_parent_deleted'];
+ } elseif (MoveDocumentTargetGuard::isInsideItself($id, $parent)) {
+ $json['errors'] = $_lang['error_movedocument1'];
+ } elseif ($parent != $parentOld && MoveDocumentTargetGuard::deniedForUser($parent)) {
+ $json['errors'] = $_lang['access_permission_parent_denied'];
} elseif (empty($json['errors'])) {
// check privileges user for move docs
if (!empty(evo()->config['tree_show_protected']) && $role != 1) {
diff --git a/tmp/admin-save-performance-plan.md b/tmp/admin-save-performance-plan.md
new file mode 100644
index 0000000000..6d28c86d4f
--- /dev/null
+++ b/tmp/admin-save-performance-plan.md
@@ -0,0 +1,204 @@
+# Evolution CMS manager: making document save the fastest in the phramark admin workload
+
+Date: 2026-09-21
+Source data: `c:\projects\Opensource\phramark\docs\results.json` (generated 2026-09-20T14:43Z),
+live reproduction against the `evo-parser` container on http://127.0.0.1:8080.
+
+## 1. What the benchmark measures
+
+`serverMs` for a step is the sum of every top-level document request (HTML navigations and
+form posts, including redirects) between the click and the settled editor, plus known CMS
+XHR endpoints (Winter's `X-Winter-Request-Handler`, MODX's `/connectors/index.php`) — see
+`benchmark/workloads/admin/lib/document-timer.mjs`.
+
+For Evolution a save is **POST `a=5` + the 302'd GET `a=27`** (full editor re-render).
+For Winter it is one XHR. Evo pays for two requests and a page render where the leader pays
+for one JSON call.
+
+### Admin results, JIT off, median serverMs
+
+| stack | login | open-edit | **save-edit** | open-create | save-create | total wall |
+|------------------|------:|----------:|--------------:|------------:|------------:|-----------:|
+| evo-parser | 243 | 102 | **439** | 84 | 502 | 11.6 s |
+| evo-latte | 252 | 100 | **422** | 83 | 529 | 12.0 s |
+| evo-latte-parser | 253 | 100 | **414** | 84 | 509 | 11.9 s |
+| evo-phalcon | 251 | 104 | **424** | 84 | 518 | 11.7 s |
+| drupal-11 | 323 | 124 | **350** | 44 | 313 | 6.0 s |
+| typo3 | 1241 | 155 | **399** | 111 | 763 | 18.5 s |
+| winter | 193 | 33 | **36** | 34 | 67 | 4.3 s |
+| modx | 367 | 211 | **138** | 169 | 355 | 15.5 s |
+| wordpress-gantry | 263 | 72 | **132** | 79 | 141 | 9.1 s |
+
+Observations before touching any framework question:
+
+1. **All four Evo stacks are identical on admin.** aPhalcon and aLatteX only touch the front
+ end. The admin cost is CMS logic, not the DB adapter or the template engine.
+2. **Save costs 4x an editor load, and `phpPeakMb` jumps 1.2 -> 5.9 MB only on save**
+ (every other step stays ~1.2 MB). That is a compile spike, not data.
+3. Login is 1 808 ms wall for 243 ms server: the frameset renders a **27 228-node DOM**
+ (Winter 5 230, Drupal 2 498). Every step also carries ~60 ms client-side overhead.
+
+## 2. Root cause, reproduced
+
+`manager/processors/save_content.processor.php:467-470` (and `:672-675` for the update
+branch) call `$modx->clearCache('full')` whenever `syncsite=1`, which is the form default.
+That runs `Legacy\Cache::emptyCache()` (`core/src/Legacy/Cache.php:112-144`), which on
+**every document save** does:
+
+- `Illuminate\Support\Facades\Cache::flush()` — wipes the whole Laravel cache store
+- unlinks every `assets/cache/*.pageCache.php`
+- **`opcache_reset()`** — discards the compiled bytecode of the entire Laravel + Evo tree
+- `buildCache()` — regenerates `siteCache.idx.php`; with default settings
+ (`full_aliaslisting=0`) that enumerates all documents (10 000 in the fixture). The benchmark
+ sets `full_aliaslisting=1` (`benchmark/fixtures/setup.php:204`), so this part is skipped
+ there — real-world sites are hit *harder* than the benchmark shows.
+
+The redirected GET then runs on a cold opcache (`opcache.validate_timestamps=0`,
+`memory_consumption=512` in `benchmark/config/php.ini` — nothing was warming it back).
+
+### Live measurement (curl, same session, editor form payload replayed verbatim)
+
+| scenario | POST a=5 | GET a=27 after | sum |
+|----------------------------------|-------------:|---------------:|--------------:|
+| `syncsite=1` (form default) | 260–500 ms | 420–940 ms | ~750–1200 ms |
+| `syncsite=0` | 130–280 ms | 100–170 ms | ~300 ms |
+| warm editor load, no save | — | ~160 ms | |
+| unknown action `a=999` (bootstrap only) | — | ~90–120 ms | |
+
+curl through Docker Desktop adds roughly 40–60 ms per request compared to what Playwright
+records in-container, so relative numbers are the point.
+
+Cost breakdown of a save today:
+
+- **~60 %** cache flush + opcache reset + cold recompile on the redirect
+- **~25 %** fixed manager bootstrap, paid twice (POST + GET): ~75–85 ms per request
+- **~15 %** the processor's own work: ~15–25 independent queries, TV loop with per-row
+ `find()->update()`, `DocumentGroup` reconciliation, `secure_web_documents.inc.php` include
+
+## 3. Plan
+
+### Phase 1 — Stop nuking the cache on save (1–2 days, ~2x faster)
+
+Target: save-edit 439 -> ~200 ms. Ahead of Drupal/TYPO3, level with MODX/WordPress.
+
+- `Legacy\Cache::emptyCache()`: remove `opcache_reset()` from the document-save path. Keep it
+ for element saves (snippet/plugin/chunk/module code) and the explicit "Clear cache" action.
+ This is the one deliberate `core/src/Legacy/` edit; it fixes a concrete bug.
+- Replace `Cache::flush()` with keyed/tagged invalidation (`site_settings`, `doc:{id}`,
+ `siteCache`).
+- `buildCache()` on document save: update the single alias row in the listing, or mark
+ `siteCache.idx.php` stale and rebuild lazily on the next *front-end* request where it is
+ actually consumed. No synchronous 10k-row rebuild inside the manager request.
+- Page cache: delete `docid_{id}` plus ancestors/siblings whose menus render it, not
+ `*.pageCache.php` wholesale.
+- Unit tests for the invalidation matrix (document save / element save / settings save /
+ explicit clear). Re-run `benchmark/scripts/bench` on the `evo-parser` admin cell.
+
+### Phase 2 — Processor diet (2–3 days)
+
+Target: POST work ~40 -> ~15 ms.
+
+- One transaction around the save.
+- TVs: one query for current values, one bulk upsert, one bulk delete. Today: a `distinct()`
+ join over all TVs at the top (`:204-220`), then `find()->update()` per TV (`:577`).
+- Skip `DocumentGroup` reconciliation and `secureWebDocument()` / `secureMgrDocument()`
+ when privacy/group fields are unchanged; `$existingDocument` (`:267`) is already loaded to
+ diff against.
+- Drop `SiteContent::max('id')` / the `id + 1` self-join id hunting on create (`:333-342`)
+ where auto-increment already answers.
+- Move the 700-line processor into `core/src/Services/DocumentSaveService.php`; leave a thin
+ controller in `manager/processors/`. Prerequisite for Phase 3 and for any fast lane.
+
+### Phase 3 — AJAX save, no editor reload (2–3 days)
+
+Target: save-edit ~= one request ~= 90 ms; then bootstrap-bound.
+
+- `a=5` answers JSON when called with `X-Requested-With: XMLHttpRequest` (id, alias,
+ `editedon`, refreshed CSRF token); the editor JS updates the form in place. The form POST
+ remains as the no-JS fallback. CSRF: send `_token` from `` (see
+ AGENTS.md, CSRF rules).
+- `save-create` still needs one navigation to `a=27&id=`, same as Winter.
+- **Fairness:** phramark counts Winter's XHR by header and MODX's by URL
+ (`document-timer.mjs:12-13`). Add the Evo endpoint to that list in the same way so the XHR
+ *is* counted. Otherwise the improvement would be a measurement artefact, not a win.
+
+### Phase 4 — Manager bootstrap diet (1–2 weeks; this is what makes Evo first)
+
+Target: fixed cost ~80 -> ~35 ms per request. Winter's whole save is 36 ms.
+
+- Profile first: add php-spx (or xhprof) to `benchmark/images/php`, trace `a=27` and `a=5`.
+ Candidates, all unverified until profiled:
+ - ~30 service providers booted eagerly on every request -> defer the manager-irrelevant ones.
+ - Lexicon: full language arrays per request -> per-action files.
+ - `OnManagerPageInit` / theme init, `ManagerTheme` action map, Blade compile checks.
+ - Settings and user-permission loading: must hit cache, not DB, per request.
+ - Session: `session.lazy_write`, no unnecessary `session_regenerate_id`.
+- Autoload is 44 ms cold on CLI in the container; under FPM with opcache it should be ~2 ms.
+ Verify the deployed vendor is `classmap-authoritative` in the benchmark image and that the
+ Phase 1 cache changes never invalidate it.
+
+### Phase 5 — Front-end wall time (parallel track, biggest user-visible win)
+
+`totalWallMs` 11.6 s vs Winter 4.3 s; login 1 808 ms wall for 243 ms server.
+
+- Lazy document tree: render root + expanded path only, fetch children on expand (the AJAX
+ expand endpoint already exists — make it the default instead of pre-rendering 27k nodes).
+- Audit per-step client overhead (~60 ms on every action): scripts loaded in the mainframe
+ on every navigation, `stay=2` re-initialising editors.
+- JS/Blade work in `manager/media/style/*` and the tree views; independent of the PHP phases.
+
+## 4. Phalcon in the manager — assessment
+
+Recommendation: do not port the manager to Phalcon. The benchmark itself is the evidence.
+
+- **evo-phalcon == evo-parser on every admin metric** (424 vs 439 save-edit). Phalcon is
+ loaded in that container and buys nothing for the manager, because the manager cost is Evo
+ logic (cache flush, processor, bootstrap), not framework dispatch speed.
+- **The guest win (p50 51 ms vs 393 ms) is not "Phalcon is fast", it is "takeover skips the
+ CMS".** aPhalcon's `frontend.takeover` renders the content row straight into a Latte view:
+ no parser pass, no snippets, no plugin events, no `documentListing`
+ (`c:\projects\Opensource\evo\aPhalcon\README.md`, "takeover" section). A plain-PHP takeover
+ without the extension lands in the same ballpark. Porting the manager to Phalcon without
+ rewriting what it does reproduces evo-phalcon's admin numbers: unchanged.
+- Cost of doing it anyway: a C extension most shared hosts do not ship, a second dispatch
+ model to maintain under `/manager`, and every extra hooking `OnManagerPageInit`,
+ `OnDocFormSave`, etc. needs a shim.
+
+Where Phalcon can be tested cheaply and fairly: after Phase 2 extracts
+`DocumentSaveService`, build a **fast-lane entry point** (`manager/api.php`) that boots only
+session + DB + the service for the AJAX save.
+
+1. Implement it in plain PHP / Illuminate first and measure.
+2. Optionally implement the same lane as a `Phalcon\Mvc\Micro` inside aPhalcon behind a config
+ flag (`manager.fastlane => true`) and measure again.
+
+If the plain lane reaches ~35 ms the extension has nothing left to add. Either way Phalcon
+stays an opt-in accelerator in the sibling plugin, not a core dependency — consistent with
+how aPhalcon is positioned today.
+
+## 5. Expected trajectory — save-edit, serverMs
+
+| after | estimate | rank |
+|-------------|---------:|---------------------------------------|
+| today | 439 | 8 / 8 |
+| Phase 1 | ~200 | 4 (behind Winter, WordPress, MODX) |
+| Phase 2 + 3 | ~90 | 2 (behind Winter) |
+| Phase 4 | ~35–45 | 1–2, tied with Winter |
+
+Phase 1 is a small PR and pays back on every real site immediately — more than in the
+benchmark, given `full_aliaslisting=0` defaults. Start there, land the profiler in the phramark
+PHP image alongside it, and let the Phase 4 profile decide the order of the bootstrap work.
+
+## Appendix — how the reproduction was done
+
+```sh
+B=http://127.0.0.1:8080/manager/index.php
+H=(-A Mozilla/5.0 -H 'Accept: text/html' -H 'Accept-Language: en' -e "$B")
+# manager/index.php returns 404 without Accept-Language; CSRF check needs a Referer
+curl -s "${H[@]}" -c cj.txt -b cj.txt -d 'username=benchmark&password=benchmark-admin&ajax=0' "$B?a=0"
+curl -s "${H[@]}" -c cj.txt -b cj.txt -o edit.html "$B?a=27&id=10104"
+# scrape every input/textarea/select of the editor form into post.txt (stay=2), then:
+curl -s "${H[@]}" -b cj.txt -c cj.txt -o /dev/null -w '%{time_total}\n' --data-binary @post.txt "$B"
+curl -s "${H[@]}" -b cj.txt -c cj.txt -o /dev/null -w '%{time_total}\n' "$B?a=27&id=10104&r=1&stay=2"
+# repeat with syncsite=0 in post.txt
+```
diff --git a/tmp/udperms-check-never-denies.md b/tmp/udperms-check-never-denies.md
new file mode 100644
index 0000000000..46c5cc1659
--- /dev/null
+++ b/tmp/udperms-check-never-denies.md
@@ -0,0 +1,63 @@
+# `Legacy\Permissions::checkPermissions()` never denies a document
+
+Date: 2026-09-21. Found while porting the target-folder permission check into the tree
+drag-and-drop handler (`fix-move-document-missing-checks`, bd4342d83). Not fixed there —
+it is a separate Legacy bug and deserves its own branch.
+
+## Where
+
+`core/src/Legacy/Permissions.php:69-81` (`checkPermissions()`), used by:
+
+- `Controllers\MoveDocument::checkNewParentPermission()` / `processDisplay()` (a=51)
+- `Support\MoveDocumentTargetGuard::deniedForUser()` (tree drag-and-drop, since bd4342d83)
+- `manager/actions/mutate_menuindex_sort.dynamic.php:19-25`
+- `core/functions/actions/mutate_content.php` and the other manager actions that instantiate
+ `new Permissions()` with `->document = $id` (grep `udperms->document`)
+
+## What is wrong
+
+The query that is supposed to answer "may this user see document `$this->document`"
+never filters by that id:
+
+```php
+$query = SiteContent::query()->select('id');
+if (!empty($docgrp)) {
+ $query = $query->leftJoin('document_groups', 'site_content.id', '=', 'document_groups.document')
+ ->where(function ($q) use ($docgrp) {
+ $q->where('document_groups.document_group', $docgrp) // (2)
+ ->orWhere('site_content.privatemgr', 0);
+ });
+} else {
+ $query->where('privatemgr', 0);
+}
+if ($query->count() > 0) { // (1)
+ $permissionsok = true;
+}
+```
+
+1. No `->where('site_content.id', $this->document)`. `count() > 0` is true as soon as *any*
+ non-private (or any group-accessible) document exists, i.e. always on a real site. For every
+ non-admin role with `use_udperms = 1` the check returns true regardless of the target.
+2. `$docgrp` is `implode(' || dg.document_group = ', $_SESSION['mgrDocgroups'])` — a leftover
+ raw-SQL fragment from the original MODX query. Bound as a single value in `where(...)`, it
+ only matches when the user is in exactly one group (`"3"`); with two groups the bound value
+ is the literal string `"3 || dg.document_group = 5"` and matches nothing, so the
+ `privatemgr = 0` branch carries the whole result.
+
+Result: udperms cannot deny moving/creating into a private folder, and cannot deny opening the
+move page for a private document. The only denial that still fires is the root case before the
+query — and it fires for the wrong reason (see the global below).
+
+## Fix outline
+
+- Add `->where('site_content.id', (int) $this->document)` and use `whereIn('document_groups.document_group', $_SESSION['mgrDocgroups'])`.
+- Keep the `privatemgr = 0` OR-branch (public documents stay visible), and keep the root
+ short-circuits above the query unchanged.
+- `global $udperms_allowroot` (`Permissions.php:33`) is never assigned anywhere in core or
+ manager (grep), so it is always null: the "allowed at root" short-circuit (`== 1`) never
+ fires and the root denial (`== 0`, loose) fires for every non-admin creating/duplicating at
+ root even when the setting is on. Read `evo()->getConfig('udperms_allowroot')` instead, as
+ `ajax.php:657` already does.
+- Tests: in-memory `site_content` + `document_groups` with a private folder the user is not in
+ (expect false), one they are in (expect true), a public one (expect true), user in two groups.
+ Then a manager-level check for a=51 and the tree `movedocument` with a private target.