Skip to content

[Maintainer] Refuse parent changes that create hierarchy cycles - #1234

Merged
Aymericr merged 9 commits into
mainfrom
cursor/hierarchy-cycle-guard-7190
Nov 9, 2026
Merged

Aymericr merged 9 commits into
mainfrom
cursor/hierarchy-cycle-guard-7190

Conversation

@Aymericr

@Aymericr Aymericr commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Fixes #975. A parent change can no longer make a node its own ancestor:

  • The scene store's createNodes, updateNodes and applyNodeChanges throw invalid hierarchy: placing "<id>" under "<parent>" would create a cycle instead of writing the cycle.
  • apply_patch refuses an update or a create that would close a cycle as invalid_parent and leaves the scene unchanged. That includes a cycle closed by a later op in the same patch.
  • SceneBridge.validateScene and verify_scene report an existing parent cycle, one parentId error per node in it (schema_invalid in verify_scene). As a result, save_scene refuses to save the current scene while it holds a cycle (scene_invalid). Such scenes still load. Edits beneath an existing cycle still apply, and moving one of its nodes to a valid parent repairs it.

A placement adds one parent link, so the only cycle it can close runs through the placed node. The guard refuses exactly that case. A cycle the scene already holds elsewhere on the chain does not block the edit; the scene checks report it instead.

This ports #980 by @Vansh0Sharma onto current main; the first commit keeps his authorship. It differs from #980 in two ways:

Later commits:

  • Regression tests for both reproductions in apply_patch can create and persist a schema-valid cyclic Site hierarchy #975.
  • The apply_patch row in packages/mcp/README.md.
  • An empty commit that only re-ran CI.
  • The Codex review fix: a create whose own parentId closes a cycle is refused as invalid_parent instead of surfacing the store's error as a protocol error.
  • stage-runtime.ts builds the bundled MCP server and geometry worker with --minify-whitespace, so the packed CLI fits its existing budget again (see CI notes).
  • Review fixes:
    • The guard counts only a cycle through the placed node. Before, an update beneath a cycle an old scene already held moved the wall, then failed on the zones derived from it.
    • verify_scene lists parent cycles as validateScene does, from one shared hierarchyCycleErrors.
    • The stage-runtime.ts comment now gives the actual reason for whitespace-only minification.

Not covered:

  • save_scene with an explicit graph argument still checks node schemas only, as before.
  • Mounting a scene that already holds a parent cycle with no level on it still hangs: the spatial grid sync calls resolveLevelId (packages/core/src/lib/node-ancestry.ts), whose parent walk has no guard. That predates this PR, which only stops new cycles from being written, and is left for a follow-up.

How to test

  1. Through MCP, on a scene with two root sites, send one apply_patch containing { op: 'update', id: 'site_a', data: { parentId: 'site_b' } } and { op: 'update', id: 'site_b', data: { parentId: 'site_a' } }. Expected: a refusal { code: 'invalid_parent', patchIndex: 1, id: 'site_b' }, and both sites keep parentId: null.
  2. On the default scene, send an apply_patch that moves the site under its own level. Expected: invalid_parent, and the hierarchy is unchanged.
  3. Send one apply_patch creating site_a with parentId: 'site_b' and site_b with parentId: 'site_a'. Expected: invalid_parent at patch index 1, and neither site exists.
  4. Load a scene whose two sites name each other as parent. Expected: validateScene reports hierarchy cycle detected: site_a -> site_b -> site_a for both nodes, verify_scene returns valid: false with a schema_invalid issue on each one's parentId, and save_scene of the current scene returns scene_invalid.
  5. Load the default scene with a room of four walls under its level and a second site, where the two sites name each other as parent. Then apply_patch an update that moves a free wall across the room. Expected: the whole patch applies, including the zone it derives for the split room, and validateScene still reports the cycle.

Run locally on this branch, which is based on main at 78aaf93, at the head commit:

  • bun run ci with TURBO_FORCE=1 (nothing cached): biome check, i18n:check, skills:validate, check-types (14 of 14 tasks), turbo run test (15 of 15 tasks, among them core 7065 pass and 3 skip, mcp 1523, nodes 5230 and 1 skip, editor 2856, viewer 1350, cli 139) plus the root script tests, turbo run build (9 of 9), and the production editor server smoke
  • The packed CLI path that cli-smoke runs: a portable editor build, then build, stage-runtime and smoke-runtime in packages/cli

Screenshots / screen recording

N/A — non-visual change.

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

