resolveIdentifier GraphQL Mutation

Created: Tue 29 Jul 2025 08:58:00 CEST
Updated: Sun 14 Jun 2026 10:17:12 CEST - Scan landing Phase 1: routing convergence to /o/:oid for all known objects; new sid redirect parameter; new publicObject and publicClass unauthenticated queries documented
Updated: Mon 15 Jun 2026 17:21:39 CEST - Native scan logging: geo/user now written to :ScanEvent on native path; object_instances geo columns; new ObjectInstance.lastSeenInfo field
Updated: Mon 15 Jun 2026 - type field clarified as transport code (q/n/r/b; legacy plgs/mfr); added path: String! (HD wallet path) to ResolveIdentifierInput; error example error code corrected to canonical scan-error convention
Updated: Wed 17 Jun 2026 13:45:00 CEST - scanHistory query documented: ownership-gated guarded tag-walk over :ScanEvent nodes; backs iOS compact “Senast sedd” history
Document Version: 1.4 - scanHistory query
Security Classification: Internal Technical Documentation
Target Audience: Backend Developers, API Developers
Author: Paul Wisén

Overview

The resolveIdentifier mutation is a critical endpoint that the Gateway service (s.plings.io) calls to verify and resolve Plings identifiers into routing decisions. This endpoint performs cryptographic verification, retrieves object context, and returns routing instructions.

GraphQL Schema

input ResolveIdentifierInput {
  type: String!         # Transport code: 'q' (QR), 'n' (NFC), 'r' (RFID), 'b' (Bluetooth); legacy: 'plgs', 'mfr'
  identifier: String!   # Full identifier (e.g., "plgs_1_12345")
  path: String!         # HD wallet path (e.g., "2.1.3.2.5.100.5000")
  classPointer: String  # Optional class pointer for verification
  scanMetadata: ScanMetadataInput
}

input ScanMetadataInput {
  timestamp: DateTime!
  ipAddress: String
  userAgent: String
  referer: String
  geoLocation: GeoLocationInput
}

input GeoLocationInput {
  latitude: Float
  longitude: Float
  accuracy: Float
  consent: Boolean!
}

type ResolveIdentifierResponse {
  success: Boolean!
  routing: RoutingDecision!
  object: ObjectContext
  scanEvent: ScanEvent
  error: ErrorResponse
}

type RoutingDecision {
  action: RoutingAction!      # REDIRECT, SHOW_INFO, ERROR
  destination: String         # URL to redirect to
  parameters: [Parameter!]    # Additional URL parameters
  cacheTTL: Int              # Cache duration in seconds
}

enum RoutingAction {
  REDIRECT      # Redirect to destination URL
  SHOW_INFO     # Show information page
  ERROR         # Show error page
}

type ObjectContext {
  objectId: String!
  status: ObjectStatus!
  owner: OwnerInfo
  manufacturer: ManufacturerInfo
  class: ObjectClassInfo
  location: LocationInfo
}

enum ObjectStatus {
  ACTIVE
  FOR_SALE
  FOR_RENT
  LOST
  ARCHIVED
  UNKNOWN
}

type Parameter {
  key: String!
  value: String!
}

Request Flow

Gateway (s.plings.io) → Backend API (api.plings.io)
    ↓
1. Extract parameters from QR scan
2. Call resolveIdentifier mutation
3. Backend performs:
   - Manufacturer validation
   - Cryptographic verification
   - Object lookup
   - Context retrieval
   - Routing decision
4. Return routing instructions
5. Gateway redirects user

Implementation Requirements

1. Manufacturer Verification

2. Object Resolution

3. Routing Logic

Based on object context, determine destination.

v1 (Phase 1 — current): All known objects (any status with a resolved object_id) route to the single status-aware landing page at plings.io/o/{oid}. The page renders status-specific content (notice, CTA) itself. Unknown/unregistered tags route to /welcome.

Condition Destination (v1) Core Parameters
object_id present (any status) plings.io/o/{oid} oid, sid, ikey, path, src
No object_id (unknown/unregistered tag) plings.io/welcome ikey, path, cptr (if present), sid, src
Malformed / invalid identifier plings.io/scan-error?reason=invalid details
Unverifiable identifier plings.io/scan-error?reason=unverified

