Neo4j Core Schema (Graph & Postgres Reference)

Created: Tue 29 Jul 2025 06:49:39 CEST
Updated: Sun 14 Jun 2026 10:17:12 CEST - Scan landing Phase 1: :ScanEvent persistence is now implemented; documented written fields, Phase 2 reserved fields, and (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier) relationship
Updated: Mon 15 Jun 2026 17:21:39 CEST - Native scan logging: geo/user fields now IMPLEMENTED for native app path; browser/Gateway path still records timestamp/transport/ip_hash/object_id only
Updated: Thu 18 Jun 2026 11:37:32 CEST - WS3-A current/normal realignment: clarified that CURRENT_* is the live “where it is now” predicate (the moveObject write + all “where is it”/hierarchy/children reads) and NORMAL_* is the deliberate “home”/expected location (read only via normalPlacement); noted the 2026-06-18 CURRENT_IN := NORMAL_IN alignment migration
Document Version: 1.3 - current/normal realignment (WS3-A): CURRENT_* drives “where is it”, NORMAL_* is the home
Security Classification: Internal Technical Documentation
Target Audience: Backend Developers, Database Administrators
Author: Paul Wisén

This document defines the core data schema for the Universal Object Graph System, designed for use with Neo4j. The schema is built around a property graph model of nodes and relationships.

Graph Model Overview

The system models the physical world using a graph containing several key node labels and relationship types.

  • Nodes: Represent entities like object blueprints (ObjectClass), physical items (ObjectInstance), and identifiers (PlingsIdentifier).
  • Relationships: Represent how these entities are connected, such as an instance being an INSTANCE_OF a class, a component being PART_OF another, or an object being IN a container.

Node Reference

1. :ObjectClass Node

Represents a template or definition of an object.

Property Type Description Example
name String Required. The unique name of the class. "Mountain Bike"
description String A detailed description of the object class. "A bicycle designed for off-road cycling."
version Integer The version number for the class definition. 2
owner String The entity that defined or owns this class. "ACME Inc."
visibility String Access control for the class (“public”, “private”). "public"
capabilities List<String> A list of potential uses for this object type. ["off-road", "sport"]
anchor_key String The public cryptographic anchor key from the HD Wallet. "xpub..."
path_template String The schema for interpreting the HD Wallet derivation path. "model/factory/instance"

2. :ObjectInstance Node

Represents a specific, physical object in the real world.

Property Type Description Example
statuses List<String> The current state(s) of the physical object. ["for_sale", "broken"]
owner String The user or organization that owns this instance. "user:12345"

3. :PlingsIdentifier Node

Represents a physical or digital identifier (e.g., QR code, NFC tag).

Property Type Description Example
key String Required. The unique identifier value. "plg:44-..."
identifier_type String The medium of the identifier. "QR"
status String The state of the identifier itself. "active"
is_one_time_use Boolean Flag for OTP-style verification identifiers. true

4. :ScanEvent Node

Represents a single scan event of a PlingsIdentifier, creating a historical audit trail.

Phase 1 status: persistence IMPLEMENTED (browser path). As of scan landing Phase 1, a :ScanEvent node is written to Neo4j on every browser-path scan processed by the Gateway. The node is linked via (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier). The scan event id is returned to the Gateway and forwarded to the browser landing page as the sid URL parameter, so the page can enrich the event with coarse geolocation in Phase 2.

Native app path geo/user persistence: IMPLEMENTED (native scan logging feature, Jun 2026). When resolveIdentifier is called from the native app (authenticated JWT, scanMetadata.geoLocation present), it now also persists latitude, longitude, scanning_user, and owner_at_time_of_scan on the :ScanEvent node via log_scan_event. The browser/Gateway path still records only the Phase 1 fields (timestamp/identifier_type/ip_hash/object_id); geolocation from the browser is Phase 2 future work.

Fields written on every scan (both paths):

Property Type Description Example
id String Required. Unique scan event identifier. "scan_789xyz"
timestamp DateTime Required. The exact time of the scan. 2024-07-30T10:00:00Z
identifier_type String The transport/medium of the scan. "QR"
ip_hash String SHA-256 hash of the scanner’s IP address (not stored in plaintext). "a3f5c2..."
object_id String UUID of the resolved object, if any. null for unknown tags. "obj-550e8400-..."

Additional fields written on the native app path (IMPLEMENTED):

Property Type Description Example
latitude Float GPS latitude from scanMetadata.geoLocation. 59.3293
longitude Float GPS longitude from scanMetadata.geoLocation. 18.0686
scanning_user String Authenticated user who performed the scan (format: "user:<userId>"). "user:550e8400-..."
owner_at_time_of_scan String Owning organisation at the time of the scan (format: "org:<orgId>"). "org:a1b2c3-..."

Fields deferred to Phase 2 (browser path only, not yet populated):

Property Type Description
latitude, longitude Float Browser will request geolocation and enrich the event via sid callback.

Relationship Reference

Core Relationships

