Modeling Contributor Roles and Split Sheets

A single track credit like “produced and written by” hides a payout decision that a relational schema either gets right once or gets wrong on every subsequent statement run. Contributor-role modeling is the narrowest and most consequential decision inside Metadata Taxonomy Best Practices: whether a writer, composer, arranger, producer, or featured artist row belongs to the composition split or the master split determines which downstream ledger a percentage ever reaches. Get the role vocabulary loose and a producer point silently migrates into a mechanical royalty pool it was never entitled to.

Contributor Modeling Trade-offs

Approach Split Precision Schema Complexity Query Cost Royalty Impact
Free-text role string Low — “Producer”, “producer”, “Prod.” all coexist Minimal Cheap, no joins Duplicate or missing roles cause split-sum drift; producers can be double-paid or dropped from a statement
Enum role_type column, single split table Medium Low Fast, single table scan Cannot separate master vs. composition splits; a featured artist’s master share leaks into publishing calculations
Controlled vocabulary + separate composition_splits / master_splits tables High Moderate — two tables, shared contributor FK One join per split type Correct rights-domain isolation; a songwriter’s mechanical share and a session player’s master point never collide
Fully normalized: contributors, roles, works, recordings, split_sheets junction with role-scoped percentage constraints Highest High — five+ tables, referential integrity checks Slower without indexing, but auditable Enables per-territory, per-right-type split overrides without duplicating contributor identity; supports CISAC/DDEX role crosswalks

The controlled-vocabulary rows above are the ones that survive an audit. A role_type enum without split-table separation is the most common failure this reference sees repeated across catalogs already documented in Cross-Platform Catalog Matching.

Prerequisites & Assumptions

This implementation targets Python 3.11+, Pydantic v2 (pydantic>=2.6), and a Postgres-compatible warehouse reachable via SQLAlchemy 2.x or raw DuckDB for local validation. It assumes each work (composition) has a stable iswc where available and each recording (master) has a stable isrc, and that split sheets arrive as either DDEX ERN party blocks, CWR registration records, or manually keyed spreadsheets from a label’s business affairs team. Percentages are stored as Decimal — never float — to avoid rounding drift across thousands of statement lines.

Implementation

1. Define a controlled contributor-role vocabulary

Do not let role be free text. Model it as a closed enum with an explicit split-domain tag so a query can never accidentally sum a master-domain role into a composition-domain payout.

python
from enum import Enum


class SplitDomain(str, Enum):
    COMPOSITION = "composition"  # publishing / mechanical rights
    MASTER = "master"            # sound recording rights


class ContributorRole(str, Enum):
    WRITER = "writer"
    COMPOSER = "composer"
    ARRANGER = "arranger"
    LYRICIST = "lyricist"
    PRODUCER = "producer"
    FEATURED_ARTIST = "featured_artist"
    REMIXER = "remixer"
    SESSION_MUSICIAN = "session_musician"


ROLE_DOMAIN_MAP: dict[ContributorRole, SplitDomain] = {
    ContributorRole.WRITER: SplitDomain.COMPOSITION,
    ContributorRole.COMPOSER: SplitDomain.COMPOSITION,
    ContributorRole.ARRANGER: SplitDomain.COMPOSITION,
    ContributorRole.LYRICIST: SplitDomain.COMPOSITION,
    ContributorRole.PRODUCER: SplitDomain.MASTER,
    ContributorRole.FEATURED_ARTIST: SplitDomain.MASTER,
    ContributorRole.REMIXER: SplitDomain.MASTER,
    ContributorRole.SESSION_MUSICIAN: SplitDomain.MASTER,
}

A producer who also co-writes a topline must appear as two separate contributor rows, one per domain, each with its own role_type and percentage. Collapsing them into one row is the single most common cause of composition/master split bleed.

2. Model the relational schema for split sheets

Separate works (compositions) from recordings (masters) at the table level, and hang splits off a shared contributors identity table so the same person can hold both a writer share and a producer point without duplicating their payee record.

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


class Contributor(BaseModel):
    contributor_id: str
    legal_name: str
    ipi_number: str | None = None  # required for composition-domain payees
    payee_id: str


class SplitEntry(BaseModel):
    contributor_id: str
    role_type: ContributorRole
    right_share_pct: Decimal = Field(gt=Decimal("0"), le=Decimal("100"))
    territory_code: str = Field(default="WW", pattern=r"^[A-Z]{2}$|^WW$")


class SplitSheet(BaseModel):
    work_or_recording_id: str
    split_domain: SplitDomain
    entries: list[SplitEntry]

    @model_validator(mode="after")
    def validate_domain_consistency(self) -> "SplitSheet":
        for entry in self.entries:
            expected = ROLE_DOMAIN_MAP[entry.role_type]
            if expected != self.split_domain:
                raise ValueError(
                    f"role {entry.role_type} belongs to {expected}, "
                    f"not {self.split_domain}"
                )
        return self

The relational shape backing this model is works and recordings as parent tables, each with a one-to-many split_sheets row keyed by split_domain, and each split_sheet holding many split_entries that foreign-key into a shared contributors table. This mirrors how CISAC and DDEX separate publishing party blocks from sound-recording party blocks, so a crosswalk into DDEX ERN 4.2 Implementation Guide party structures requires no remodeling.

3. Enforce split-sum integrity per domain

python
from collections import defaultdict


def assert_split_sums(sheet: SplitSheet) -> None:
    territory_totals: dict[str, Decimal] = defaultdict(Decimal)
    for entry in sheet.entries:
        territory_totals[entry.territory_code] += entry.right_share_pct
    for territory_code, total in territory_totals.items():
        if total != Decimal("100"):
            raise ValueError(
                f"{sheet.split_domain} split for territory "
                f"{territory_code} sums to {total}, expected 100"
            )

Run this assertion at ingestion time and again immediately before any statement-generation batch, since split sheets are frequently amended mid-catalog-life and a stale cached sum will silently under- or over-pay every contributor on the sheet.

Verification & Validation

Validate every split sheet against three checks before it reaches a payout run: the domain-consistency check embedded in SplitSheet.validate_domain_consistency, the per-territory sum assertion in assert_split_sums, and an identity check confirming every contributor_id resolves to exactly one payee_id (a missing or duplicate payee mapping is the most common cause of returned or unclaimed payments). Log the resolved split-sum total and territory count to the append-only audit log for every sheet processed, keyed by work_or_recording_id and a content hash of the serialized entries, so any later amendment is diffable against the original.

Edge Cases & Gotchas

A contributor credited only by stage name with no ipi_number cannot be safely routed into a composition-domain split; quarantine these rows rather than guessing an identity match. Split sheets that total 99.99% or 100.01% due to repeating-decimal percentages (a common artifact of dividing 100 by three writers) should be caught by the sum assertion rather than rounded silently — round only at the final payout-calculation step, never in the stored split sheet. Watch for a featured artist who is contractually a master-domain-only credit but who a careless CSV import tags with a composition role_type; the domain-consistency validator exists specifically to catch this. Finally, territory-scoped split overrides (a composition split that differs in one territory due to a local sub-publishing deal) must never be merged into the worldwide WW row — keep them as distinct SplitEntry records or the override will be lost on the next re-import.

Once contributor roles and split domains are correctly separated, the next reconciliation question is usually how those same works get deduplicated across DSP catalogs, covered in Cross-Platform Catalog Matching, and how the underlying taxonomy that this schema depends on is governed in Metadata Taxonomy Best Practices.