RFID and Self-Check Circulation Sync

Operating within the broader Circulation Protocols & Interoperability architecture, this guide covers the moment where a physical RFID read becomes a circulation transaction: a patron drops a stack of books on a self-check pad, the reader returns a set of encoded tag payloads, and the station must turn those bytes into a clean, deduplicated checkout against the ILS and then disarm each item’s anti-theft security so the patron walks out without setting off the gate. That chain is deceptively fragile. The tag speaks ISO 28560, a binary data model that stores an item identifier the ILS has never heard of unless it exactly matches the item barcode; the checkout itself speaks SIP2 Protocol Integration, an entirely different framed-message protocol; and the security bit lives back on the tag, which means a successful loan is not finished until a second write lands on the same piece of RFID hardware that read it. This page walks through the ISO 28560 data model, the read-to-checkout-to-security-bit data flow, a production reconciliation and checkout implementation, the privacy rule that keeps patron data off the tag entirely, and the error-handling, performance, and verification patterns that keep a self-check fleet reliable.

Data flow from an RFID pad read through the self-check controller to a SIP2 checkout and a security-bit write-back An RFID pad read returns raw ISO 28560 tag payloads to the self-check controller, which parses each tag and reconciles its primary item identifier against the ILS item barcode. The reconciled barcode is sent as a SIP2 checkout to the ILS, and on an OK response the controller issues an EAS/AFI security-bit write back to the same tag to disarm the anti-theft gate. On a checkout failure the controller diverts the item on a dashed branch to an exception tray, leaving its security bit armed. A trusted-zone note under the controller records that only item data, never patron data, is ever written to a tag. RFID pad read raw ISO 28560 tag payloads self-check controller parse tag payload reconcile id vs item barcode SIP2 checkout 11 request to ILS 12 response security-bit write flip EAS / set AFI gate disarmed exception tray bit stays armed item data only never patron PII on tag OK checkout fail

Specification & Contract

A self-check transaction is a bridge between two standards that share nothing but the item. On one side is the RFID tag, a passive HF (13.56 MHz, ISO 15693) transponder whose memory holds a small structured record defined by ISO 28560. On the other side is the ILS, which knows the item only by its barcode and executes loans over SIP2. The self-check controller’s job is to read the first, prove it maps to the second, perform the loan, and then reach back to the tag to disarm its anti-theft security. Every failure mode on this page comes from a gap between those two identifiers or a half-completed write to the tag.

ISO 28560 defines which data elements a library tag carries and how they are encoded; the standard has three parts, and the one that matters operationally is ISO 28560-2, the compact encoding used by the widely deployed Danish Data Model. Each element has a stable numeric element identifier, so a reader that does not recognise an optional element can skip it by length rather than choking. The primary item identifier is mandatory and, by design, holds the same string printed in the item’s barcode — the tag does not carry a database key of its own, precisely so that a barcode fallback scan and an RFID scan resolve to the identical ILS record.

Element ID Name Description
1 Primary item identifier Mandatory. The item barcode string; the join key against the ILS item record and the value sent in the SIP2 checkout
2 Content parameter Encoding/version flags describing how the remaining block is laid out (relative vs absolute OID, byte order)
3 Owner institution (ISIL) The owning library’s ISIL code (e.g. US-xxxxx), used to route inter-library returns and reject foreign tags
4 Set information Part n of m for multi-part items (a boxed set, a book-plus-disc), so the station knows how many tags equal one loan
5 Type of usage A code distinguishing a circulating item from equipment, a patron card, or an acquisition-in-process tag
AFI (Application Family Identifier) The ISO 15693 security byte read/written out-of-band from the data block; toggled between “armed” and “disarmed” to drive EAS gates

Two properties of this table govern the whole implementation. First, the primary item identifier is the only value the ILS understands — element 3’s ISIL and element 4’s set data are for the controller’s own routing logic, never sent as-is to SIP2. Second, the AFI (or, on some tag families, a dedicated EAS bit) is not part of the addressable data block; it is a single byte toggled by its own command, which is why disarming a loaned item is a physically separate operation from writing any data element, and why it can succeed or fail independently of the checkout. Choosing SIP2 as the checkout transport here is deliberate; the trade-off against NCIP for self-service circulation is laid out in SIP2 vs NCIP: Choosing the Right Circulation Protocol.