Start Node Type End Node Description
:ObjectInstance INSTANCE_OF :ObjectClass Links a physical item to its blueprint.
:ObjectClass SUBCLASS_OF :ObjectClass Creates the class hierarchy (e.g., Mountain Bike is a subclass of Bicycle).

Class granularity (ADR-0070, 2026-07-06). A minted :ObjectClass (a “mall” — e.g. created by promotion) is a specific article: brand + product + variant/size (“Extra White Bubblemint 60 st”). Two packagings or variants are two classes — the class payload (images, description, future GTIN/artikelnummer) identifies an article, not a category. Categories (“tuggummi”) are NOT flattened into classes: they are future ancestor :ObjectClass nodes reached via SUBCLASS_OF, so “all my chewing gum regardless of packaging” stays one traversal: (:ObjectInstance)-[:INSTANCE_OF]->(:ObjectClass)-[:SUBCLASS_OF*0..]->(:ObjectClass {name:'tuggummi'}). The category layer is deferred, additive work (new ancestor nodes + edges; no migration of article classes). | :ObjectInstance | PART_OF | :ObjectInstance | Defines the physical composition of complex objects (a wheel is part of a bike). | | :PlingsIdentifier| IDENTIFIES | :ObjectInstance | Links an identifier to the specific object it represents. | | :ScanEvent | SCANNED | :PlingsIdentifier| Records that a specific identifier was scanned in an event. Written on every browser-path scan as of Phase 1.|

Spatial Relationships

These relationship types model the physical world. The authoritative list of spatial predicates is maintained in the Supabase spatial_predicates table, and these predicates become Neo4j relationship types.

Dual Spatial Relationship System (from spatial_predicates table)

The spatial system uses dual predicates to separately track current and expected locations. The authoritative list is maintained in the Supabase spatial_predicates table.

CURRENT_* is “where it is now”; NORMAL_* is the home. This is the load-bearing distinction, realigned in WS3-A (2026-06-18). CURRENT_IN is the live current-location predicate: the moveObject mutation writes CURRENT_IN, and all “where is it” / hierarchy / children reads follow CURRENT_* (including the legacy bare :IN reads, flipped in the same work). NORMAL_* means only the deliberate “home” / expected location: it is written by createObject (the dual write at creation), by createNormalSpatialRelationship, and (future) by Document-mode bulk capture, and it is read only via the explicit normalPlacement detail field — never by the default location reads. So a move changes where the object is (CURRENT_*) without touching its home (NORMAL_*); a CURRENT ≠ NORMAL divergence means the object is misplaced relative to its home.

Before WS3-A the system was inverted: moveObject wrote NORMAL_* and the location reads followed NORMAL_IN|IN, while CURRENT_IN was only a stale creation snapshot. A one-time Neo4j migration (migrations/2026-06-18-align-current-to-normal.cypher, in Plings-API; run 2026-06-18) seeded CURRENT_IN := NORMAL_IN as the baseline for objects that had a home but no current edge, so every object kept its existing location across the flip. This is an API-only change; the iOS app reads spatialParent (now backed by CURRENT_*) transparently.

Current Location Relationships (where objects are now — the default location reads + the moveObject write):

Start Node Type End Node Parent Relationship Description
:ObjectInstance CURRENT_IN :ObjectInstance ✅ Yes Subject is currently inside target
:ObjectInstance CURRENT_ON :ObjectInstance ✅ Yes Subject is currently on surface of target
:ObjectInstance CURRENT_LEFT_OF :ObjectInstance ❌ No Subject is currently to the left of target
:ObjectInstance CURRENT_RIGHT_OF :ObjectInstance ❌ No Subject is currently to the right of target
:ObjectInstance CURRENT_ABOVE :ObjectInstance ❌ No Subject is currently above target
:ObjectInstance CURRENT_UNDER :ObjectInstance ❌ No Subject is currently under target
:ObjectInstance CURRENT_NEXT_TO :ObjectInstance ❌ No Subject is currently next to target
:ObjectInstance CURRENT_ATTACHED_TO :ObjectInstance ❌ No Subject is currently attached to target

Normal/Expected Location Relationships (the deliberate “home” — read only via normalPlacement):

Start Node Type End Node Parent Relationship Description
:ObjectInstance NORMAL_IN :ObjectInstance ✅ Yes Subject should normally be inside target
:ObjectInstance NORMAL_ON :ObjectInstance ✅ Yes Subject should normally be on surface of target
:ObjectInstance NORMAL_LEFT_OF :ObjectInstance ❌ No Subject should normally be to the left of target
:ObjectInstance NORMAL_RIGHT_OF :ObjectInstance ❌ No Subject should normally be to the right of target
:ObjectInstance NORMAL_ABOVE :ObjectInstance ❌ No Subject should normally be above target
:ObjectInstance NORMAL_UNDER :ObjectInstance ❌ No Subject should normally be under target
:ObjectInstance NORMAL_NEXT_TO :ObjectInstance ❌ No Subject should normally be next to target
:ObjectInstance NORMAL_ATTACHED_TO :ObjectInstance ❌ No Subject should normally be attached to target

