Generating ARCOS Fixed-Width Transaction Files

Build byte-exact ARCOS fixed-width transaction records in Python — field widths, zero and space padding, justification, and truncation guards with a hash

A fixed-width file has no delimiters to save you. If a single field is one byte too wide, every column to its right shifts, and the DEA’s ARCOS processor either rejects the batch or — worse — silently reads a quantity where it expected a date. This is the specific failure mode that makes ARCOS serialization unforgiving: correctness is not “the values are right,” it is “every value occupies exactly its column range, padded on the correct side, truncated nowhere.” This recipe builds byte-exact ARCOS transaction records in Python, validates that each is the mandated width, and hashes the file for the audit trail. It implements the serialization step deferred by the parent topic, ARCOS Transaction Reporting, where the reportable events are selected and assembled; here we turn assembled records into the bytes ARCOS ingests.

The problem decomposes into three deterministic questions for every field: how wide is it, which side does it pad on, and what happens when the value is too long. Answer all three per field and the record is correct by construction; leave any one to chance and the batch fails inspection.

Fixed-width ARCOS record layout showing each field's column span, justification, and pad character A horizontal ruler represents one fixed-width ARCOS transaction record divided into seven contiguous fields with no delimiters. From left to right: reporter DEA number occupying nine columns, left-justified and space-padded; action indicator one column; transaction code two columns, left-justified space-padded; NDC eleven columns, right-justified zero-padded; quantity ten columns, right-justified zero-padded; associated registrant DEA nine columns, left-justified space-padded; and transaction date eight columns in YYYYMMDD form. Below the ruler a note states that numeric fields pad with zeros on the left and text fields pad with spaces on the right, and that any value exceeding its width is a hard error rather than a silent truncation. Reporter DEA Act Code NDC Quantity Assoc. DEA Date w=9 · left 1 2 · left w=11 · right w=10 · right w=9 · left w=8 space-pad zero-pad zero-pad space-pad YYYYMMDD one fixed-width record · no delimiters numeric → left zero-pad · text → right space-pad a value wider than its field is a hard error, never a silent truncation a one-byte overflow shifts every field to its right — the whole record is misread

Prerequisites & Environment

  • Python 3.11+ — the implementation uses dataclasses(frozen=True), enum.Enum, and the union-type syntax.
  • Standard library only: hashlib, logging, datetime, dataclasses, enum. Add pytest only for the test block.
  • Encoding: ARCOS records are ASCII. The serializer encodes to strict ASCII so a stray non-ASCII byte fails loudly rather than widening a field.
  • Regulatory context you should already hold: 21 CFR § 1304.33 (ARCOS periodic transaction reporting) and 21 CFR § 1304.04 (two-year retrievable retention of the filing). The upstream selection and record assembly — which events are reportable and how they map to transaction codes — belong to the parent topic and are assumed done. Each record’s NDC must already be the canonical 11-digit identity produced by NDC parsing regex patterns for Python, because the serializer zero-pads on the left and would happily mask a lost leading zero.

Implementation: A Frozen Field Spec and a Fixed-Width Serializer

The design separates the layout (a frozen field specification: name, width, justification, pad character) from the serializer (a pure function that applies the spec). The spec is data, so it is auditable and testable on its own; the serializer is small and total. Every field declares whether it right-justifies with zeros (numeric) or left-justifies with spaces (text), and the serializer raises on any value that would exceed its width — the truncation hazard is converted into a hard failure.

python
from __future__ import annotations

import hashlib
import logging
from dataclasses import dataclass
from datetime import date
from enum import Enum

logger = logging.getLogger("pharmacy.reporting.arcos.fixedwidth")


class Justify(str, Enum):
    LEFT = "LEFT"     # text: pad on the right
    RIGHT = "RIGHT"   # numeric: pad on the left


@dataclass(frozen=True)
class Field:
    """One fixed-width column. Immutable so the layout cannot drift at runtime."""

    name: str
    width: int
    justify: Justify
    pad: str          # single character: " " for text, "0" for numeric


# The ARCOS record layout, left to right. Widths are illustrative of the ARCOS
# fixed-width discipline; confirm exact widths against the current DEA ARCOS
# Registrant Handbook before production filing.
ARCOS_LAYOUT: tuple[Field, ...] = (
    Field("reporter_dea",    9,  Justify.LEFT,  " "),
    Field("action",          1,  Justify.LEFT,  " "),
    Field("transaction_code", 2, Justify.LEFT,  " "),
    Field("ndc_11",          11, Justify.RIGHT, "0"),
    Field("quantity",        10, Justify.RIGHT, "0"),
    Field("associated_dea",  9,  Justify.LEFT,  " "),
    Field("transaction_date", 8, Justify.RIGHT, "0"),  # YYYYMMDD
)

