CWR Registration and Work Matching for Publishing Royalties

Registering a composition with a Performing Rights Organization or a mechanical rights society is not a metadata footnote — it is the transaction that anchors every future royalty payment to a specific ISWC, writer share, and publisher administration chain. The CISAC Common Works Registration (CWR) format is the fixed-width EDI standard that publishers, sub-publishers, and administrators use to submit new works, revise existing registrations, and receive back society acknowledgements. Within the Core Royalty Architecture & Metadata Standards reference, CWR sits upstream of nearly every split calculation: get one transaction record wrong and the error propagates through every statement that work generates for years.

CWR Registration and Work Matching Five stages: Work Metadata, CWR Build, Submit to PRO, ACK Match, ISWC Return, with a revision feedback loop. Work Metadata writers · pubs CWR Build NWR · SPU Submit to PRO transmission ACK Match status codes ISWC Return registered unmatched / rejected → REV revision resubmission

The CWR Transaction Envelope and Record Types

A CWR file is a flat sequence of fixed-width lines, each identified by a three-character record type in the first columns. The envelope layers are strict: a single HDR (Transmission Header) record opens the file, identifying the sender, receiver, and creation timestamp; one or more GRH/GRT (Group Header/Trailer) pairs bracket batches of transactions of the same type; and a single TRL (Transmission Trailer) closes the file with control totals for the whole submission.

Inside a group, each work is a transaction that opens with either NWR (New Works Registration) or REV (Revised Registration) — structurally identical records distinguished only by transaction type, but semantically very different downstream. Nested beneath the transaction header come the detail records: SPU/OPU (Publisher controlled by the submitter / other publisher), SWR/OWR (Writer controlled by the submitter / other writer), PWR (the link record that ties a writer to the publisher administering their share), ALT (alternate titles), PER (performing artist), and REC (recording detail for linked audio). Every detail record carries a transaction sequence number and record sequence number pair, which is what lets a parser reassemble a work from a flat stream of lines without ever seeing an XML tree.

Python ETL Architecture for CWR Ingestion

CWR files from a mid-size catalog can run into hundreds of thousands of lines, so line-by-line streaming is the only viable ingestion pattern — loading a full submission into a list of strings before parsing defeats the purpose of a fixed-width format designed for sequential processing. A generator that yields one record at a time, keyed by its three-character type, keeps peak memory flat regardless of file size. Parsed records should immediately validate into typed objects; Pydantic v2 models with Field(pattern=...) constraints catch malformed IPI numbers, non-numeric share fields, and truncated lines before they reach a database transaction.

Because CWR carries no explicit ISWC in many NWR submissions — the society assigns or confirms it — the ingestion pipeline has to treat ISWC as a field that may arrive empty and get backfilled by a later ACK. That makes CWR ingestion a natural upstream partner to the matching logic described in ISRC to ISWC Mapping Workflows: the same composite-key and fuzzy-match tiers used to reconcile recordings against works apply almost unchanged to reconciling an incoming NWR against a catalog of already-registered works. Title and writer-name normalization should reuse the casing, diacritic-folding, and role-enumeration rules from Metadata Taxonomy Best Practices rather than reinventing them for CWR specifically — a work titled “Don’t Stop” and one titled “DONT STOP” must collapse to the same candidate before matching runs.

Step-by-Step Implementation

Step 1: Parsing the Transmission Header and Group Envelope

python
from dataclasses import dataclass
from typing import Iterator

@dataclass(frozen=True)
class CwrRecord:
    record_type: str
    raw: str

def stream_cwr_records(path: str) -> Iterator[CwrRecord]:
    with open(path, "r", encoding="ascii", errors="strict") as handle:
        for line in handle:
            line = line.rstrip("\n")
            if not line:
                continue
            yield CwrRecord(record_type=line[0:3], raw=line)

def read_header(records: Iterator[CwrRecord]) -> dict:
    hdr = next(records)
    if hdr.record_type != "HDR":
        raise ValueError(f"expected HDR as first record, got {hdr.record_type}")
    return {
        "sender_id": hdr.raw[3:12].strip(),
        "sender_name": hdr.raw[12:57].strip(),
        "creation_date": hdr.raw[57:65].strip(),
    }

Step 2: Extracting Work-Level Transactions and Their Detail Records

python
from pydantic import BaseModel, Field
from decimal import Decimal

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

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

class WorkTransaction(BaseModel):
    transaction_type: str = Field(pattern=r"^(NWR|REV)$")
    submitter_work_number: str
    work_title: str
    iswc: str | None = None
    publishers: list[PublisherShare] = []
    writers: list[WriterShare] = []

def parse_transaction(lines: list[CwrRecord]) -> WorkTransaction:
    head = lines[0]
    tx = WorkTransaction(
        transaction_type=head.raw[0:3],
        submitter_work_number=head.raw[19:33].strip(),
        work_title=head.raw[33:93].strip(),
        iswc=head.raw[93:104].strip() or None,
    )
    for rec in lines[1:]:
        if rec.record_type in ("SPU", "OPU"):
            tx.publishers.append(PublisherShare(
                ipi_number=rec.raw[27:38].strip().lstrip("0").zfill(9),
                publisher_name=rec.raw[38:83].strip(),
                right_share_pct=Decimal(rec.raw[104:109]) / Decimal(1000),
            ))
        elif rec.record_type in ("SWR", "OWR"):
            tx.writers.append(WriterShare(
                ipi_number=rec.raw[86:97].strip().lstrip("0").zfill(9),
                writer_last_name=rec.raw[8:33].strip(),
                right_share_pct=Decimal(rec.raw[97:102]) / Decimal(1000),
            ))
    return tx

