Handling SIP2 Checksum and Sequence-Number Errors

This page answers one narrow operational question: why do SIP2 messages from a self-check unit or payment kiosk sometimes fail their AZ checksum or arrive with an AY sequence number that no longer matches what the client expects, and how do you build a reader that rejects the corrupt frame instead of either crashing or — far worse — silently committing a garbled checkout to the ILS? It sits under SIP2 Protocol Integration for Self-Service Circulation, which covers the SIP2 message contract end to end, and within the broader Circulation Protocols & Interoperability architecture. If you run a fleet of self-service devices over a noisy serial-to-TCP bridge, a flipped byte on the wire is not a hypothetical — it is a Saturday-afternoon incident waiting for a reader that trusts every frame it receives.

SIP2 validate-then-accept-or-reject decision flow A raw SIP2 frame enters a validation gate that first recomputes the AZ checksum and compares it to the received value, then checks the AY sequence number against the expected next digit. If both pass, the frame is accepted and dispatched to the ILS handler. If the checksum mismatches or the sequence number has desynced, the frame is routed to a quarantine queue that requests retransmission or resets the counter, and the corrupt frame is never committed. An append-only audit log at the bottom records every accept and reject decision. raw SIP2 frame …AY3AZF1A2<CR> validation gate recompute AZ checksum compare to received check AY vs expected ACCEPT dispatch to ILS handler REJECT · quarantine resend request / reset both pass either fails append-only audit log — every ACCEPT and REJECT recorded with computed and received checksum

Problem Framing

The symptom presents in two flavours, and they are easy to confuse. In the benign flavour, a message reader throws — a ValueError, an index error, a decode failure — the moment a frame arrives whose trailing AZ field does not match the bytes that precede it. The self-check unit shows an error, the patron re-taps, and the incident is noisy but self-limiting. In the dangerous flavour, the reader does not throw: it splits the frame on field delimiters, ignores the checksum entirely, and hands a partially corrupted 11 (Checkout) message to the ILS handler, which dutifully checks out whatever item barcode survived the bit flip.

Both flavours show up in the wire log. A correct reader logs the computed checksum next to the received one so the mismatch is visible at a glance:

text
2026-07-16T14:22:07Z WARN  sip2.reader checksum_mismatch
  raw=b'11YN20260716    142207AObranch|AA21030001234567|AB31220000998877|AY3AZF2A1'
  seq_field=AY3  computed_az=F2A0  received_az=F2A1
2026-07-16T14:22:07Z WARN  sip2.reader sequence_desync
  expected_ay=3  received_ay=5  gap=2  last_good_ts=2026-07-16T14:22:04Z

The first line is a single flipped bit: the low nibble of the checksum arrived as 1 where the sender computed 0. The second line is worse — the sequence number jumped from an expected 3 to a received 5, meaning two messages between the device and the client went missing or arrived out of order. A reader that trusts the frame would commit a checkout under a stale, replayed, or interleaved sequence number, and the audit trail would never record that anything was wrong.

Root Cause

Two independent SIP2 mechanisms are failing, and they fail for different reasons.

The AZ checksum is a specific, non-optional integrity field. Its value is the 16-bit two’s-complement of the sum of every character in the message — including the two-character AZ field identifier itself — up to but not including the checksum’s four hex digits, rendered as four uppercase hex characters. The arithmetic is exact: sum the byte value of each character, take the low 16 bits, negate in two’s complement ((-total) & 0xFFFF), and format as %04X. A mismatch means one of three things went wrong. Either the sender and receiver disagree on the byte range being summed (a common bug is excluding the AZ identifier, or including the carriage return); or they disagree on the encoding (SIP2 is Latin-1 / code-page-based, and summing UTF-8 multi-byte sequences for an accented patron name yields a different total than summing the original single bytes); or the bytes were corrupted by line noise on the serial or serial-to-TCP path, flipping a bit that the checksum exists precisely to catch.