Future work: Dedicated subdomains (market.plings.io, rent.plings.io, lend.plings.io) are planned for commerce features. Migration requires only a routing rule change in the Gateway — not a reprint of physical identifiers.

sid — Scan Event ID

resolveIdentifier now returns a sid (scan event ID) in the routing parameters when a scan event is successfully persisted. The Gateway appends sid to the redirect URL so the browser landing page can call back to enrich the event with coarse geolocation (Phase 2).

Example redirect URL for a known object:

https://plings.io/o/obj-123?oid=obj-123&sid=scan_789xyz&ikey=4kyQCd5...&path=4.2.3.3.6&src=scan

4. Scan Event Logging

Phase 1 status: IMPLEMENTED (browser path). Every browser-path scan is logged to Neo4j as a :ScanEvent node linked via (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier).

Native app path geo/user logging: IMPLEMENTED (native scan logging feature, Jun 2026). When resolveIdentifier is called from the native app (authenticated JWT with scanMetadata.geoLocation present), the resolver (log_scan_event) additionally writes geolocation and user context onto the :ScanEvent node, and denormalises the latest scan position onto the Postgres object_instances row.

Fields written on every scan (both paths):

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

Postgres denormalisation (native path): The object_instances row is updated with:

Fields deferred to Phase 2 (browser path):

5. Performance Requirements

Example Request

mutation ResolveIdentifier($input: ResolveIdentifierInput!) {
  resolveIdentifier(input: $input) {
    success
    routing {
      action
      destination
      parameters {
        key
        value
      }
      cacheTTL
    }
    object {
      objectId
      status
      owner {
        name
        verified
      }
      class {
        name
        category
      }
    }
    scanEvent {
      id
      timestamp
    }
    error {
      code
      message
    }
  }
}

Variables:

{
  "input": {
    "type": "q",
    "identifier": "plgs_1_abc123xyz",
    "path": "2.1.3.2.5.100.5000",
    "classPointer": "4K7mX9abDcE",
    "scanMetadata": {
      "timestamp": "2025-07-29T12:00:00Z",
      "ipAddress": "192.168.1.1",
      "userAgent": "Mozilla/5.0...",
      "geoLocation": {
        "latitude": 59.3293,
        "longitude": 18.0686,
        "accuracy": 10.5,
        "consent": true
      }
    }
  }
}

Response Examples

Success - Known Object for Sale

{
  "data": {
    "resolveIdentifier": {
      "success": true,
      "routing": {
        "action": "REDIRECT",
        "destination": "https://plings.io/o/obj_123abc",
        "parameters": [
          { "key": "oid", "value": "obj_123abc" },
          { "key": "sid", "value": "scan_789xyz" },
          { "key": "src", "value": "scan" },
          { "key": "ikey", "value": "plgs_1_abc123xyz" }
        ],
        "cacheTTL": 300
      },
      "object": {
        "objectId": "obj_123abc",
        "status": "FOR_SALE",
        "owner": {
          "name": "John's Bike Shop",
          "verified": true
        },
        "class": {
          "name": "Mountain Bike",
          "category": "Sports Equipment"
        }
      },
      "scanEvent": {
        "id": "scan_789xyz",
        "timestamp": "2025-07-29T12:00:00Z"
      }
    }
  }
}

Success - Unknown Identifier

{
  "data": {
    "resolveIdentifier": {
      "success": true,
      "routing": {
        "action": "REDIRECT",
        "destination": "https://plings.io/welcome",
        "parameters": [
          { "key": "ikey", "value": "plgs_999_unknown" },
          { "key": "sid", "value": "scan_456def" },
          { "key": "src", "value": "scan" }
        ],
        "cacheTTL": 3600
      },
      "object": null,
      "scanEvent": {
        "id": "scan_456def",
        "timestamp": "2025-07-29T12:00:01Z"
      }
    }
  }
}

Error - Invalid Identifier

{
  "data": {
    "resolveIdentifier": {
      "success": false,
      "routing": {
        "action": "ERROR",
        "destination": "https://plings.io/scan-error",
        "parameters": [
          { "key": "reason", "value": "invalid" },
          { "key": "details", "value": "bad_instance_key" }
        ],
        "cacheTTL": 86400
      },
      "error": {
        "code": "INVALID_IDENTIFIER_FORMAT",
        "message": "Error: INVALID_IDENTIFIER_FORMAT"
      }
    }
  }
}