Prerequisites & Environment Setup

The examples target Python 3.11+ and assume the reader talks to the RFID hardware over a vendor SDK or a serial/USB HID bridge that exposes two primitives: read a tag’s data block and read/write its AFI byte. The SIP2 side reuses the framed-message client from SIP2 Protocol Integration; this page treats it as a dependency rather than re-deriving the checksum and sequence-number handling. Credentials for the SIP2 endpoint are resolved at runtime, never committed, exactly as with any ILS integration.

Core Implementation

The transaction runs through four labeled stages: parse the tag payload, reconcile its identifier against the ILS barcode, perform the SIP2 checkout, then — and only on success — flip the security bit. Keeping the security-bit write strictly downstream of an OK checkout response is the single most important ordering rule: disarm before the loan is committed and you have handed the patron an item the ILS still shows on the shelf.

Step 1 — Parse the ISO 28560 tag payload

The reader hands back a raw byte block. A typed parser walks it element by element, using each element’s declared length so an unknown optional element is skipped rather than fatal. The result is a frozen dataclass — immutable, because nothing downstream should be rewriting what the tag said.

python
from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Optional

logger = logging.getLogger("rfid_selfcheck")


class TagParseError(Exception):
    """The tag block is malformed or is missing a mandatory element."""


@dataclass(frozen=True, slots=True)
class Iso28560Tag:
    """A parsed ISO 28560-2 library tag. Item data only — never patron data."""

    primary_item_id: str            # element 1 (mandatory)
    owner_isil: Optional[str]       # element 3
    set_index: int = 1              # element 4, part n ...
    set_total: int = 1              # ... of m
    type_of_usage: Optional[str] = None  # element 5


def parse_iso28560(block: bytes, elements: dict[int, bytes]) -> Iso28560Tag:
    """Assemble a typed tag from decoded {element_id: value} pairs.

    `elements` is the reader SDK's decoding of the data block into
    element-id keys; this function enforces the mandatory-element contract.
    """
    raw_primary = elements.get(1)
    if not raw_primary:
        raise TagParseError("missing mandatory element 1 (primary item identifier)")

    set_info = elements.get(4, b"\x01\x01")
    index, total = set_info[0], set_info[1] if len(set_info) > 1 else 1

    tag = Iso28560Tag(
        primary_item_id=raw_primary.decode("ascii").strip(),
        owner_isil=elements.get(3, b"").decode("ascii").strip() or None,
        set_index=index or 1,
        set_total=total or 1,
        type_of_usage=elements.get(5, b"").decode("ascii").strip() or None,
    )
    logger.info(
        "tag_parsed",
        extra={"primary_item_id": tag.primary_item_id, "owner_isil": tag.owner_isil,
               "set": f"{tag.set_index}/{tag.set_total}"},
    )
    return tag

Pitfall: never treat type_of_usage as decoration. A tag whose element 5 marks it as a patron card or an in-process acquisition item must be refused a checkout outright — feeding it to SIP2 produces a confusing “item not found” instead of an honest “this is not a circulating item.”

Step 2 — Reconcile the primary item identifier against the ILS barcode

The tag’s primary item identifier is supposed to equal the ILS barcode, but “supposed to” is where fleets break. Normalize both sides — strip whitespace, fold case, and apply your library’s leading-zero and check-digit convention — before comparing. This reconciliation logic is deep enough to warrant its own treatment in Reconciling RFID Tag Scans with Item Barcodes; the version here is the guard the checkout path needs.

python
class ReconciliationError(Exception):
    """The tag identifier does not resolve to a usable ILS barcode."""


def normalize_barcode(value: str) -> str:
    """Fold a tag or ILS barcode to a comparable canonical form."""
    return value.strip().upper().lstrip("0")