CI notes

  • quality is green at the head. Two runs at earlier heads failed on timing assertions that also fail on main: ifc-converter's "grows linearly with the number of rooms" and viewer's "a big floor is planned in one frame's budget". A local A/B of the viewer test showed no difference between main and this branch.
  • cli-smoke is green at the head: "Packed CLI smoke passed (1.9 MB compressed, 7.9 MB unpacked, 72 files)." The budget in packages/cli/scripts/smoke-packed-runtime.ts is unchanged. main sat within a few KB of its 10 MiB unpacked limit, and this PR's cycle checks pushed the packed CLI 984 bytes over (10,486,744 B).
    • stage-runtime.ts now builds both bundles with --minify-whitespace. npm pack measures 1,978,102 B compressed, 8,295,851 B unpacked and 72 files, against limits of 3 MiB, 10 MiB and 250, which leaves 2,189,909 B of unpacked headroom. The bundled MCP server shrinks from 6.3 MB to 5.0 MB and the geometry worker from 3.9 MB to 3.1 MB.
    • Whitespace minification changes no identifier or statement. The bundles installed from the packed tarball parse to the same AST as unminified builds of the same commit. Under Node 22, an MCP session of 25 steps and five geometry-script runs return the same results from both builds.
Open in Web Open in Cursor 

Note

Medium Risk
Changes core scene mutation and MCP patch validation paths; incorrect cycle logic could block valid reparents or allow bad graphs, but behavior is heavily tested and scoped to parentId assignments.

Reviewed by Cursor Bugbot for commit 498419a. Bugbot is set up for automated code reviews on this repo. Configure here.

Vansh0Sharma and others added 3 commits November 8, 2026 23:06
Add apply_patch regressions for the two reproductions in #975: a cycle
closed by a later update in the same patch, and a site moved under its
own level. Both are refused as invalid_parent and leave the scene
unchanged. Cover SceneBridge.validateScene reporting a parent cycle,
which replaces the validate_scene test that no longer applies on main.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-08T23:14:09.516511Z 1c8d993 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c8d993c7a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/mcp/src/tools/patch-guards.ts Outdated
Comment on lines +305 to +306
const newParentId = typeof data.parentId === 'string' ? (data.parentId as AnyNodeId) : null
if (wouldCreateHierarchyCycle(patch.id, newParentId, scene().nodes)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate cycles in create patches before applying them

When a create embeds parentId in its node—such as a site whose parentId equals its own ID, or two creates whose embedded parents reference each other—the create branch never calls wouldCreateHierarchyCycle, so this new check is reached only later when the store throws. registerApplyPatch then converts the ordinary error into an MCP InvalidParams protocol error rather than the documented structured invalid_parent refusal. Apply the same progressive-scene cycle check to creates before calling put, so every hierarchy-cycle refusal has the promised { code, patchIndex, id, message } envelope.

Useful? React with 👍 / 👎.

A create whose own parentId closes a cycle (itself, or a node created
earlier in the same patch that names it) reached the store's guard and
came back as a protocol error. Check creates in the patch guard too, so
every cycle refusal carries { code, patchIndex, id, message }. Both
checks walk the progressive view through at(), so a create-only patch
still never copies the scene.
…acked budget

The hierarchy cycle guard adds 4.1 KB to both bundles and takes the packed
CLI 984 bytes over its 10 MiB unpacked budget. Whitespace-only minification
leaves identifiers and syntax as written, so zod's and three's runtime
constructor.name reads are unaffected, and both bundles parse to the same
syntax tree as before. Packed size drops from 10,486,744 to 8,295,698 bytes
unpacked and from 2,182,122 to 1,978,032 bytes compressed.
wouldCreateHierarchyCycle refused any placement whose parent chain revisits
a node, even when the walk never reaches the node being placed. In a scene
that already holds a cycle, every create beneath it was refused, including
the zones the structure commit derives after an agent's update has landed,
so apply_patch moved the wall and then reported failure. A placement adds
one edge, so the only cycle it can close runs through the placed node; a
revisit elsewhere ends the walk with no new cycle, and the old one stays
reported by validateScene.
save_scene refused a cyclic scene through the bridge's validateScene while
verify_scene, which every surface shares, still called it valid. Both checks
now take each cycle's parentId errors from hierarchyCycleErrors, so
verify_scene's valid is validate_scene's valid and each cycle node is a
schema_invalid issue on its parentId.
zod pins its class names and three reads constructor.name on typed arrays,
so neither is what identifier renaming would break; the reason is that
whitespace-only output is the same program token for token, keeping the
names stack traces and name-reading code see.
@Aymericr Aymericr changed the title core: refuse parent changes that create hierarchy cycles [Maintainer] Refuse parent changes that create hierarchy cycles Nov 9, 2026
@Aymericr Aymericr added the bot:maintainer label Nov 9, 2026 — with Cursor
@Aymericr
Aymericr merged commit 7d45390 into main Nov 9, 2026
6 checks passed
@Aymericr
Aymericr deleted the cursor/hierarchy-cycle-guard-7190 branch November 9, 2026 00:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

apply_patch can create and persist a schema-valid cyclic Site hierarchy

3 participants