Error destination convention. All ERROR routings point to the single canonical scan-error route https://www.plings.io/scan-error?reason=<token>, where <token> is invalid (bad/unauthorized/unknown identifier) or system (internal error). This is a query-param route — Plings-Web exposes one /scan-error React route, so a sub-path like /scan-error/invalid would render NotFound. The form matches the Gateway firewall’s own error redirect (?reason=invalid&details=<token>). Fixed diagnostic tokens such as details ride along as parameters (the caller merges them onto the destination’s query string); raw user input is never echoed into the destination.

Security Considerations

  1. Rate Limiting: Implement per-IP rate limits (100 req/min)
  2. Input Validation: Strict validation of all parameters
  3. Authentication: Gateway service must authenticate with API key
  4. Encryption: All communication over HTTPS
  5. Privacy: Hash IP addresses, require consent for geolocation

Implementation Status

Phase Scope Status
Scan landing Phase 1 Identifier validation, routing convergence to /o/:oid, scan event persistence, publicObject query, publicClass stub, sid parameter DONE
Native scan logging Native-path geo/user fields on :ScanEvent, object_instances geo columns, ObjectInstance.lastSeenInfo DONE
scanHistory query Ownership-gated scan-history query (scanHistory(objectId, limit)); guarded tag-walk over :ScanEvent; creation-window guard; iOS “Senast sedd” history screen DONE
Scan landing Phase 2 Browser geolocation enrichment (landing page posts GPS back via sid), publicClass full implementation when class system is built Planned
Advanced caching Cache headers for routing decisions, per-IP rate limiting tuning Planned

Unauthenticated Public Queries (Phase 1)

Two new GraphQL queries were added in scan landing Phase 1 to serve the browser landing pages. Both are unauthenticated — they require no session or API key and can be called by any browser. Sensitive data is excluded by design; these queries return only a curated public subset.

publicObject(id: ID!): PublicObject

Returns a curated public view of a known object. Called by plings.io/o/:oid to render the status-aware landing page.

query PublicObject($id: ID!) {
  publicObject(id: $id) {
    id
    name
    mainImageUrl
    status
    statuses
    objectClass {
      name
      category
    }
    owner {
      name
      verified
    }
    lastSeen {
      area
      coarseLatitude
      coarseLongitude
      at
    }
  }
}

Fields returned:

Field Type Description
id ID Object UUID
name String Display name
mainImageUrl String URL of the primary object image
status String Primary status (e.g. NORMAL, LOST, FOR_SALE)
statuses [String] All active statuses (an object can carry multiple)
objectClass { name, category } The object’s class blueprint (name and category)
owner { name, verified } Owning organization name and verification flag
lastSeen { area, coarseLatitude, coarseLongitude, at } Structured last-seen object. In Phase 1 only at (timestamp) is populated; coarse coordinates (coarseLatitude, coarseLongitude) arrive in Phase 2.

Authentication: None required. This query is intentionally public.

Excluded from public view: owner contact details, GPS history, internal IDs, price/valuation, and any organization-private fields.


publicClass(classPointer: String!): PublicClass

Returns public information about an object class identified by its cryptographic class pointer (cp / cptr parameter). Called by plings.io/welcome when a class pointer is present in the URL, enabling the unknown-tag onboarding page to show class-specific information (e.g. “This is a Trek Marlin 7 mountain bike — create it in the app”).

query PublicClass($classPointer: String!) {
  publicClass(classPointer: $classPointer) {
    id
    name
    description
    imageUrl
  }
}

Phase 1 status: STUB — always returns null. The class system is not yet built. The query exists and the welcome page is wired to call it, but it returns null for all class pointers. The welcome page gracefully falls back to generic onboarding when the response is null. Full implementation is deferred to the phase when the class/manufacturer system is built.

Authentication: None required. This query is intentionally public.


ObjectInstance.lastSeenInfo Field (Native “Senast sedd” View)

The native scan logging feature adds a new non-breaking GraphQL field to ObjectInstance:

type LastSeen {
  at: DateTime
  latitude: Float
  longitude: Float
  scannedBy: ID        # user UUID
  scannedByName: String  # resolved from profiles.full_name
}

type ObjectInstance {
  # ... existing fields unchanged ...
  lastSeen: String       # pre-existing scalar, UNCHANGED
  lastSeenInfo: LastSeen # NEW — populated from object_instances geo columns + profiles
}

