Reconciling RFID Tag Scans with Item Barcodes
This page answers one narrow operational question: why does a self-check checkout fail — or act on the wrong item — when the primary item identifier read from an ISO 28560 RFID tag does not match the item barcode the ILS expects, and how do you insert a normalization and reconciliation layer that canonicalizes both identifiers before the checkout ever reaches the circulation system? It sits under RFID and Self-Check Circulation Sync, which covers the self-check data path end to end, and within the broader Circulation Protocols & Interoperability architecture. If you run a self-check fleet whose readers speak RFID but whose ILS was built around printed barcodes, this mismatch is one you will eventually hit on a re-barcoded or re-tagged item.
Problem Framing
The symptom presents at the self-check unit, not in a log you were watching. A patron taps a book on the RFID pad, the screen reads the tag, and either the transaction is rejected with “item not found” or — far more dangerous — the ILS charges out a different item than the one on the pad. The reader hardware is working perfectly; it returns exactly the primary item identifier that was encoded on the ISO 28560 tag. The problem is that that identifier is not the string the ILS indexes its items by.
The mismatch is obvious the moment you log both sides of the bridge. The self-check middleware reads the tag, then issues a SIP2 11 Checkout with the raw tag id in the AB item-identifier field, and the ILS answers AF with a screen message:
2026-07-14T09:41:02Z rfid.read tag_primary_id="211004991234560" antenna=2
2026-07-14T09:41:02Z sip2.send 11YN20260714 093000AOmain|AA21544000123|AB211004991234560|AC|
2026-07-14T09:41:02Z sip2.recv 120NUN20260714 093000AOmain|AB211004991234560|AJ|AH|AFItem not found in database.|
2026-07-14T09:41:02Z bridge.fail item_lookup_miss tag="211004991234560" ils_barcode_expected="4991234560"
The tag carries 211004991234560; the ILS knows the same physical item as barcode 4991234560. An institution prefix (211), two leading zeros, and a trailing check digit separate the string the reader produced from the string the circulation system will accept. Because the bridge forwarded the raw tag id verbatim, the ILS rejected a valid item — and on a collision, a different item’s barcode could match instead.
Root Cause
The tags were encoded under a different identifier scheme than the one the ILS barcodes use, and nothing between the reader and the circulation system reconciles the two. ISO 28560 standardizes how a primary item identifier is stored on the tag, not what that identifier is; the encoding vendor was free to write the full institution-prefixed, check-digit-bearing string, while the ILS item record stores the bare barcode. Three concrete divergences produce the miss:
- Leading zeros. Barcode symbologies and tag-encoding tools pad to a fixed width; the ILS stores the significant digits only, so
004991234560and4991234560are the same item to a human and different keys to a database. - Check digit. Codabar and Code 39 barcodes carry a trailing modulo check digit that the printed label and the tag include but the ILS item key may omit — or compute differently.
- Institution prefix. Consortia namespace their barcodes with an owning-library prefix so items can circulate between branches; a tag encoded before a re-barcoding, or by a different vendor, may carry the wrong prefix or none at all.
The compounding failure is operational drift: an item gets re-barcoded at the desk after a damaged label, but the RFID tag is not re-encoded, so the tag now points at a barcode that no longer exists. The self-check bridge makes this worse by design — it maps RFID directly to SIP2 using the raw tag id, treating the tag string as if it were already the ILS item key. There is no canonicalization step, so any scheme difference becomes a checkout failure. The fix is to stop trusting the raw tag id and insert a reconciliation layer before the SIP2 protocol integration that actually performs the checkout.
Solution
Insert a normalization and reconciliation layer between the reader and the SIP2 client. It canonicalizes both the tag id and the barcode to the same form — strip any institution prefix, remove leading zeros, and validate (or recompute) the check digit — then maps the canonical key to the authoritative item barcode before the checkout is issued. A scan whose canonical key resolves to no item is quarantined for staff review rather than forwarded, so a stale tag can never charge out the wrong item.
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Protocol
logger = logging.getLogger("rfid.reconcile")
_INSTITUTION_PREFIXES: frozenset[str] = frozenset({"210", "211"})
_NON_ALNUM = re.compile(r"[^0-9A-Za-z]")
class BarcodeIndex(Protocol):
"""Authoritative lookup: canonical key -> ILS item barcode."""
def resolve(self, canonical_key: str) -> str | None: ...
class ReconciliationError(Exception):
"""Raised when a tag id cannot be reconciled to an item barcode."""
class CheckDigitError(ReconciliationError):
"""Raised when the trailing check digit fails validation."""
@dataclass(frozen=True)
class Reconciled:
tag_primary_id: str
canonical_key: str
item_barcode: str
def _codabar_check_digit(digits: str) -> str:
"""Modulo-10 (Luhn-style) check digit used by the barcode symbology."""
total = 0
for offset, char in enumerate(reversed(digits)):
value = int(char) * (2 if offset % 2 == 0 else 1)
total += value - 9 if value > 9 else value
return str((10 - total % 10) % 10)
def canonicalize(identifier: str) -> str:
"""Reduce a tag id or barcode to a scheme-independent canonical key.
Strips non-alphanumerics and any known institution prefix, validates the
trailing check digit, then removes leading zeros so padded and unpadded
encodings of the same item collapse to one key.
"""
cleaned = _NON_ALNUM.sub("", identifier)
if len(cleaned) < 2 or not cleaned.isdigit():
raise ReconciliationError(f"non-numeric identifier: {identifier!r}")
for prefix in _INSTITUTION_PREFIXES:
if cleaned.startswith(prefix) and len(cleaned) > len(prefix) + 1:
cleaned = cleaned[len(prefix):]
break
body, check = cleaned[:-1], cleaned[-1]
expected = _codabar_check_digit(body)
if check != expected:
raise CheckDigitError(
f"check digit {check} != expected {expected} for {identifier!r}"
)
return body.lstrip("0") or "0"
def reconcile_scan(
tag_primary_id: str,
*,
index: BarcodeIndex,
reader_antenna: int,
) -> Reconciled:
"""Map a raw RFID primary id to the ILS item barcode, or raise.
The canonical key is resolved against the authoritative barcode index;
an unresolved key is an explicit error so the caller can quarantine the
scan instead of sending a raw tag id to the circulation system.
"""
try:
key = canonicalize(tag_primary_id)
except CheckDigitError:
logger.warning(
"check_digit_mismatch",
extra={"tag": tag_primary_id, "antenna": reader_antenna},
)
raise
barcode = index.resolve(key)
if barcode is None:
logger.warning(
"item_lookup_miss",
extra={"tag": tag_primary_id, "canonical_key": key,
"antenna": reader_antenna, "action": "quarantine"},
)
raise ReconciliationError(f"no item for canonical key {key!r}")
logger.info(
"scan_reconciled",
extra={"tag": tag_primary_id, "canonical_key": key, "item_barcode": barcode},
)
return Reconciled(tag_primary_id=tag_primary_id, canonical_key=key, item_barcode=barcode)
The behavioural change is that the SIP2 client is never handed a raw tag id again. Before, reconcile_scan did not exist and the bridge sent 211004991234560 straight into the AB field, where the ILS could only reject it or mis-match it. After, both the tag id and the ILS barcode 4991234560 reduce to the same canonical key 499123456 (prefix stripped, leading zeros removed, check digit validated), the resolve lookup returns the authoritative barcode, and only that reconciled string reaches the checkout. A re-barcoded item whose tag was never re-encoded resolves to no key and is quarantined — visibly, with a log line — rather than silently charging out whatever the raw id happened to collide with.
Compliance or Privacy Impact
The reconciliation layer touches item data only, and that boundary is the whole compliance story: an RFID tag must carry item identifiers and nothing else — never a patron identifier, borrowing history, or any personal data. ISO 28560 defines fields for the item, and libraries deliberately keep the patron out of the tag so that a tag read in the open air cannot be turned into surveillance of who is carrying which book. The tag_primary_id in the code above is an item key; it joins to a patron only transiently inside the SIP2 checkout, and only after the patron has authenticated at the unit.
Two rules follow. First, keep patron identifiers out of the reconciliation logs. The extra={...} payloads above deliberately log the tag id, canonical key, and antenna — never the AA patron barcode from the SIP2 exchange. When you export self-check logs for analytics or troubleshooting, mask any patron field the same way the PII masking in patron data exports contract requires, so a diagnostic feed cannot be joined back to a borrower. Second, if a future tag-encoding project is ever proposed that would write patron or circulation data onto the tag itself, treat it as a privacy regression to be rejected: the item barcode is the only identifier that belongs on the tag, and the reconciliation layer exists precisely so the ILS can hold the patron-to-item link on the server side, behind authentication, instead of on an air-readable chip.
Verification
Confirm the layer collapses divergent encodings to one key and quarantines an unresolved scan with a test that exercises the prefix, leading-zero, and check-digit paths against a stub index.
import pytest
class StubIndex:
def __init__(self, mapping: dict[str, str]) -> None:
self._mapping = mapping
def resolve(self, canonical_key: str) -> str | None:
return self._mapping.get(canonical_key)
def test_padded_prefixed_tag_and_ils_barcode_share_one_key() -> None:
# The prefixed, zero-padded tag and the bare ILS barcode collapse to one key.
assert canonicalize("211004991234560") == canonicalize("4991234560") == "499123456"
def test_reconcile_maps_tag_to_authoritative_barcode() -> None:
index = StubIndex({"499123456": "4991234560"})
result = reconcile_scan("211004991234560", index=index, reader_antenna=2)
assert result.item_barcode == "4991234560"
assert result.canonical_key == "499123456"
def test_stale_tag_is_quarantined_not_checked_out() -> None:
index = StubIndex({}) # item was re-barcoded; tag points at nothing
with pytest.raises(ReconciliationError):
reconcile_scan("211004991234560", index=index, reader_antenna=2)
def test_bad_check_digit_raises() -> None:
index = StubIndex({"499123456": "4991234560"})
with pytest.raises(CheckDigitError):
reconcile_scan("4991234561", index=index, reader_antenna=2) # wrong check digit
For a running fleet, add a continuous invariant on the bridge itself: assert that no raw tag_primary_id is ever placed in a SIP2 AB field — every checkout must carry a Reconciled.item_barcode that came out of resolve. Track the item_lookup_miss rate per unit over time; a sudden rise on one self-check station is usually a batch of items re-barcoded at that branch without re-encoding, and a nonzero baseline tells you exactly how many tags need re-encoding before they silently fail at the pad.
Related
- RFID and Self-Check Circulation Sync — the parent guide to the self-check data path this reconciliation layer plugs into.
- Circulation Protocols & Interoperability — the wider architecture governing how self-check units talk to the ILS.
- SIP2 Protocol Integration for Self-Service Circulation — the checkout exchange that consumes the reconciled item barcode.
- PII Masking in Patron Data Exports — keeping patron identifiers out of exported self-check logs.