Step 3: Matching Incoming Works to Registered ISWCs

python
import duckdb

def match_work_to_iswc(tx: WorkTransaction, con: duckdb.DuckDBPyConnection) -> str | None:
    if tx.iswc:
        row = con.execute(
            "SELECT iswc FROM registered_works WHERE iswc = ?", [tx.iswc]
        ).fetchone()
        if row:
            return row[0]
    writer_ipis = [w.ipi_number for w in tx.writers]
    candidate = con.execute(
        """
        SELECT iswc FROM registered_works
        WHERE normalized_title = ? AND primary_writer_ipi = ANY(?)
        """,
        [tx.work_title.upper().strip(), writer_ipis],
    ).fetchone()
    return candidate[0] if candidate else None

Step 4: Distinguishing REV from NWR and Reconciling the ACK Round-Trip

python
def apply_transaction(tx: WorkTransaction, con: duckdb.DuckDBPyConnection) -> None:
    if tx.transaction_type == "NWR":
        con.execute(
            "INSERT INTO registered_works (submitter_work_number, work_title, iswc) "
            "VALUES (?, ?, ?) ON CONFLICT (submitter_work_number) DO NOTHING",
            [tx.submitter_work_number, tx.work_title, tx.iswc],
        )
    else:  # REV
        existing = con.execute(
            "SELECT 1 FROM registered_works WHERE submitter_work_number = ?",
            [tx.submitter_work_number],
        ).fetchone()
        if existing is None:
            raise ValueError(f"REV for unknown work {tx.submitter_work_number}")
        con.execute(
            "UPDATE registered_works SET work_title = ?, iswc = COALESCE(?, iswc) "
            "WHERE submitter_work_number = ?",
            [tx.work_title, tx.iswc, tx.submitter_work_number],
        )

def parse_ack(records: Iterator[CwrRecord]) -> dict[str, str]:
    """Maps submitter_work_number -> assigned ISWC from an ACK/AKN record pair."""
    results: dict[str, str] = {}
    for rec in records:
        if rec.record_type == "ACK":
            work_number = rec.raw[19:33].strip()
            status = rec.raw[104:106].strip()
            if status == "AS":  # accepted
                iswc = rec.raw[106:117].strip()
                if iswc:
                    results[work_number] = iswc
    return results

Reconciliation Gates & Validation

Before a parsed transaction is allowed to update the registered-works table, three gates must pass. First, a schema gate rejects any transaction whose detail records fall outside expected cardinality — a work with zero SWR/OWR records, or a PWR that references a publisher IPI not present among that transaction’s SPU records, is quarantined rather than applied. Second, a split-sum gate uses decimal.Decimal arithmetic (never floats) to assert that publisher shares sum to exactly 100.00% and writer shares sum to exactly 100.00% independently, since CWR tracks the two pools separately. Third, an ISWC-consistency gate checks that an incoming REV never silently overwrites an existing ISWC with a blank value — a common bug in poorly configured submitter software — by only accepting ISWC updates that add or correct a value, never null one out.

Failure Modes & Rollback

CWR ingestion failures tend to fall into two patterns: a batch that parses cleanly but fails split-sum validation en masse (usually a submitter-side encoding bug), and a batch whose ACK comes back with an unexpectedly high rejection rate. Both should trip a circuit breaker that halts further application of that batch’s transactions once the rejection or quarantine rate crosses a threshold (5% is a reasonable default for a mature pipeline). Every write against registered_works should be an idempotent UPSERT keyed on submitter_work_number, so a batch can be safely replayed after a fix without creating duplicate registrations. Before applying a large REV batch, snapshot the affected rows so an operator can restore the prior state in one statement if a downstream royalty run reveals the revision was itself malformed.

Security & Audit Trail

CWR transactions carry IPI numbers, legal names, and banking-adjacent administration data for writers and publishers, all of which should be encrypted at rest and restricted by role: only registration-desk staff should be able to submit NWR/REV transactions to a society, while royalty analysts should have read-only access to the resulting registered-works table. Following the boundary model in Security Boundaries for Royalty Data, every applied transaction and its matching ACK response should be written to an append-only log rather than mutated in place, with each batch hashed on ingestion so a later audit can prove which CWR file produced which registered-works change — the pattern is detailed further in the Royalty Audit Trail Reference.

Work registration only closes the loop once metadata that starts as a CWR transaction can be recognized again on the way back out as a file; Generating CWR 2.1 Files from Work Metadata covers the reverse direction, serializing normalized work and split metadata into a submittable file. Both directions depend on the same normalization and matching discipline used across the Core Royalty Architecture & Metadata Standards reference, and teams building either side should keep their ISWC matching logic consistent with the recording-level matching used in ISRC to ISWC workflows.