The AY sequence number is a single rotating digit, 0 through 9, that the client and server must keep in lockstep. When error detection is enabled, the client stamps each request with the next sequence number, and the ILS echoes that same digit in its response. The purpose is to bind a response to the request that caused it and to detect dropped frames. The desync in the log — expected 3, received 5 — means a message was lost or reordered on the wire, so the counter on one side advanced while the other did not. Because the field is a single digit, it wraps every ten messages, and a naive equality check will happily accept a stale response from ten frames ago. The counters are shared mutable state across a lossy channel, and any dropped message breaks the invariant that the two sides agree.

The correct posture, matching the fail-closed discipline used elsewhere in the SIP2 Protocol Integration for Self-Service Circulation contract, is to treat both checks as gates: a frame that fails either one is not data, it is a defect to be quarantined.

Solution

Build the checksum and sequence logic as small, typed, side-effect-free functions, then wrap them in a gate that raises explicit exceptions the transport layer can catch and route. The checksum verifier recomputes the value over the exact documented byte range and compares; the sequence tracker holds the expected digit and decides whether a received digit is in lockstep, a recoverable gap, or a wrap-around match.

python
from __future__ import annotations

import logging

logger = logging.getLogger("sip2.reader")

CHECKSUM_LEN = 4  # four uppercase hex digits after the "AZ" identifier


class ChecksumError(ValueError):
    """Raised when a frame's AZ checksum does not verify."""


class SequenceError(ValueError):
    """Raised when a frame's AY sequence number is out of lockstep."""


def compute_checksum(payload: bytes) -> str:
    """Return the SIP2 AZ checksum for *payload*.

    ``payload`` is the message up to and including the two-character ``AZ``
    identifier, but excluding the four checksum hex digits and any trailing
    carriage return. The result is the 16-bit two's-complement of the sum of
    every byte, rendered as four uppercase hex characters.
    """
    total = sum(payload) & 0xFFFF
    return f"{(-total) & 0xFFFF:04X}"


def verify_checksum(frame: bytes) -> None:
    """Recompute and compare the AZ checksum, raising on mismatch.

    Operates on raw bytes so the sum matches the on-the-wire encoding exactly;
    decoding to str first would corrupt the total for non-ASCII field data.
    """
    marker = frame.rfind(b"AZ")
    if marker == -1 or len(frame) - marker < 2 + CHECKSUM_LEN:
        raise ChecksumError("frame has no AZ checksum field")
    covered = frame[: marker + 2]                       # include the "AZ" bytes
    received = frame[marker + 2 : marker + 2 + CHECKSUM_LEN].decode("ascii")
    computed = compute_checksum(covered)
    if computed != received.upper():
        logger.warning(
            "checksum_mismatch",
            extra={"computed_az": computed, "received_az": received},
        )
        raise ChecksumError(f"computed {computed} != received {received}")


class SequenceTracker:
    """Track the rotating 0-9 AY sequence digit shared with the server.

    ``check`` returns normally when the received digit is the expected one,
    tolerates a small forward gap by resynchronising, and raises otherwise so
    the caller can request retransmission or reset the channel.
    """

    def __init__(self, max_gap: int = 1) -> None:
        self._expected: int = 0
        self._max_gap = max_gap

    def next_outbound(self) -> int:
        """Return the digit to stamp on the next request, then advance."""
        seq = self._expected
        self._expected = (self._expected + 1) % 10
        return seq

    def check(self, received: int) -> None:
        expected = self._expected
        gap = (received - expected) % 10
        if received == expected:
            self._expected = (expected + 1) % 10
            return
        if gap <= self._max_gap:
            logger.warning(
                "sequence_gap_resync",
                extra={"expected_ay": expected, "received_ay": received, "gap": gap},
            )
            self._expected = (received + 1) % 10
            return
        logger.warning(
            "sequence_desync",
            extra={"expected_ay": expected, "received_ay": received, "gap": gap},
        )
        raise SequenceError(f"expected {expected}, received {received}")

With the primitives in place, the gate composes them and lets the transport decide what to do with a rejected frame — the key behavioural change is that a corrupt frame produces a typed exception rather than a silent commit or an unhandled crash:

