Generating CWR 2.1 Files from Work Metadata

Serializing a work record into a CWR 2.1 submission is where royalty metadata stops being a database row and becomes a legally significant filing: field widths, record ordering, and control totals are all validated mechanically by the receiving society, and one misaligned column can bounce an entire transaction group back unprocessed. Building on the transaction model covered in CWR Registration and Work Matching for Publishing Royalties, this walks through generating a CWR 2.1 file directly from normalized work, writer, and publisher metadata in Python, with the arithmetic guarantees a distributor actually needs — split percentages that sum to exactly 100.00%, deterministic record ordering, and control counts that match what was written to disk.

CWR 2.1 vs 2.2 vs 3.0 Trade-offs

Version Format Share Field Precision ISWC at Submission Adoption Today Royalty Impact
CWR 2.1 Fixed-width flat file 5-digit implied-decimal field (10000 = 100.000%) Optional; society assigns or confirms it back via ACK Still required by several mechanical societies and smaller PROs Widest compatibility, but every writer and publisher share depends on a hand-maintained record layout — a single offset bug misallocates shares across the whole batch until an ACK rejection surfaces it
CWR 2.2 Fixed-width flat file (superset of 2.1) Same implied-decimal fields, adds territory and right-type detail records Strongly encouraged; most societies pre-validate against it De facto standard at ASCAP, BMI, PRS, and GEMA Fewer post-registration ISWC corrections, but the added territory/right-type records must exist in your model or splits validate against the wrong right entirely
CWR 3.0 / 3.1 XML, CWR-DDEX aligned Explicit decimal element, no implied-decimal parsing Effectively required for straight-through processing Growing among major publishers, not yet universal Eliminates an entire class of column-offset bugs, but requires XML schema validation infrastructure most legacy CWR pipelines don’t have running yet

Prerequisites & Assumptions

This implementation targets Python 3.11+ with pydantic>=2.5 for the in-memory work model and the standard-library decimal module for every share calculation — floats are never acceptable here, since a binary rounding error of even one cent per share is exactly the kind of drift a society’s intake validator is built to catch. It assumes work, writer, and publisher metadata has already passed through the normalization pass described in Metadata Taxonomy Best Practices — consistent casing, resolved IPI numbers, and a single canonical writer-role code per contributor — so the serializer’s only remaining job is arithmetic and column placement, not data cleanup. No actively maintained third-party CWR 2.1 library exists for Python, so the serializer below is built directly against the record layout rather than wrapping a dependency.

Implementation

Step 1: Modeling Work Metadata with Guaranteed Split Sums

python
from decimal import Decimal, ROUND_HALF_UP
from pydantic import BaseModel, Field, model_validator

class PublisherSplit(BaseModel):
    ipi_number: str = Field(pattern=r"^\d{9,11}$")
    publisher_name: str
    right_share_pct: Decimal

class WriterSplit(BaseModel):
    ipi_number: str = Field(pattern=r"^\d{9,11}$")
    writer_last_name: str
    right_share_pct: Decimal

class WorkMetadata(BaseModel):
    submitter_work_number: str = Field(max_length=14)
    work_title: str = Field(max_length=60)
    iswc: str | None = None
    publishers: list[PublisherSplit]
    writers: list[WriterSplit]

    @model_validator(mode="after")
    def splits_sum_to_100(self) -> "WorkMetadata":
        pub_total = sum((p.right_share_pct for p in self.publishers), Decimal(0))
        wtr_total = sum((w.right_share_pct for w in self.writers), Decimal(0))
        if pub_total != Decimal("100.00"):
            raise ValueError(f"publisher shares sum to {pub_total}, expected 100.00")
        if wtr_total != Decimal("100.00"):
            raise ValueError(f"writer shares sum to {wtr_total}, expected 100.00")
        return self

Step 2: Serializing Fixed-Width HDR, GRH, NWR, SPU, SWR, and PWR Records

