Scan codes
A code printed on a physical object drives the platform from the floor. Scanning it finds the row that stands for the object, checks that the row is in an expected queue and state, and runs a configured action: open it, claim it, resolve it with a canned payload, move it to another queue or cancel it. This page covers the code format, rules, holding an item across scans, badges and station devices.
Declare a scheme
Section titled “Declare a scheme”await start({ database: { connectionString: process.env.DATABASE_URL }, features: { scanCodes: true }, scanSchemes: [ { version: 10, name: 'Carton', target_facet: 'cartonId', encoding: 'delimited', rules: [ { category: '1', name: 'Packed', steps: [ { query: { roles: ['packing'], status: 'pending', availability: 'available' }, verb: 'resolve', params: { resolverPayload: { outcome: 'packed' } }, }, { verb: 'show-detail' }, ], fallback: { markdown: '**No carton found for this code.**' }, }, ], }, ],});Print 10:1:ctn-4417 beside the word “Packed”. Scanning it resolves the carton’s pending row in packing. If the row is anywhere else, the second step opens it, so the scan also answers “where is this carton?”.
features.scanCodes: true turns on the dashboard’s scan surfaces. The execute and config APIs work either way.
The code
Section titled “The code”A code has three parts: version:category:target.
| Part | Width | Selects |
|---|---|---|
| version | 2 digits (10 to 99) | The scheme: which metadata facet the target matches and how the code parses |
| category | 1 digit (0 to 9) | The rule: what scanning this code does |
| target | scheme-defined | The value matched against the scheme’s target_facet |
The label and the row share the facet value, so the scan is the join. In the dashboard, operators name schemes and actions and the numbers are assigned for them.
encoding |
Parses | Use with |
|---|---|---|
delimited (default) |
Text split on one character, default :, e.g. 10:1:SN-123 |
Code 128, QR, DataMatrix |
fixed |
Digits only with a target_length, e.g. 1015949975930; a trailing check digit is accepted |
UPC-A, EAN, ITF |
gtin |
UPC-A, EAN-13, EAN-8, GTIN-14 as printed on products. The target is the 14-digit GTIN and the one rule is category 0 |
Manufacturer barcodes |
A deployment has at most one gtin scheme. While it is enabled, a fixed scheme that would accept an 8, 12, 13 or 14 digit code is refused, so every digits-only code has one reading.
Rules are ECA
Section titled “Rules are ECA”A rule is a friendly name, an ordered list of steps and a fallback:
- Event: a code arrives.
- Condition: each step’s query against the queue (role, status, availability, facets).
- Action: the verb of the first step that matches.
{ "query": { "roles": ["packing"], // expected queue(s) "status": "pending", // pending | resolved | cancelled "availability": "available", // available | claimed | mine | any "facets": { "state": "open" }, // extra metadata guards "subtypes": ["carton"] // optional types / subtypes }, "cardinality": "first", // first | many "verb": "resolve", "confirm": { "prompt": "Close this carton?" }, "params": { "resolverPayload": { "outcome": "closed" } }}| Verb | Effect | Params |
|---|---|---|
show-detail |
Open the row’s detail page | |
show-list |
Open the list filtered to all matches | |
claim, claim-show-detail |
Atomic claim (and open) | durationMinutes, metadata |
release |
Release the caller’s own claim | |
resolve |
Atomic claim and resolve with a canned payload | resolverPayload, metadata |
escalate |
Create a row in another queue, optionally closing this one | targetRole, closeCurrent, escalationType, description, metadata |
cancel |
Claim as a lock, then cancel | |
accumulate |
Add the scanned item to an open accumulator row | itemKey, resolverPayload, metadata, accumulate |
present, hold, fill |
Offer choices, hold a subject, check off an expected item | See below |
availability |
Matches |
|---|---|
mine |
A row under a live claim held by the acting identity |
available |
An unclaimed row, or one whose claim lapsed |
claimed |
A row under anyone’s live claim |
any (default) |
Every row the rest of the query matches |
String values in itemKey, resolverPayload and metadata interpolate {scan.target}, {scan.category} and {scan.scannedAt}. {claim.<facet>} reads the acting user’s single live claim, and {item.<facet>} reads the row an item-mode accumulate located. A token that cannot resolve makes the step fall through.
Every mutating verb stamps scanScheme, scanCategory, scanActionName and scannedAt on the row, so scan-driven changes stay queryable.
Claim, resolve and cancel run as single by-metadata statements, and the caller’s role scope folds into the same SQL filter. Two people scanning the same code produce exactly one transition. The second scan reports conflict or falls through to the locator step.
Confirmation and fallback
Section titled “Confirmation and fallback”A step with confirm locates instead of acting. The scan answers confirm_required, the dashboard opens the row and shows the prompt, and confirming calls the same per-id endpoint every surface uses.
When no step matches, the response carries the rule’s fallback: markdown for the operator and optionally a route to open.
Facts above, choices below
Section titled “Facts above, choices below”Some objects carry one code for life, and the right action depends on where the object is. A present step locates the row, states its facts and returns a labeled choice set:
{ "query": { "roles": ["packing", "shipping", "returns"] }, "verb": "present", "facts": [ { "label": "Order", "value": "{item.orderId}" }, { "label": "Bin", "value": "{item.binCode}" }, { "label": "Ship by", "value": "{item.releaseAt}", "format": "date" } ], "choices": [ { "label": "Claim and start", "verb": "claim", "requireActingIdentity": true, "code": "CLAIM" }, { "label": "Complete", "verb": "resolve", "requireActingIdentity": true, "confirm": { "prompt": "Mark this item complete?" }, "params": { "resolverPayload": { "outcome": "complete" } } }, { "label": "View details", "verb": "show-detail" } ]}factstakes up to 12 labeled templates rendered on the server.formatisdate,ageorago.- A choice’s
codelets a worker scan the object, then scan an action card. - Picking a choice calls
POST /api/scan-codes/execute-choicewith a pointer. The server re-reads config, re-locates the row, re-checks identity and writes the exact row that was shown. A row that moved on answersconflict. autoSelectSingle: trueon a step with one confirm-less choice runs it directly: scan the item, it is claimed, the form is on screen.- A choice with
rolesruns only for a person holding one of them. A badge without the role is refused with the choice’srefusecopy and the badge is not spent. That is the manager override.
Holding a subject
Section titled “Holding a subject”At a bench where items go into containers, the motion is three scans: the item, your badge, the container. The container scan is the act.
hold is the item scan. It writes nothing and answers held with a subject the station keeps in memory and sends back with the next scans:
{ "query": { "roles": ["packing"] }, "verb": "hold", "params": { "hold": { "ttlSeconds": 300, "label": "{scan.target}", "headline": "{item.binCode}", "expect": { "schemes": [14], "prompt": "Walk to this bin and scan it." } } }}The station shows headline large, with subline and the prompt under it. hold.show names a roster field of the held row’s form and renders it under the hold card. The subject lapses after ttlSeconds (default 45).
Subject steps run only while a subject from subject.schemes is held, and read it with {subject.<facet>}:
| Step | The container scan |
|---|---|
accumulate with accumulate.from: "subject" |
Adds the held item to the accumulator the scan names. The held row is written as the reciprocal in the same statement. |
accumulate with accumulate.into: "subject" |
The held row collects the scanned code. With accumulate.item, the scanned item’s own pending row is written as the reciprocal too. |
fill with fill.into: "subject" |
Checks off one expected code on the held batch row. The answer carries progress: { filled, total, remaining }. |
Match and refuse before any write
Section titled “Match and refuse before any write”match.target lists templates the scanned target must equal (["{subject.binCode}"]). match.facets lists facets the container must share with the held row. A miss answers refused with refuse.markdown rendered, and nothing is written:
"match": { "target": ["{subject.binCode}"] },"refuse": { "markdown": "That is bin {container.binCode}. This item goes in **{subject.binCode}**.", "conflict": "That bin just closed. Scan the item again."},"done": { "markdown": "Place it in **{container.binCode}**. All good." }When the write loses a race, the answer is refused with refuse.conflict. Of two racing acts that name the same row, exactly one wins: the reciprocal statement writes both rows or neither. The server re-reads the subject’s row on every request and answers subject_stale when it moved on.
Badges: attribution, never privilege
Section titled “Badges: attribution, never privilege”A scheme with kind: "identity" is the badge layer. Its target_facet names the lt_users.metadata key a badge token matches (for example badge_id). A badge scan answers identity_primed with an acting grant: an ephemeral token minted under the scheme’s policy.
| Field | Meaning |
|---|---|
grant_ttl_seconds |
How long the grant lives (1 to 86400) |
grant_max_uses |
0: bound by time. n: covers n acts. |
grant_scope |
action (default): each act spends a use. subject: the grant binds to the held subject and covers that item’s whole motion. |
The grant rides later scans as actingToken. Verbs run as the badged person under their own live RBAC. An expired, exhausted or revoked grant answers not_primed, never a silent run as the device. Showing, presenting, holding and refusing spend nothing. A write spends one use, and a write that does not land gives it back.
Steps opt in with requireActingIdentity: true. A role sets its stations’ policy in properties.badge_grant:
"properties": { "kiosk": true, "badge_grant": { "ttl_seconds": 600, "max_uses": 0 } }Seed badge tokens as long random strings. Unknown badges answer identity_unknown.
Stations and kiosk mode
Section titled “Stations and kiosk mode”A shared device signs in once as a station account: a member with read_scope: 'all' and write_scope: 'none' on the queues it fronts. Reads are free. Every write takes a badge, and write_scope: 'none' means even a misconfigured rule cannot write as the station.
A role with properties.kiosk: true locks the dashboard for a user who is a member of exactly that one role: no left nav, the role’s list (or first portal) is home, and the session stays on the list, detail page, portals and scan screens. On the work form, anyone at the bench edits freely and the badge is asked at submit. Writes record the badged person in assigned_to and resolved_by, with the device in scanStation.
The full-screen experience is /scan/station. With a live grant, the header shows the badged person and the minutes left.
Executing a scan
Section titled “Executing a scan”curl -X POST https://lt.acme.dev/api/scan-codes/execute \ -H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \ -d '{ "code": "10:1:ctn-4417" }'The call runs as the caller under normal RBAC and adds actingToken and subject when the station holds them. Every terminal state is a structured 200:
| Outcome | Meaning |
|---|---|
executed |
A step matched and its action ran |
matched_list |
A show-list step matched |
confirm_required |
A confirm step located its row |
choices, held |
A present or hold step located its row |
refused |
Understood and not acted on. Nothing written. |
no_match_fallback |
No step matched; fallback included |
no_open_container |
The item was found but no pending container carries its facet |
conflict |
A concurrent actor won the row |
forbidden |
The caller’s roles bar the action |
subject_stale |
The held subject moved on |
not_primed |
The act needs a badge |
identity_primed, identity_unknown |
A badge scan matched a person, or did not |
unconfigured, invalid_code |
Unknown scheme or category, or no scheme parses the string |
Anything that produces a string can call it: a barcode scanner, an RFID reader, a camera decode, the execute_scan_code MCP tool, or curl.
Keystroke capture
Section titled “Keystroke capture”With scan surfaces on, the dashboard listens on every page. A keyboard-wedge scanner types its decode and ends with Enter. Capture checks whether the recent keys end in a configured code shape, strips the code back out of any focused field and runs it.
- Digits-only codes fire only at scanner speed (50 ms per key or faster), so a typed number in a field stays there.
- A form field with
x-lt-scanthat accepts the scheme takes the scan first. - Keys inside an element marked
data-lt-scan-capture="off"reach only that field. - A scanner with no Enter suffix fires after 300 ms of quiet at scanner speed.
- Delimited targets carry
a-z A-Z 0-9 . _ -and matching is case-sensitive.
The header bar runs rules without hardware: pick a rule from the type chip and type the target.
Configure without code
Section titled “Configure without code”Admin > Scan Codes lists schemes. Add a scheme, point it at a facet, then add actions: name it, order the steps, set the fallback. The same CRUD is at PUT/GET/DELETE /api/scan-codes/schemes/:version[/actions/:category] and in the MCP tools list_scan_schemes, upsert_scan_scheme, upsert_scan_rule, delete_scan_rule and execute_scan_code. An incoherent rule fails the write with the exact problem.
Declared scanSchemes follow code-owned configuration: under configSource: 'code' a changed rule is live after the next boot. Per-entry reset covers the scheme and its rules together.