python
def accept_frame(frame: bytes, tracker: SequenceTracker) -> bytes:
    """Validate a raw SIP2 frame, returning it only if both gates pass.

    Raises ChecksumError or SequenceError so the caller can quarantine the
    frame and request retransmission instead of dispatching it to the ILS.
    """
    verify_checksum(frame)
    marker = frame.rfind(b"AY")
    if marker != -1:
        tracker.check(int(frame[marker + 2 : marker + 3]))
    logger.info("frame_accepted", extra={"length": len(frame)})
    return frame

Before this change, the reader split on | and trusted the result; a flipped bit in the item barcode became a real checkout. After it, the same frame raises ChecksumError, is never dispatched, and the device is asked to resend. See the parent SIP2 Protocol Integration for Self-Service Circulation guide for how accept_frame slots into the request/response loop and where retransmission requests are issued.

Compliance or Privacy Impact

The instinct when a frame fails to verify is to drop it and move on. Do not. A silently dropped frame is an integrity event that vanished, and in a circulation system integrity events are exactly what an audit needs to see. A corrupt 11 checkout that was rejected still means a patron stood at a device and something went wrong; a desynced sequence may be the visible edge of a replay or an interleaving between two concurrent sessions on the same line.

Quarantine the corrupt frame rather than dropping it. Route the raw bytes, the computed and received checksums, and the expected and received sequence digits to a durable quarantine store, and emit one audit event per rejection into the same append-only trail the rest of the circulation stack writes to. Because the raw frame can carry a patron barcode or name, treat the quarantine record as patron data: apply the same redaction and access controls described in PII Masking in Patron Data Exports before the quarantined bytes are surfaced in any report or shared with a device vendor for diagnosis. A quarantined frame that is later shown to be a benign single-bit error can be discarded on the retention schedule; one that recurs is evidence, and a complete audit trail is what turns a vague “the kiosk was acting up” into a provable sequence of rejected frames with timestamps.

The related failure mode where a valid but duplicated frame is replayed is handled separately — see Deduplicating Replayed SIP2 Checkout Messages — but both patterns feed the same quarantine and audit sink, so build that sink once.

Verification

Confirm the checksum math against a known-good frame and assert that both gates raise rather than pass on corrupted input.

python
import pytest


def test_checksum_roundtrips_on_known_good_frame() -> None:
    covered = b"11YN20260716    142207AObranch|AY3AZ"
    frame = covered + compute_checksum(covered).encode("ascii")
    verify_checksum(frame)  # must not raise


def test_flipped_bit_is_rejected() -> None:
    covered = b"11YN20260716    142207AObranch|AY3AZ"
    good = compute_checksum(covered)
    # Corrupt one hex digit of the checksum to simulate line noise.
    bad = good[:-1] + ("0" if good[-1] != "0" else "1")
    with pytest.raises(ChecksumError):
        verify_checksum(covered + bad.encode("ascii"))


def test_sequence_desync_raises_but_small_gap_resyncs() -> None:
    tracker = SequenceTracker(max_gap=1)
    tracker.check(0)          # in lockstep, advances to 1
    tracker.check(2)          # gap of 1 (from expected 1), resyncs to 3
    with pytest.raises(SequenceError):
        tracker.check(7)      # gap of 4 from expected 3 — desynced


def test_wraparound_is_not_mistaken_for_a_gap() -> None:
    tracker = SequenceTracker(max_gap=1)
    for _ in range(9):
        tracker.next_outbound()
    # expected is now 9; a received 9 must be accepted, wrapping to 0.
    tracker.check(9)
    tracker.check(0)

For a running device fleet, track two counters over time: checksum_mismatch and sequence_desync per device. A single mismatch is line noise and is expected; a rising rate on one unit points at a failing cable, a flaky serial-to-TCP bridge, or an encoding mismatch introduced by a firmware update. A sequence_desync rate that climbs under load is your signal that frames are being dropped faster than the max_gap tolerance absorbs, and that the channel needs a reset rather than a resync. Alert on the rate, not the single event, and keep the raw quarantined frames long enough to diagnose the pattern.