Misplacement Detection Examples:

// Box is on wrong pallet
(:Box)-[:CURRENT_ON]->(:Pallet_A)
(:Box)-[:NORMAL_ON]->(:Pallet_B)

// Keys are contained but should be hanging
(:Keys)-[:CURRENT_IN]->(:Drawer)
(:Keys)-[:NORMAL_ON]->(:KeyHook)

// Monitor on wrong side of computer
(:Monitor)-[:CURRENT_LEFT_OF]->(:Computer)
(:Monitor)-[:NORMAL_RIGHT_OF]->(:Computer)

Note: Legacy unprefixed predicates (IN, ON, LEFT_OF, etc.) are deprecated in favor of the dual system. Where they still exist, the location reads treat them as current (the bare :IN reads were flipped to align with CURRENT_* in WS3-A, 2026-06-18).

Functional Relationships

These relationship types describe how objects interact rather than where they are located. They come from the authoritative catalogue in functional_relationships_system.md and are stored as uppercase relationship types in Neo4j (directional pairs are stored both ways; symmetric predicates are stored once in canonical order).

Category Type (Predicate) Inverse Symmetric? Description Example
Physical Composition PART_OF HAS_PART No Component is part of a larger assembly or set (:Wheel)-[:PART_OF]->(:Bike)
Compatibility IS_FOR ACCEPTS No Subject is designed for target (:Remote)-[:IS_FOR]->(:TV)
  ACCEPTS IS_FOR No Target accepts subject (:TV)-[:ACCEPTS]->(:Remote)
  WORKS_WITH Yes Designed to function together (:Scanner)-[:WORKS_WITH]-(:Computer)
Control CONTROLS CONTROLLED_BY No Subject operates target (:Remote)-[:CONTROLS]->(:TV)
  OPERATES OPERATED_BY No Activates / runs target (:Switch)-[:OPERATES]->(:Light)
Power POWERS POWERED_BY No Subject provides electrical power (:Battery)-[:POWERS]->(:Flashlight)
  CHARGES CHARGED_BY No Replenishes energy of target (:Charger)-[:CHARGES]->(:Phone)
Access UNLOCKS UNLOCKED_BY No Grants access to target (:Key)-[:UNLOCKS]->(:Door)
Pairing PAIRS_WITH Yes Two items form an intended pair (:LeftGlove)-[:PAIRS_WITH]-(:RightGlove)
Replacement REPLACES REPLACED_BY No Subject serves as substitute (:BackupBattery)-[:REPLACES]->(:MainBattery)

Note: Functional predicates can also be created between ObjectClass nodes to act as templates. The API resolver automatically merges explicit instance-level edges with class-level edges (marked inherited = true) so that every object gains the functional context of its class hierarchy without data duplication.

The PART_OF relationship is a special case used for both physical assembly (a wheel is part of a bike) and for defining “Set Objects” where a conceptual item contains other items (a “Glove Pair Set” has a left and right glove).

Example Cypher

// Find everything that powers a given laptop (instance-level + class templates)
MATCH (l:ObjectInstance {id:$laptop})<-[:POWERS|POWERED_BY|CHARGES|CHARGED_BY]-(src)
RETURN DISTINCT src.id, type(relationships(src,l)) AS predicate;

Supabase (Postgres) Schema

Tables

  1. profiles
    • id: uuid, not nullable
    • full_name: text, nullable
    • avatar_url: text, nullable
    • updated_at: timestamp with time zone, nullable, default now()
  2. organizations
    • id: uuid, not nullable, default gen_random_uuid()
    • name: text, not nullable
    • created_at: timestamp with time zone, nullable, default now()
    • created_by: uuid, nullable
    • type: text, not nullable, default 'Individual'::text
    • description: text, nullable
  3. organization_members
    • organization_id: uuid, not nullable
    • user_id: uuid, not nullable
    • role: text, not nullable
    • joined_at: timestamp with time zone, nullable, default now()
  4. object_instances
    • id: uuid, not nullable
    • neo4j_id: text, nullable
    • name: text, not nullable
    • description: text, nullable
    • owner_organization_id: uuid, not nullable
    • created_by: uuid, not nullable
    • created_at: timestamp with time zone, nullable, default now()
    • updated_at: timestamp with time zone, nullable, default now()
    • status: text, nullable
    • main_image_url: text, nullable
    • last_scanned_at: timestamp with time zone, nullable
    • last_scanned_by: uuid, nullable
  5. object_images
    • id: uuid, not nullable
    • object_instance_id: uuid, not_nullable

This file was introduced to eliminate ambiguous names like schema.md now that the project uses both Neo4j and Supabase databases.

Last Updated: Thu Jul 10 09:18:47 CEST 2025 - Standardized PlingsIdentifier relationship from POINTS_TO to IDENTIFIES for better semantic clarity