def reconcile(tag: Iso28560Tag, owner_isil: str) -> str:
    """Validate ownership and return the barcode to send to SIP2."""
    if tag.owner_isil and tag.owner_isil != owner_isil:
        # A foreign tag belongs to another library's inter-lending flow.
        raise ReconciliationError(
            f"foreign owner ISIL {tag.owner_isil!r}, expected {owner_isil!r}"
        )
    barcode = normalize_barcode(tag.primary_item_id)
    if not barcode:
        raise ReconciliationError("empty barcode after normalization")
    return barcode

Pitfall: reconcile ownership before the checkout, not after. A tag owned by a partner library may still resolve to a real barcode string, so a normalization-only check would happily loan another institution’s item; the ISIL guard in element 3 is what stops it.

Step 3 — Perform the SIP2 checkout, then flip the security bit

The checkout and the security-bit write are wrapped in a single transaction context manager so the security write cannot possibly run unless the SIP2 response was OK, and so a failure at either step is logged as one coherent event. The write_afi call is the physical act of disarming the gate; it happens last.

python
from contextlib import contextmanager
from typing import Iterator, Protocol

AFI_ARMED = 0x07       # confirm against your gate vendor
AFI_DISARMED = 0xC2


class SecurityWriteError(Exception):
    """The checkout committed but the tag's security bit could not be flipped."""


class RfidReader(Protocol):
    def write_afi(self, tag_uid: str, afi: int) -> None: ...


class Sip2Client(Protocol):
    def checkout(self, *, item_barcode: str, patron_barcode: str) -> "Sip2Response": ...


@dataclass(frozen=True, slots=True)
class Sip2Response:
    ok: bool
    screen_message: str


@contextmanager
def checkout_transaction(item_barcode: str) -> Iterator[dict]:
    """Bracket one item's checkout so success/failure logs as one event."""
    ctx: dict = {"item_barcode": item_barcode, "committed": False}
    try:
        yield ctx
    finally:
        logger.info("checkout_transaction_end", extra=ctx)


def checkout_and_disarm(
    tag: Iso28560Tag,
    tag_uid: str,
    patron_barcode: str,
    owner_isil: str,
    sip2: Sip2Client,
    reader: RfidReader,
) -> None:
    """Reconcile, check out over SIP2, then disarm the anti-theft bit."""
    barcode = reconcile(tag, owner_isil)
    with checkout_transaction(barcode) as ctx:
        response = sip2.checkout(item_barcode=barcode, patron_barcode=patron_barcode)
        if not response.ok:
            # Loan refused (fines, holds, not-holdable): leave the bit ARMED.
            logger.warning(
                "checkout_refused",
                extra={"item_barcode": barcode, "screen": response.screen_message},
            )
            raise CheckoutRefused(response.screen_message)
        ctx["committed"] = True

        try:
            reader.write_afi(tag_uid, AFI_DISARMED)
        except Exception as exc:  # reader/comm failure after a committed loan
            logger.error(
                "security_bit_write_failed",
                extra={"item_barcode": barcode, "tag_uid": tag_uid},
            )
            raise SecurityWriteError(barcode) from exc
        logger.info("item_disarmed", extra={"item_barcode": barcode})


class CheckoutRefused(Exception):
    """SIP2 returned a not-OK checkout response for a valid item."""

Pitfall: the ordering is load-bearing. If write_afi(AFI_DISARMED) ran before the SIP2 response was confirmed OK, a network failure on the checkout would leave the item disarmed but not loaned — a walk-out. Disarm strictly after ctx["committed"] = True. The inverse case (loan committed, disarm failed) is recoverable and is handled in Error Handling below.

PII & Compliance Checkpoints

The defining privacy rule of library RFID is short and absolute: the tag carries item data only, never patron data. ISO 28560 has no element for a borrower, and there must never be a locally-invented one. A tag that could be read by any 13.56 MHz reader in a bag or on a shelf must not disclose who borrowed the item, what a patron has out, or any identity token — otherwise the collection becomes a covert tracking surface readable from a doorway.

Patron identity stays on the wire, not on the tag. In the checkout flow above, patron_barcode is read from the patron’s own card at the station and passed straight into the SIP2 message; it is never written into any ISO 28560 element and never persisted on the item tag. The reconciliation and checkout logs deliberately record only the item barcode and tag UID, not the patron barcode, so the operational trail cannot be mined into a circulation history keyed by person.

