Design: Positional spatial relations — sibling placement, lifecycle, normal + display
Design: Positional spatial relations — sibling placement, lifecycle, normal + display
Created: Sun 28 Jun 2026 17:36:37 CEST Updated: Sun 28 Jun 2026 17:36:37 CEST - Initial design Document Version: 1.0 Security Classification: Internal Technical Documentation Target Audience: API Engineer, iOS Engineer, Product Author: Paul Wisén
Cross-repo (Plings-API + Plings-iOS). Modifies the canonical spatial model (
docs/core-systems/spatial-relationships.md,relationship-decision-guide.md). Yields two implementation plans:Plings-API/docs/plans/(resolver) thenPlings-iOS/docs/plans/(model + UI).
Context
Placing an object with a positional relation in Flytta is broken. Device test (Paul, 2026-06-28): scan Plastlåda yTvK (current Saltsjön › Lillstugan › Köksbordet) → Flytta → UNDER Bord (5GgB) (Bord is in Vardagsrummet) → Både. Expected: X lands in Vardagsrummet (Bord’s container), since “under Bord” is neither in nor on Bord. Actual: current stayed Köksbordet, home became NORMAL_IN Bord.
Root cause (current behaviour, by the Flytta v1 spec):
MoveFlow.commitfor a positional relation callsmove(into: Y, relationship: .currentUnder)— butObjectStore.moveonly moves the parent for parent-creating relations (in/on), so the parent is untouched (stays Köksbordet); the API resolver’s positional branch also only writes the edge, not the parent. AndsetNormalLocation(into: Y)always writesNORMAL_IN Yregardless of relation, so home wrongly becomes “inside Bord”.
A positional relation means X is a sibling of Y (shares Y’s container), positioned relative to it. That semantic is not implemented anywhere. This spec builds it correctly, end to end.
Decisions (locked in brainstorming)
- Placement = sibling inheritance. Placing X with relation R against Y:
- Parent-creating R (
IN,ON,ATTACHED_TO) → X’s parent = Y (X follows Y). - Positional R (
UNDER,ABOVE,LEFT_OF,RIGHT_OF,NEXT_TO) → X’s parent = Y’s parent (X becomes Y’s sibling), plus a positional edge X→Y.
- Parent-creating R (
- Mirror Y per edge. With Både: current →
CURRENT_IN (Y's current parent)+CURRENT_R Y; normal →NORMAL_IN (Y's normal parent)+NORMAL_R Y. (For parent-creating R the parent is Y.) - Lifecycle. When Y moves, inbound positional edges pointing at Y are deleted (X stays in its container; positional ≠ follow, per the Movement Test). When X is re-placed, its positional edges on the changed axis are replaced; moving X to a different container clears X’s positional edges (the referenced siblings aren’t there anymore). Done durably in the API resolver and optimistically in iOS (local-first, ADR-0003).
- ATTACHED_TO = follows (parent-creating). Resolves the doc contradiction
(
spatial-relationships.mdsays non-parent;relationship-decision-guide.mdsays parent — physics: keys follow keyring) in favour of parent. New ADR + fixspatial-relationships.md. - Display = the object’s detail card (a section listing current + normal positional relations), local-first from the graph + fetched in the details query. Navigator-inline display deferred.
- Scope: the existing predicates only. Depth axis (
framför/bakom), diagonals, and navigator-inline positional display are out (follow-ups).
Design
A. Plings-API (resolver) — do first
The predicate catalogue already holds all CURRENT_*/NORMAL_* positional keys; no new predicate type.
- Positional placement sets the parent.
createOrMoveSpatialRelationship(objectId=X, targetId=Y, relationship=R, locationType)for a positional R must additionally set X’s container to Y’s container on the same axis:locationType=current→X CURRENT_IN (Y's current parent);locationType=normal→X NORMAL_IN (Y's normal parent). Then upsert the positional edgeX R Y(delete existing same-axis edge first, as today). If Y has no parent on that axis, X’s parent on that axis is cleared to the shared root (World, ADR-0045) — X is a sibling of a root-level Y. ATTACHED_TOis parent-creating. Route it through themoveObject(parent) path, not the positional branch.- Cascade cleanup in
moveObject(objectId=Y, …)— after relocating Y, delete inbound positional edges that point at Y ((:other)-[:CURRENT_*|NORMAL_* positional]->(Y)), on the axis that moved. This is the documented gap (spatial-relationships.md §302-327cleans only Y’s outbound parent edge). - Atomicity: all of the above in one resolver transaction (no invalid intermediate graph state), consistent with the existing “clients express intent, resolver mutates safely” contract.
- Tests:
pytest— positional create sets the sibling parent (current+normal, mirrored); Y-move cascade-deletes inbound positional refs; ATTACHED_TO goes through the parent path; root-level Y.
B. Plings-iOS (model + flow + display) — after the API lands
SpatialRelationshipenum gains theNORMAL_*positional cases (normalOn,normalLeftOf,normalRightOf,normalAbove,normalUnder,normalNextTo,normalAttachedTo) alongside the existingCURRENT_*+normalIn. A purecurrent↔normalmapping (R.normalCounterpart) + axis/ kind classifiers (isParentCreating,isPositional) — all unit-tested in PlingsKit.ObjectGraphextension (the architectural core). Today the graph holds only the parent edge per object. Add positional edges: per object, the set of(predicate, targetId)for current and for normal. New pure ops:applyingPositional(x, rel, target, axis),clearingPositional(x, axis),removingInboundPositional(toTarget:)(cascade), and reads (positionalRelations(of:)). Unit-tested.ObjectStoreplacement. Replace the relation-blind move/setNormal with aplace(x, relativeTo:Y, relationship:R, edges:)that, per the locked semantics, sets X’s parent (Y for parent-creating; Y’s same-axis parent for positional — looked up from the graph) and records the positional edge, optimistically (graph + cache) then enqueues. Lifecycle: on any move, run the optimistic cascade (removingInboundPositional) + clear X’s now-invalid positional edges. The enqueued job carries enough for the resolver to do the durable equivalent.MoveFlow.commitmaps the picked relation per edge: Både → current R + itsnormalCounterpart; Ny hemplats → normal only; Bara nuvarande → current only.ATTACHED_TO/IN/ON→ parent = Y.updatesCurrentParentOptimisticallyupdated: parent-creating set now includes.currentOnand.currentAttachedTo; positional relations set the parent to Y’s parent (handled byplace, not this flag) — refactor so the flag/abstraction reflects the three kinds (parent=Y / parent=Y’s parent / —).- Display (detail card). Add positional relations to the iOS details query; render a section on
ObjectScreen(current + normal positional relations, e.g. “Under Bord”, “Höger om X”), derived local-first from the graph (so it’s correct offline/instantly after placement) and reconciled from the server fetch. Mirror the existing “Relationer” (functional) section’s style. - Tests: PlingsKit graph/enum units; app build; device checklist (below).
Testing (device, load-bearing)
- ★ X at Köksbordet → Flytta → UNDER Bord → Både → X lands in Vardagsrummet (Bord’s container) for current and home; “Under Bord” shown on X’s card. (The reported bug.)
- ★ Höger om Y → X becomes Y’s sibling in Y’s container; “Höger om Y” shown.
- ★ FASTSATT PÅ Y → X’s parent = Y (follows); moving Y carries X.
- ★ Move Y elsewhere → X stays in the container, “Under/Höger om Y” relation disappears (cascade).
- Move X to a new container → its positional relations clear.
- Offline + relaunch: placement + cascade correct from cache before sync.
- Y away (current ≠ normal) → X’s current mirrors Y’s current, X’s normal mirrors Y’s normal.
Out of scope (follow-ups → ROADMAP Backlog)
- Depth axis (
framför/bakom); diagonals; navigator-inline positional display. - Re-display of positional relations in bulk (Samla) review.
Docs & decisions (ship with the code)
- New ADR (next free number): positional placement = sibling inheritance + lifecycle cascade;
ATTACHED_TO is parent-creating (supersedes the
spatial-relationships.md“non-parent” claim). Refines ADR-0005; relates ADR-0044, ADR-0045. - Fix
spatial-relationships.md: themoveObjectresolver contract (§302-327) gains inbound- positional cascade; the attachment axis “creates parent: false” → true; alignrelationship- decision-guide.md. - Update
Plings-iOS/docs/flows/flytta.md(positional now places into the sibling’s container + shows the relation) and UC-303; cross-repo flow index. - ROADMAP: the parked “Display of non-IN spatial relations + lifecycle” and “Positional-references- a-sibling” items resolve here (move to Done on merge); depth/diagonals/navigator-inline stay Backlog.
Plan decomposition
- Plan 1 — Plings-API (
Plings-API/docs/plans/): resolver sibling-placement + cascade + ATTACHED_TO parent-routing + pytest. Merges first (backend before client). - Plan 2 — Plings-iOS (
Plings-iOS/docs/plans/): enum,ObjectGraphpositional edges,ObjectStore.place- optimistic lifecycle,
MoveFlow.commit, detail-card display, tests. Merges after the API.
- optimistic lifecycle,