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) then Plings-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.commit for a positional relation calls move(into: Y, relationship: .currentUnder) — but ObjectStore.move only 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. And setNormalLocation(into: Y) always writes NORMAL_IN Y regardless 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)

  1. 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.
  2. 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.)
  3. 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).
  4. ATTACHED_TO = follows (parent-creating). Resolves the doc contradiction (spatial-relationships.md says non-parent; relationship-decision-guide.md says parent — physics: keys follow keyring) in favour of parent. New ADR + fix spatial-relationships.md.
  5. 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.
  6. 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.

  1. 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=currentX CURRENT_IN (Y's current parent); locationType=normalX NORMAL_IN (Y's normal parent). Then upsert the positional edge X 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.
  2. ATTACHED_TO is parent-creating. Route it through the moveObject (parent) path, not the positional branch.
  3. 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-327 cleans only Y’s outbound parent edge).
  4. 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.
  5. 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

  1. SpatialRelationship enum gains the NORMAL_* positional cases (normalOn, normalLeftOf, normalRightOf, normalAbove, normalUnder, normalNextTo, normalAttachedTo) alongside the existing CURRENT_* + normalIn. A pure current↔normal mapping (R.normalCounterpart) + axis/ kind classifiers (isParentCreating, isPositional) — all unit-tested in PlingsKit.
  2. ObjectGraph extension (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.
  3. ObjectStore placement. Replace the relation-blind move/setNormal with a place(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.
  4. MoveFlow.commit maps the picked relation per edge: Både → current R + its normalCounterpart; Ny hemplats → normal only; Bara nuvarande → current only. ATTACHED_TO/IN/ON → parent = Y.
  5. updatesCurrentParentOptimistically updated: parent-creating set now includes .currentOn and .currentAttachedTo; positional relations set the parent to Y’s parent (handled by place, not this flag) — refactor so the flag/abstraction reflects the three kinds (parent=Y / parent=Y’s parent / —).
  6. 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.
  7. Tests: PlingsKit graph/enum units; app build; device checklist (below).

Testing (device, load-bearing)

  1. ★ X at Köksbordet → Flytta → UNDER BordBåde → X lands in Vardagsrummet (Bord’s container) for current and home; “Under Bord” shown on X’s card. (The reported bug.)
  2. Höger om Y → X becomes Y’s sibling in Y’s container; “Höger om Y” shown.
  3. FASTSATT PÅ Y → X’s parent = Y (follows); moving Y carries X.
  4. ★ Move Y elsewhere → X stays in the container, “Under/Höger om Y” relation disappears (cascade).
  5. Move X to a new container → its positional relations clear.
  6. Offline + relaunch: placement + cascade correct from cache before sync.
  7. 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: the moveObject resolver contract (§302-327) gains inbound- positional cascade; the attachment axis “creates parent: false” → true; align relationship- 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, ObjectGraph positional edges, ObjectStore.place
    • optimistic lifecycle, MoveFlow.commit, detail-card display, tests. Merges after the API.