Workflow: Identifier Scan Flow

This flow describes what happens immediately after a QR or NFC identifier is scanned. The goal is to decide what the user sees next and which backend calls are triggered.

Resolver Contract

resolveIdentifier(input) → {
  success: boolean,
  routing: { action, destination },
  object: ObjectContext | null,   // null = no object materialized for this identifier yet
  scanEvent, error
}

“Unknown” means no object has been materialized for this identifier yet (lazy creation) — distinct from an unrecognised/invalid identifier, which surfaces as error.

Branches

1. Unknown Tag

Variant UI Dialog Primary Buttons
Unknown, General Class “We don’t recognise this item.” Create new object (pre-attaches identifier) • Tag existing object (opens search)
Unknown, Specific Class Shows class name + placeholder image Create new object (pre-fills class) • Tag existing object (opens search) • Cancel

“Tag existing object” is available for both general and specific classes — the class pointer is a loose assertion (see Identifier–Object Lifecycle), not a binding. The general/specific distinction only changes what “Create new object” pre-fills.

2. Known Object

User Rights Behaviour
canEdit = true Navigate straight to Object Detail with full edit actions
false Load Object Detail in Read-Only mode. Banner shows owning org. If object has sale/rent flags, show Purchase / Rent / Borrow CTA

3. Background Actions

  • Every scan triggers createScanEvent() (audit & analytics).

4. Error & Edge Cases

  • Invalid code → toast “Identifier not valid”.
  • Duplicate tag → list of candidate objects to pick from.
  • Offline – cache last success & queue events until online.

GraphQL Operations

  • mutation resolveIdentifier(input: ResolveIdentifierInput!) – resolve a scanned tag (returns success, routing, object).
  • mutation assignTagToObject(objectId: ID!, tagData: TagDataInput!) – if the user chooses Tag existing object. Returns the object, the assigned short code, or a conflict if the tag is already on another object.
  • mutation createObject(input: CreateObjectInput!) – if the user creates a new object (pass tagInstanceKey/tagData to pre-attach the scanned tag).

The earlier linkIdentifierToObject / createObjectWithIdentifier names were never implemented and assumed the identifier already existed as a node. The real flow passes scanned tagData and upserts the identifier (lazy creation — see Identifier–Object Lifecycle).

Object-First Tagging (assign-tag-v2)

The scan-first flow above requires the user to begin with a physical scan. As of assign-tag-v2, a parallel object-first path is available: the user opens an object directly from the app’s spatial navigator and tags it from there — no prior scan needed.

Why this is now reachable: v1 deferred object-first tagging because the only way to reach an object in the app was to scan its existing tag — making an untagged object unreachable (a chicken-and-egg). That premise no longer holds. The iOS app is local-first: the spatial navigator builds the complete object graph from myObjectsLight and lets the user open any object — including objects that were imported, created by name, or never scanned — straight from the spatial tree. Object-first tagging corrects v1’s “presupposes the object is already tagged” framing: the path is reachable for any object, tagged or not.

Object-First Flow

  1. User opens an object from the navigator.
    • Has tags: a compact tag affordance row in the header (short code + +N suffix if multiple) — tap to open the Taggar management sheet.
    • No tags: a prominent “Tagga objekt” button in the header — launches the scanner directly.
  2. “Lägg till tagg” (in the sheet) or “Tagga objekt” (header button) presents the tag scanner.
  3. On scan, resolveIdentifier runs first (warn-don’t-steal — see inherited principle):
    • Unknown tagassignTagToObject(objectId, tagData) is called for this object. Short code appears in the header instantly (optimistic local update).
    • Tag already on another object → user is warned (“Taggen sitter redan på ‘X’”); nothing is written. No transfer is offered.
    • Tag already on this object → informational “Redan kopplad”, no-op.
  4. The server assignTagToObject conflict return remains the race backstop: if a conflict slips past the scan-time check (race condition), the queued job lands in the needs-attention queue.

The assignTagToObject mutation and its queue job (.assignTag) are reused unchanged from v1. The object-first path adds only a thin UI entry point and reuses the identical enqueue logic. Cross-link: Identifier–Object Lifecycle principle applies here unchanged — the tag’s class pointer never blocks attachment.

Unassign (assign-tag-v2)

A user-assigned (non-manufacturer) tag can be removed from an object via the Taggar sheet (swipe-to-delete). The removal is also available via the new mutation:

removeTagFromObject(objectId: ID!, shortCode: String!): RemoveTagResult!

type RemoveTagResult {
  object: ObjectInstance   # the object after removal
  removed: Boolean!        # true if an edge was deleted; false if not present (still success)
}

Key properties:

  • Keyed on shortCode, scoped to the object. The short code is the user-facing value and is unique-enough within a single object’s tag set to unambiguously identify which edge to drop.
  • Idempotent: removing a tag that is not on the object returns removed: false — not an error. A retried queue job never fails with a spurious error.
  • Manufacturer tags are not removable (decision D1). A manufacturer tag is the genuineness anchor (key-derivation verification); the UI badges them and does not offer swipe-to-delete. The server also rejects removal attempts. See the genuineness verification plan for background.
  • Removal is optimistic: the short code disappears from the header and sheet immediately; the .removeTag queue job syncs in the background; errors land in needs-attention.

New read field — identifiers

The object-detail query (getObjectDetailsComplete) now returns:

identifiers: [TagIdentifier!]   # nullable; populated only by getObjectDetailsComplete, null on lightweight queries
type TagIdentifier { shortCode: String!  isManufacturerTag: Boolean! }

This field powers the Taggar sheet: it provides each short code and the manufacturer flag the UI needs to badge tags and decide which ones are swipe-removable. It is populated only by the detail query (null on lightweight myObjectsLight queries, which carry only shortCodes). The long instanceKey is deliberately not exposed to clients.

Open Items / TODO

  • Rate-limit scan events for spamming protection.
  • Deep-link sharing of results (/o/:id vs modal).
  • UI wireframes (link to Figma) for all dialog variants.