python
def implied_decimal(pct: Decimal, width: int = 5) -> str:
    scaled = (pct * 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
    return str(int(scaled)).zfill(width)

def build_hdr(sender_id: str, sender_name: str, creation_date: str) -> str:
    return "HDR" + sender_id.ljust(9) + sender_name.ljust(45) + creation_date.ljust(8)

def build_grh(transaction_type: str, group_id: int) -> str:
    return "GRH" + transaction_type.ljust(3) + str(group_id).zfill(5)

def build_nwr(work: WorkMetadata, transaction_seq: int) -> str:
    return (
        "NWR"
        + str(transaction_seq).zfill(8)
        + work.submitter_work_number.ljust(14)
        + work.work_title.ljust(60)
        + (work.iswc or "").ljust(11)
    )

def build_spu(publisher: PublisherSplit, seq: int) -> str:
    return (
        "SPU"
        + str(seq).zfill(8)
        + publisher.ipi_number.zfill(11)
        + publisher.publisher_name.ljust(45)
        + implied_decimal(publisher.right_share_pct)
    )

def build_swr(writer: WriterSplit, seq: int) -> str:
    return (
        "SWR"
        + str(seq).zfill(8)
        + writer.ipi_number.zfill(11)
        + writer.writer_last_name.ljust(25)
        + implied_decimal(writer.right_share_pct)
    )

def build_pwr(publisher: PublisherSplit, writer: WriterSplit, seq: int) -> str:
    return (
        "PWR"
        + str(seq).zfill(8)
        + publisher.ipi_number.zfill(11)
        + writer.ipi_number.zfill(11)
    )

Step 3: Computing Control Counts and Assembling the GRT/TRL Trailers

python
def build_grt(group_id: int, transaction_count: int, record_count: int) -> str:
    return (
        "GRT"
        + str(group_id).zfill(5)
        + str(transaction_count).zfill(8)
        + str(record_count).zfill(8)
    )

def build_trl(group_count: int, transaction_count: int, record_count: int) -> str:
    return (
        "TRL"
        + str(group_count).zfill(5)
        + str(transaction_count).zfill(8)
        + str(record_count).zfill(8)
    )

def serialize_work(work: WorkMetadata, transaction_seq: int) -> list[str]:
    lines = [build_nwr(work, transaction_seq)]
    seq = 1
    for publisher in work.publishers:
        lines.append(build_spu(publisher, seq)); seq += 1
    for writer in work.writers:
        lines.append(build_swr(writer, seq)); seq += 1
        for publisher in work.publishers:
            lines.append(build_pwr(publisher, writer, seq)); seq += 1
    return lines

Step 4: Writing the File Atomically with a Deterministic Batch ID

python
import os
import tempfile
from datetime import datetime, timezone

def write_cwr_file(works: list[WorkMetadata], sender_id: str, sender_name: str, output_path: str) -> str:
    batch_id = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
    all_lines = [build_hdr(sender_id, sender_name, batch_id[:8]), build_grh("NWR", 1)]
    record_count = 0
    for i, work in enumerate(works, start=1):
        work_lines = serialize_work(work, i)
        all_lines.extend(work_lines)
        record_count += len(work_lines)
    all_lines.append(build_grt(1, len(works), record_count))
    all_lines.append(build_trl(1, len(works), record_count + 3))

    fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(output_path) or ".")
    try:
        with os.fdopen(fd, "w", encoding="ascii") as handle:
            handle.write("\n".join(all_lines) + "\n")
        os.replace(tmp_path, output_path)
    finally:
        if os.path.exists(tmp_path):
            os.remove(tmp_path)
    return batch_id

Verification & Validation

Before a generated file leaves the pipeline, re-parse it with the same record-boundary logic used on the ingestion side and assert byte-for-byte equality between the recovered WorkMetadata objects and the source data — a round trip that fails is far cheaper to catch locally than as an ACK rejection days later. Assert that every serialized SPU/SWR share field, once divided back by 1000, reproduces the original Decimal value exactly; any drift indicates a rounding bug in implied_decimal. Track an ISWC-resolution rate across the batch (works submitted with a known ISWC divided by total works) as an operational metric, since a falling rate usually means an upstream matching regression rather than a serialization problem. Finally, log the batch_id, work count, and control totals to an append-only audit record before transmission, so a later dispute over what was actually submitted can be resolved from the log rather than from the society’s copy alone.

Edge Cases & Gotchas

Works without a confirmed ISWC at generation time should serialize the field as blank spaces rather than omitting it or inventing a placeholder value — padding it incorrectly is a common cause of otherwise-valid transactions being flagged. Floating-point-style rounding drift shows up specifically when a work has an odd number of equal writer shares, such as three writers at 33.333%; naive rounding produces a sum of 99.999% or 100.002%, so the remainder should be allocated deterministically to a designated primary writer rather than distributed silently. CWR 2.1 is strictly ASCII, so writer and publisher names carrying diacritics need a transliteration step before serialization, not a UTF-8 passthrough that a society’s intake parser will reject. Record ordering is enforced, not advisory: a PWR record must follow both the SPU and SWR it links, and a file that emits SWR before its corresponding SPU will fail validation even though every individual record is well-formed. Finally, NULL or unassigned writer shares — common for works with an unresolved arranger credit — should route to a manual-review queue rather than defaulting to a 0.00 share, since a silent zero share is functionally indistinguishable from a correctly registered one until a royalty statement is disputed.

The serialization logic here is the mirror image of the matching and revision-handling process described in CWR Registration and Work Matching for Publishing Royalties, and teams validating ISWC assignments before they’re written into a submission should apply the same checks used in Validating ISWC Assignments for Publishing Splits.