lastSeenInfo is resolved from the object_instances Postgres row (last_scanned_at, last_scanned_by, last_scanned_latitude, last_scanned_longitude) plus a join to profiles.full_name for scannedByName. It is populated only for objects that have been scanned by the native app after the native scan logging feature was deployed; older or browser-only objects will return null for the coordinate fields.

The pre-existing scalar lastSeen: String is unchanged — existing clients are not affected.

lastSeenInfo drives the iOS “Senast sedd” view that shows the owner when and where their object was last seen.


scanHistory Query (Ownership-Gated Scan History)

The scanHistory query returns the chronological scan history for a specific object. It is ownership-gated: only the object’s creator or a member of its owning organisation receives results. Unauthorised or unauthenticated callers always receive an empty list — no scanner identity or location data is ever leaked to third parties.

GraphQL signature

query ScanHistory($objectId: ID!, $limit: Int) {
  scanHistory(objectId: $objectId, limit: $limit) {
    at
    latitude
    longitude
    scannedBy
    scannedByName
  }
}
Argument Type Default Description
objectId ID! UUID of the object whose scan history is requested
limit Int 20 Maximum number of events to return (most-recent first)

ScanEventInfo type

type ScanEventInfo {
  at:            String   # ISO 8601 UTC timestamp of the scan
  latitude:      Float    # GPS latitude (null if not recorded)
  longitude:     Float    # GPS longitude (null if not recorded)
  scannedBy:     ID       # User UUID (user: prefix stripped); null for anonymous scans
  scannedByName: String   # Resolved from profiles.full_name; null if unknown
}

Results are ordered most-recent first.

Guarded tag-walk — how events are selected

The resolver performs a guarded tag-walk over the Neo4j graph:

(:ObjectInstance) ← [:IDENTIFIES] ← (:PlingsIdentifier) ← [:SCANNED] ← (:ScanEvent)

Not every :ScanEvent reachable via that path belongs to this object’s history. The guard keeps an event only when either of the following is true:

  1. Post-attachment scanevent.object_id == objectId
    The event was recorded after the tag was physically attached to this object. The object_id field is written onto the :ScanEvent node by resolveIdentifier at scan time, so it survives tag reassignment: if a tag is ever moved to a different object, events from the old attachment are no longer matched by this branch.

  2. Creation-trigger scan — the scan was made by the object’s created_by user within CREATION_SCAN_WINDOW (10 minutes) before the object’s created_at timestamp.
    This captures the registration scan: the user scans an unregistered tag, which opens the “create object” flow, and the object record is written a few seconds later. At that point the :ScanEvent node does not yet carry an object_id (it was written before the object existed), so the post-attachment guard would miss it. The creation-window guard recovers it.

What is excluded by design:

Ownership gating

Caller Result
Object’s creator (created_by matches JWT sub) Full history, up to limit events
Member of the object’s owning organisation Full history, up to limit events
Authenticated user who is neither Empty list []
Unauthenticated (no JWT) Empty list []

DB errors are swallowed and also return an empty list (fail-safe; no internal error detail is surfaced to the caller).

Example query and response

query {
  scanHistory(objectId: "550e8400-e29b-41d4-a716-446655440000", limit: 5) {
    at
    latitude
    longitude
    scannedBy
    scannedByName
  }
}
{
  "data": {
    "scanHistory": [
      {
        "at": "2026-06-17T11:23:44Z",
        "latitude": 59.3293,
        "longitude": 18.0686,
        "scannedBy": "d4c1b2a0-...",
        "scannedByName": "Paul Wisén"
      },
      {
        "at": "2026-06-14T08:05:11Z",
        "latitude": null,
        "longitude": null,
        "scannedBy": null,
        "scannedByName": null
      }
    ]
  }
}

The second event is a browser-path scan (no GPS consent, no authenticated user).

No schema migration, no backfill

scanHistory reads :ScanEvent nodes that already exist in Neo4j from prior scan logging. No new graph nodes or edges are written; no Postgres migration is required. Objects scanned before the native scan logging feature (Jun 2026) will have events with null coordinates and null scannedBy.

iOS integration

scanHistory backs the compact “Senast sedd” history line on the iOS object detail view and the “Visa alla” full-history screen. The compact line shows the most-recent event inline; “Visa alla” loads the full list (up to the default limit: 20).