World root data model — single shared root, default-to-World, backfill
Created: Wed 24 Jun 2026 10:09:24 CEST Updated: Wed 24 Jun 2026 10:09:24 CEST - Initial design Document Version: 1.0 - Initial design Security Classification: Internal Technical Documentation Target Audience: Backend Engineer, iOS Engineer, Architect Author: Paul Wisén
Scope: cross-repo (Plings-API + Neo4j migration, Plings-iOS). System-spanning, so this spec lives in the Plings-Web docs hub. Each executing repo gets its own implementation plan. This is sub-project #1 of a three-part vision (see “Out of scope” / “Future” below).
Problem
Today “root” means “no relationship”: a top-level ObjectInstance has zero spatial edges
in Neo4j, and “is this a root?” is answered by the absence of a parent. This is elegant in the
abstract but leaks everywhere:
- The backend can unparent CURRENT but not NORMAL.
moveObject(id, null)deletes theCURRENT_INedge (current→root works), but there is no op to clearNORMAL_IN. The navigator is the NORMAL home-map (ADR-0020), so dragging an object onto the globe can never re-home it — its home stays put and the list doesn’t change. This is the “can’t drag up to root” bug. isAwaycan’t classify a homeless object — it returnsfalsewhennormalParentIdis nil (it needs both sides), so “no home” is indistinguishable from “at home.”- Every “where does it live” path forks on nil —
residents(of: nil), ghost-parent rules, nil-guards. Root is a permanent special case.
Vision context (why a real root, not a patch)
Plings is a world-spanning system — a global knowledge graph of physical objects. If everything physical lives in the world, then “an object with no location” should not be representable at all. The root of containment is a real thing: the World. The model must be depth-agnostic — the day Plings reaches the Moon we add “Universe” above World with World/Moon beneath it; later we may insert continents → countries → municipalities between World and a property. The graph simply shows the highest objects (that you can see) which have sub-objects. Navigation at scale is a map (pan/zoom-to-enter), not breadcrumb drill-down — but that is presentation; this spec is the data model underneath it.
ADR-0039 anticipated this: “a globe fits the world-spanning vision and survives the future scope-root idea.”
Decision
Introduce a single shared World root ObjectInstance. Every object always has a real
parent chain ending at World; nil survives only as World’s own parent — the one universal
base case. This replaces the implicit “root = no relation” model.
1. The World node
- A single system-owned
ObjectInstancewith a well-known fixed id:00000000-0000-0000-0000-000000000001(a reserved sentinel both API and app hardcode, so no lookup is ever needed). - Name “World” — Plings’ native language is English; “World” (not “Earth”) is the abstract top so we can insert “Earth” (and “Universe”) beneath/above it later. Localized display names are a future i18n concern, not this slice.
type = 'world'(marks it a system/place node);owner_organization_id = NULL,created_by = NULL,short_code = NULL— all confirmed nullable inobject_instances, so no schema change and no system-org row are required.- Public parent: anyone may place objects under World.
createObject’s access check special-cases the World id as an always-valid parent. - Lifecycle guards: World cannot be deleted, moved, or re-homed. The API rejects delete/move/relationship-mutations targeting the World id; the app hides those actions for it.
2. Default-to-World + backfill (the two load-bearing requirements)
- New objects default to World.
createObjectwith nospatialParentIdnow createsNORMAL_IN WorldandCURRENT_IN World(the same dual-write it already does for a real parent — seeobject_creation_resolvers.py:514-526). A new object defaults to World unless placed somewhere else. - Backfill migration (one-time, idempotent). A Neo4j migration (plus the Postgres + Neo4j
World node creation):
- Create the World node if absent (Postgres row + Neo4j
ObjectInstance), idempotent on the well-known id. - For every
ObjectInstanceexcept World with noNORMAL_INparent → createNORMAL_IN World. - For every
ObjectInstanceexcept World with noCURRENT_INparent → createCURRENT_IN World. Re-running is a no-op (guarded by the same “has no such edge” predicate). After backfill no object is ever homeless, which is what makesisAwayand the home-map logic correct.
- Create the World node if absent (Postgres row + Neo4j
3. iOS — root becomes World, the drag bug disappears
- New PlingsKit constants
WORLD_ID(the sentinel uuid) andWORLD_NAME(“World”). - The navigator root view =
residents(of: WORLD_ID)instead ofresidents(of: nil). It stays filtered to objects you own becausemyObjectsLightonly returns your objects — so you see your top-level things under World, not the whole planet’s. - The globe breadcrumb crumb’s drop target becomes
containerId: WORLD_ID(wasnil). - Drag-to-globe is now an ordinary home-map drop →
sortInto(WORLD_ID, …)→NORMAL_IN Worldvia the existing op. The current-only root-drop special-case added intask/navigator-drag-sortis deleted — re-homing to root just works because World is a real container. (This is the bug fix, achieved by the model rather than a patch.) nilis removed from the app’s home-map logic; it remains only as World’s parent, handled by the single “World is the root” base case.- The app references World by constant, so it does not need World returned in
myObjectsLight(the constant carries id + display name). World need not appear as a row in the user’s owned list.
4. Depth-agnostic guarantees + what is explicitly OUT
- Nothing hardcodes a level count. World is just a container; future tiers (Universe above;
Earth / continents / countries / municipalities below) are ordinary
ObjectInstances inserted into the chain with no migration to this model. - Out of scope (own future specs):
- Geographic tiers (Sverige/Kramfors …): not built. Top-level objects sit flat under World for now.
- Visibility (seeing others’ public places like a store “Rusta”): parked → its own spec. Today’s ownership filter is sufficient for #1.
- Map navigation (pan/zoom-to-enter): parked → vision spec.
- i18n / localized root name: parked.
- Forward note: once deep geo + visibility exist, “my root view” becomes the highest objects I own/can see — not literally World’s residents. That computation is sub-project #2’s job; #1’s flat-to-World is correct until then, and does not block it (visibility layers on as an object attribute + query filter).
Rollout / merge order
Cross-repo, so deploy backend first:
- Plings-API — World node creation + backfill migration +
createObjectdefault-to-World + access special-case + delete/move guards. Run the migration against Neo4j Aura + Supabase. - Plings-iOS —
WORLD_IDconstant, navigator root = World, drag-to-globe, delete the root-drop special-case.
The deployed app must never re-home to World before the backend can accept NORMAL_IN World, so
iOS ships after the API.
Testing
Plings-API (pytest) + migration:
createObjectwith nospatialParentId→ object hasNORMAL_IN WorldandCURRENT_IN World.createObjectunder a real parent → unchanged (links to that parent, not World).- Backfill: an orphan (no NORMAL_IN / no CURRENT_IN) gets both edges to World; an object already parented is untouched; re-running the migration changes nothing (idempotent).
- World cannot be deleted / moved / re-homed (mutations rejected).
myObjectsLightfor an object under World returnsnormalParent = World.
Plings-iOS (swift test + device):
residents(of: WORLD_ID)lists the user’s top-level objects; objects already under World are unchanged in the list.isAwayis correct for an object whose home is World (home == current World → not away; moved out → away).- Drag a top-level object onto the globe → re-homes to World (home-map both/away rules apply); the previously-broken “drag up to root” now works.
- Device (load-bearing): drag to globe re-homes immediately; force-quit + relaunch offline → it stays; a brand-new object appears under World instantly.
ADR
A new ADR records this: the World is a real shared root; nil-as-root is retired. It
supersedes the implicit no-relation-root model and refines ADR-0039 (globe root),
ADR-0005 (move writes CURRENT), ADR-0020 (navigator reads NORMAL tree), and ADR-0044 (navigator
drag is a home-map op — the root-drop carve-out it noted is now removed). Ships with the
implementation (code-coupled; the API PR is the natural home, cross-referenced from iOS).
Open questions / implementation notes
- Well-known id value — proposed
00000000-0000-0000-0000-000000000001; confirm it does not collide with any existing row (it won’t; ids are generated uuids). - Neo4j migration mechanism — follow the WS3-A precedent (a one-time guarded Cypher script); confirm where migration scripts live in Plings-API and whether they run via a script or a resolver-guarded one-shot.
spatialHierarchy/ breadcrumb depth —myObjectswalksCURRENT_IN*1..10; World adds one level. Confirm the depth cap (10) is comfortable; revisit when geo tiers land.