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.
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
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
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
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
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.
Related
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.