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.
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:
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.
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:
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.
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.
Related
- SIP2 Protocol Integration for Self-Service Circulation — the parent guide to the SIP2 message contract, request/response loop, and where these gates attach.
- Circulation Protocols & Interoperability — the wider architecture covering SIP2, NCIP, and self-check circulation sync.
- Deduplicating Replayed SIP2 Checkout Messages — handling valid frames that arrive more than once, feeding the same quarantine sink.
- PII Masking in Patron Data Exports — redaction controls to apply before quarantined frames carrying patron data are surfaced.