Any export of these logs is a patron-data export. The instant a self-check event log is joined to patron records for analytics or troubleshooting, it falls under the same masking obligations as any other circulation feed. Route it through the deterministic tokenization boundary described in PII Masking in Patron Data Exports so the patron identifier becomes a stable, non-reversible token before it leaves the trusted zone. Keeping patron data off the tag is the first control; masking it out of the logs is the second.

Error Handling & Quarantine Patterns

Self-check failures split into three classes, and conflating them is how a fleet either loses items or frustrates patrons. A parse/reconciliation failure means this item cannot be trusted — divert it to the exception tray with its bit still armed. A checkout refusal is a business outcome (a hold, a fine, a non-circulating type) — surface the screen message, keep the bit armed, no quarantine. A security-write failure after a committed loan is the dangerous one: the ILS shows the item out, but the gate will still alarm, so it must be retried and, if still failing, flagged for staff rather than silently dropped.

python
def process_item(
    block: bytes, elements: dict[int, bytes], tag_uid: str,
    patron_barcode: str, owner_isil: str,
    sip2: Sip2Client, reader: RfidReader, exception_tray,
) -> None:
    """Drive one tag through the full flow with class-specific handling."""
    try:
        tag = parse_iso28560(block, elements)
        checkout_and_disarm(tag, tag_uid, patron_barcode, owner_isil, sip2, reader)
    except (TagParseError, ReconciliationError) as exc:
        # Untrusted item: never loaned, bit stays armed, staff inspects.
        exception_tray.put({"tag_uid": tag_uid, "reason": str(exc)})
        logger.warning("item_quarantined", extra={"tag_uid": tag_uid, "reason": str(exc)})
    except CheckoutRefused:
        # Business refusal — already logged; patron sees the screen message.
        pass
    except SecurityWriteError as exc:
        # Loan COMMITTED but not disarmed: retry once, else flag for staff.
        try:
            reader.write_afi(tag_uid, AFI_DISARMED)
            logger.info("item_disarmed_on_retry", extra={"item_barcode": str(exc)})
        except Exception:
            exception_tray.put({"tag_uid": tag_uid, "reason": "disarm_failed_loan_committed"})
            logger.error("disarm_retry_failed", extra={"tag_uid": tag_uid})

The SecurityWriteError branch never un-does the loan. Reversing a committed checkout to re-arm the item would race against the ILS and risk double-charging; the correct recovery is to complete the disarm out-of-band, which is why the item goes to a staff-visible tray rather than a silent drop. This mirrors the isolation discipline libraries use when they route bad ingest records to a schema validation quarantine queue — the good transactions in the batch keep flowing while the ambiguous one is set aside for a human.

Performance Considerations

The bottleneck at a self-check station is almost never CPU — an ISO 28560 parse and an HMAC-free reconciliation are microseconds. It is I/O latency, and specifically the two round trips per item: one SIP2 request/response over the network to the ILS, and one AFI write over the reader bus to the tag. A cart of twenty items processed strictly serially, each waiting on a full SIP2 round trip, is what makes a station feel slow.

Read the whole antenna field once, then pipeline. The reader can return every tag in the field in a single anti-collision sweep, so parse and reconcile all of them up front while the SIP2 client processes checkouts, and issue each AFI write the instant its own checkout confirms rather than batching the disarms to the end. Do not, however, parallelize the security writes ahead of their checkouts — the ordering guarantee is more valuable than the latency. When the SIP2 endpoint itself is the limiter under a fleet of stations, the same throttle-and-backoff discipline from ILS REST API Polling & Rate Limiting applies to the SIP2 connection pool, so a busy branch does not stampede the ILS into refusing connections mid-transaction.

Verification & Testing

Two properties must be proven by test, not assumed: the security bit is flipped only on a committed loan, and a foreign or malformed tag never reaches the ILS. Use fakes for the reader and SIP2 client so the ordering invariant is observable.

python
import pytest