RECORD_WIDTH = sum(f.width for f in ARCOS_LAYOUT)  # 50


class ArcosFieldError(ValueError):
    """Raised when a value cannot fit its field — never truncate silently."""


@dataclass(frozen=True)
class ArcosTransaction:
    reporter_dea: str
    action: str
    transaction_code: str
    ndc_11: str
    quantity: int            # implied-decimal integer units
    associated_dea: str
    transaction_date: date


def _fit(field: Field, raw: str) -> str:
    """Pad `raw` to exactly field.width, or raise if it overflows."""
    if len(raw) > field.width:
        # A one-byte overflow would shift every downstream field — refuse it.
        raise ArcosFieldError(
            f"field '{field.name}' value width {len(raw)} exceeds {field.width}"
        )
    if field.justify is Justify.RIGHT:
        return raw.rjust(field.width, field.pad)
    return raw.ljust(field.width, field.pad)


def serialize_record(txn: ArcosTransaction) -> bytes:
    """Render one transaction to a byte-exact fixed-width ASCII record."""
    raw_values = {
        "reporter_dea": txn.reporter_dea,
        "action": txn.action,
        "transaction_code": txn.transaction_code,
        # Digits only — a hyphen here would consume a column and shift the record.
        "ndc_11": txn.ndc_11,
        "quantity": str(txn.quantity),
        "associated_dea": txn.associated_dea,
        "transaction_date": f"{txn.transaction_date:%Y%m%d}",
    }
    line = "".join(_fit(f, raw_values[f.name]) for f in ARCOS_LAYOUT)
    if len(line) != RECORD_WIDTH:
        raise ArcosFieldError(f"assembled width {len(line)} != {RECORD_WIDTH}")
    # Strict ASCII: a non-ASCII byte would widen the record — fail loudly.
    return line.encode("ascii")


def serialize_batch(transactions: list[ArcosTransaction]) -> tuple[bytes, str]:
    """Concatenate records and return (file_bytes, sha256_hex) for the audit trail."""
    records = [serialize_record(t) for t in transactions]
    body = b"\n".join(records) + (b"\n" if records else b"")
    audit_hash = hashlib.sha256(body).hexdigest()
    logger.info(
        "arcos_file_serialized records=%d bytes=%d audit_hash=%s",
        len(records), len(body), audit_hash,
    )
    return body, audit_hash


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    txns = [
        ArcosTransaction("PM1234567", "A", "P", "00093005801", 500, "PM9988776", date(2026, 7, 10)),
        ArcosTransaction("PM1234567", "D", "S", "00093005801", 120, "PB5544332", date(2026, 7, 12)),
    ]
    body, audit_hash = serialize_batch(txns)
    for line in body.decode("ascii").splitlines():
        print(f"[{line}] len={len(line)}")
    print("audit_hash:", audit_hash)

The _fit helper is the whole safety story: it pads to the exact width when the value fits and raises ArcosFieldError the moment it does not, so a too-long DEA number or an over-range quantity stops the batch instead of corrupting it. Because ARCOS_LAYOUT is a frozen tuple of frozen Field objects, the layout is data an auditor can read and a test can assert against.

Verification & Testing

Two things must be proven: that every serialized record is exactly RECORD_WIDTH bytes with each field in its declared column, and that an overflowing value raises rather than truncates. The block below asserts exact widths, verifies zero- and space-padding on representative fields, and confirms the overflow guard fires.

python
import pytest
from datetime import date


def _sample() -> ArcosTransaction:
    return ArcosTransaction("PM1234567", "A", "P", "00093005801", 500, "PM9988776", date(2026, 7, 10))


def test_record_is_exact_width():
    rec = serialize_record(_sample())
    assert len(rec) == RECORD_WIDTH == 50


def test_numeric_fields_zero_pad_left():
    rec = serialize_record(_sample()).decode("ascii")
    # NDC occupies columns [12:23) after reporter(9)+action(1)+code(2)=12.
    assert rec[12:23] == "00093005801"
    # Quantity 500 zero-pads to width 10, right-justified.
    assert rec[23:33] == "0000000500"


