Neo4j Core Schema (Graph & Postgres Reference)
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_OFa class, a component beingPART_OFanother, or an object beingINa 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
:ScanEventnode is written to Neo4j on every browser-path scan processed by the Gateway. The node is linked via(:ScanEvent)-[:SCANNED]->(:PlingsIdentifier). The scan eventidis returned to the Gateway and forwarded to the browser landing page as thesidURL 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
resolveIdentifieris called from the native app (authenticated JWT,scanMetadata.geoLocationpresent), it now also persistslatitude,longitude,scanning_user, andowner_at_time_of_scanon the:ScanEventnode vialog_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_INis the live current-location predicate: themoveObjectmutation writesCURRENT_IN, and all “where is it” / hierarchy / children reads followCURRENT_*(including the legacy bare:INreads, flipped in the same work).NORMAL_*means only the deliberate “home” / expected location: it is written bycreateObject(the dual write at creation), bycreateNormalSpatialRelationship, and (future) by Document-mode bulk capture, and it is read only via the explicitnormalPlacementdetail field — never by the default location reads. So a move changes where the object is (CURRENT_*) without touching its home (NORMAL_*); aCURRENT ≠ NORMALdivergence means the object is misplaced relative to its home.Before WS3-A the system was inverted:
moveObjectwroteNORMAL_*and the location reads followedNORMAL_IN|IN, whileCURRENT_INwas 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) seededCURRENT_IN := NORMAL_INas 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 readsspatialParent(now backed byCURRENT_*) 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
- profiles
id: uuid, not nullablefull_name: text, nullableavatar_url: text, nullableupdated_at: timestamp with time zone, nullable, defaultnow()
- organizations
id: uuid, not nullable, defaultgen_random_uuid()name: text, not nullablecreated_at: timestamp with time zone, nullable, defaultnow()created_by: uuid, nullabletype: text, not nullable, default'Individual'::textdescription: text, nullable
- organization_members
organization_id: uuid, not nullableuser_id: uuid, not nullablerole: text, not nullablejoined_at: timestamp with time zone, nullable, defaultnow()
- object_instances
id: uuid, not nullableneo4j_id: text, nullablename: text, not nullabledescription: text, nullableowner_organization_id: uuid, not nullablecreated_by: uuid, not nullablecreated_at: timestamp with time zone, nullable, defaultnow()updated_at: timestamp with time zone, nullable, defaultnow()status: text, nullablemain_image_url: text, nullablelast_scanned_at: timestamp with time zone, nullablelast_scanned_by: uuid, nullable
- object_images
id: uuid, not nullableobject_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