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.

Normalizing an RFID tag id to an item barcode before SIP2 checkout A raw primary item identifier read from an ISO 28560 RFID tag enters a normalization stage that strips the institution prefix, removes leading zeros and validates the check digit, producing a canonical key. The canonical key is looked up against the item barcode index. On a match the reconciled barcode is passed to a SIP2 checkout; on no match the scan is routed to a quarantine queue for staff review instead of being sent to the ILS. RFID tag read ISO 28560 primary id normalize strip prefix · zeros validate check digit match barcode canonical key → item barcode index SIP2 checkout reconciled barcode quarantine staff review match no match

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:

text
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:

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.

python
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.

python
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.