def test_text_fields_space_pad_right():
    rec = serialize_record(_sample()).decode("ascii")
    assert rec[0:9] == "PM1234567"          # exactly 9, no padding needed
    short = ArcosTransaction("PB12", "A", "P", "00093005801", 1, "PB99", date(2026, 7, 1))
    rec2 = serialize_record(short).decode("ascii")
    assert rec2[0:9] == "PB12     "         # left-justified, space-padded to 9


def test_overflow_raises_never_truncates():
    bad = ArcosTransaction("PM1234567890", "A", "P", "00093005801", 1, "PB99", date(2026, 7, 1))
    with pytest.raises(ArcosFieldError):
        serialize_record(bad)               # 12-char DEA cannot fit a 9-col field


def test_batch_hash_is_stable():
    body_a, hash_a = serialize_batch([_sample()])
    body_b, hash_b = serialize_batch([_sample()])
    assert body_a == body_b and hash_a == hash_b

A passing run emits a PHI-free structured audit line that binds the record count, byte length, and file hash — the provenance an inspection under 21 CFR § 1304.04 relies on:

text
INFO pharmacy.reporting.arcos.fixedwidth arcos_file_serialized \
     records=2 bytes=102 audit_hash=4e8f...b93c

Re-running serialize_batch on the same transactions reproduces both the bytes and the audit_hash; a mismatch against a stored hash proves the file was altered after generation, which is exactly the tamper signal the audit trail exists to surface.

Gotchas & Compliance Pitfalls

  • A one-byte overflow shifts everything after it. Fixed-width has no delimiters, so a 10-character value in a 9-column field does not “spill” — it pushes every downstream field one column right and the ARCOS processor misreads the entire record. Always convert overflow into a hard error, as _fit does; never str[:width].
  • Leading-zero loss corrupts the NDC. If an NDC ever passed through an integer or a careless CSV reader, 00093005801 becomes 93005801 and the serializer will zero-pad it back to eleven digits at the wrong positions, producing a valid-looking but wrong identifier. Carry NDCs as strings end to end and normalize upstream with the parser referenced in the prerequisites.
  • Justification is per field type, not global. Numeric fields (NDC, quantity, date) right-justify and zero-pad; text fields (DEA numbers, codes) left-justify and space-pad. Reversing either — right-padding a DEA number with zeros, or left-padding a quantity with spaces — yields a structurally valid width but a semantically wrong record.
  • Encoding must be strict ASCII. A smart quote or accented character pasted into a code field can encode to multiple bytes under UTF-8, silently widening the record. Encoding with ascii (not utf-8) makes such contamination raise instead of shift.
  • Quantity decimals are implied, not literal. ARCOS quantity fields carry implied decimals; writing a literal . consumes a column and shifts the record. Represent quantity as an integer in the field’s implied-decimal convention and never emit a decimal point.
  • Do not hand-edit a rejected file. When ARCOS rejects a batch, fix the source data or the layout and regenerate, then re-hash. Editing the file by hand breaks the binding between the retained audit_hash and the ledger events, destroying the reproducibility that makes the filing defensible.

Frequently Asked Questions

Why not use a CSV or delimited format instead of fixed-width?

Because ARCOS ingests fixed-width records; the format is dictated by the DEA processor, not chosen. Fixed-width trades delimiters for exact column positions, which is why width and justification are correctness properties rather than style choices. The upside is that a byte-exact record is trivially reproducible and hashable for the audit trail.

What width should each ARCOS field actually be?

The widths in ARCOS_LAYOUT illustrate the fixed-width discipline — right-justified zero-padding for numeric fields, left-justified space-padding for text — but you must confirm the exact column specification against the current DEA ARCOS Registrant Handbook before production filing. The design keeps the layout as data precisely so updating a width is a one-line change with test coverage.

How does hashing the file help during a DEA inspection?

The SHA-256 hash binds the exact bytes of the submitted file to the retained audit record. Re-running the serializer over the same transactions reproduces the identical hash, so you can demonstrate that the file on record is the file that was filed. A mismatch is proof of post-generation alteration, satisfying the tamper-evidence expectation behind 21 CFR § 1304.04 retention.

What happens if a quantity is larger than the field allows?

The _fit guard raises ArcosFieldError rather than truncating, so an out-of-range quantity stops the batch instead of silently reporting a wrong number. That is the intended behavior: an unrepresentable quantity signals a data or unit error upstream that must be corrected at the source, after which the batch is regenerated cleanly.