class FakeReader:
    def __init__(self) -> None:
        self.afi_writes: list[tuple[str, int]] = []

    def write_afi(self, tag_uid: str, afi: int) -> None:
        self.afi_writes.append((tag_uid, afi))


class FakeSip2:
    def __init__(self, ok: bool) -> None:
        self._ok = ok

    def checkout(self, *, item_barcode: str, patron_barcode: str) -> Sip2Response:
        return Sip2Response(ok=self._ok, screen_message="" if self._ok else "on hold")


def _tag() -> Iso28560Tag:
    return Iso28560Tag(primary_item_id="0031234567", owner_isil="US-abc")


def test_disarm_only_after_committed_checkout():
    reader = FakeReader()
    checkout_and_disarm(_tag(), "E004", "P77", "US-abc", FakeSip2(True), reader)
    assert reader.afi_writes == [("E004", AFI_DISARMED)]


def test_refused_checkout_leaves_bit_armed():
    reader = FakeReader()
    with pytest.raises(CheckoutRefused):
        checkout_and_disarm(_tag(), "E004", "P77", "US-abc", FakeSip2(False), reader)
    assert reader.afi_writes == []   # never disarmed


def test_foreign_tag_never_reaches_sip2():
    reader = FakeReader()
    foreign = Iso28560Tag(primary_item_id="0031234567", owner_isil="GB-xyz")
    with pytest.raises(ReconciliationError):
        checkout_and_disarm(foreign, "E004", "P77", "US-abc", FakeSip2(True), reader)
    assert reader.afi_writes == []


def test_parse_rejects_missing_primary_id():
    with pytest.raises(TagParseError):
        parse_iso28560(b"", {3: b"US-abc"})

Beyond unit tests, run a physical gate test on every fleet deploy: check an item out at the station, then walk it through the security gate and confirm no alarm, and separately walk an un-checked-out item through and confirm the gate does alarm. A green unit suite proves the software flipped the byte; only the gate proves the byte means what the vendor’s gate thinks it means.

Troubleshooting

An item was checked out successfully but still alarms the security gate. What happened?

The SIP2 checkout committed but the AFI/EAS write to the tag failed or was never issued — the SecurityWriteError path. The ILS shows the loan, so the fix is never to reverse the checkout; re-run the disarm against the tag UID (the retry branch in Error Handling) or flag the item for staff to disarm at a desk reader. Check reader-bus connectivity and confirm your AFI_DISARMED constant matches the gate vendor’s expected “unsecured” value.

The gate does not alarm on an item that was never checked out. Is the tag dead?

Usually the item was returned or disarmed but never re-armed — a check-in flow that flips the AFI back to AFI_ARMED was skipped or failed. Read the tag’s AFI byte directly: if it reads the disarmed value on a shelf item, the arming step in your return workflow is broken, not the tag. A genuinely dead tag returns no data block at all, which surfaces as a TagParseError, not a silent gate.

Checkouts fail with “item not found” for tags that clearly have a barcode. Why?

The tag’s primary item identifier is not matching the ILS barcode after normalization — typically a leading-zero or check-digit mismatch, or the tag stores a value in a different grain than the barcode index. Log the normalized barcode from reconcile() and compare it to the ILS item record by hand. This is the exact failure the barcode reconciliation guide is dedicated to, and it almost always lives in normalize_barcode.

A patron from another library’s tag gets loaned at our station. How do we stop it?

Your reconciliation is skipping the element 3 ownership check. Confirm reconcile() compares tag.owner_isil against your library’s ISIL before returning the barcode, and that your tags actually encode element 3 — some older tag stock omits it, in which case the guard cannot fire and you need a stricter policy (reject any tag missing an ISIL) at branches that participate in inter-library lending.

The self-check station feels slow with a full cart. Where is the time going?

It is I/O, not parsing: a serial SIP2 round trip plus an AFI write per item, run back-to-back. Read the entire antenna field in one anti-collision sweep, reconcile all tags up front, and pipeline the SIP2 checkouts while issuing each disarm the moment its own checkout confirms — but keep each disarm strictly after its checkout, as covered in Performance Considerations. If the ILS connection is the limiter across a fleet, apply SIP2 connection-pool backoff.