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
- Validate manufacturer ID exists
- Check manufacturer status (active/suspended)
- Verify cryptographic signatures if provided
2. Object Resolution
- Look up object by identifier
- Check object status and ownership
- Retrieve current location (if tracked)
- Get associated business rules
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):
id— unique scan event identifier (returned assidin the redirect)timestamp— UTC datetime of the scanidentifier_type— transport/medium (e.g."QR","NFC")ip_hash— SHA-256 hash of the source IP (never stored in plaintext)object_id— resolved object UUID, ornullfor unknown tags
Additional fields written on the native app path (IMPLEMENTED):
latitude,longitude— GPS coordinates fromscanMetadata.geoLocationscanning_user— authenticated user (format:"user:<userId>")owner_at_time_of_scan— owning organisation at scan time (format:"org:<ownerOrgId>")
Postgres denormalisation (native path): The object_instances row is updated with:
last_scanned_at— timestamp of the scan (pre-existing column)last_scanned_by— user UUID (pre-existing column)last_scanned_latitude— GPS latitude (new column, migrationadd_scan_geolocation_columns)last_scanned_longitude— GPS longitude (new column, migrationadd_scan_geolocation_columns)
Fields deferred to Phase 2 (browser path):
latitude,longitude— browser will request geolocation and enrich viasidcallback in Phase 2
5. Performance Requirements
- Response time: <200ms (95th percentile)
- Caching: Support cache headers for routing decisions
- Rate limiting: Implement per-IP limits
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
ERRORroutings point to the single canonical scan-error routehttps://www.plings.io/scan-error?reason=<token>, where<token>isinvalid(bad/unauthorized/unknown identifier) orsystem(internal error). This is a query-param route — Plings-Web exposes one/scan-errorReact route, so a sub-path like/scan-error/invalidwould render NotFound. The form matches the Gateway firewall’s own error redirect (?reason=invalid&details=<token>). Fixed diagnostic tokens such asdetailsride along asparameters(the caller merges them onto the destination’s query string); raw user input is never echoed into the destination.
Security Considerations
- Rate Limiting: Implement per-IP rate limits (100 req/min)
- Input Validation: Strict validation of all parameters
- Authentication: Gateway service must authenticate with API key
- Encryption: All communication over HTTPS
- 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:
-
Post-attachment scan —
event.object_id == objectId
The event was recorded after the tag was physically attached to this object. Theobject_idfield is written onto the:ScanEventnode byresolveIdentifierat 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. -
Creation-trigger scan — the scan was made by the object’s
created_byuser withinCREATION_SCAN_WINDOW(10 minutes) before the object’screated_attimestamp.
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:ScanEventnode does not yet carry anobject_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:
- Scans made while the tag was unattached (registration of a printed tag series, loose-tag
testing, factory QC scans) — these carry no
object_idand fall outside the 10-minute creation window. - Scans of the same tag for a different object before or after this object’s attachment period.
- Browser-path scans that did not record GPS or a user (they appear with
nulllatitude / longitude / scannedBy, but are still included if the guard passes).
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).