How to Map ISCI and Ad-ID Codes to House Numbers

Every commercial that airs carries at least two identities that a traffic system must reconcile: the industry code the agency ships (a legacy 8-character ISCI slug or a modern 11–12 character Ad-ID), and the station’s own house number — the short internal key the automation, log, and billing systems actually schedule against. This guide solves one exact task: building a deterministic crosswalk that resolves an incoming ISCI or Ad-ID to a single, stable house number, with explicit collision handling so two different creatives can never collapse onto the same slot. It is the mapping procedure beneath Standardizing Billing Codes Across Traffic Systems, itself part of Broadcast Traffic Architecture & Taxonomy. Getting it wrong is not cosmetic: the house number is the join key that ties an as-run line back to an invoice, so a mapping that drifts or duplicates misbills the advertiser and leaves a public-inspection and SOC 2 trail that cannot be reproduced under audit.

The core requirement is that the crosswalk be deterministic and one-directional in resolution but reversible in lineage. The same Ad-ID presented tomorrow — possibly with different casing, a stray space, or a check-character variant — must resolve to the same house number, and that house number must always name exactly one creative. That rules out sequence counters that hand out the next free number by arrival order, in favour of a lookup-first, mint-deterministically-on-miss strategy validated against the canonical field contract in the traffic field data dictionary before anything is written downstream.

Prerequisites

  • Python 3.11+ — required for the X | None union syntax, enum.StrEnum, and timezone-aware datetime.now(timezone.utc) used throughout.
  • Pydantic v2 — pin pydantic==2.7.1; the crosswalk models use field_validator and model_validator to enforce ISCI and Ad-ID shape at the boundary.
  • A persistent house-number ledger — a table or key-value store that survives restarts, so mappings are stable across runs. Pin your driver exactly (psycopg[binary]==3.2.1 or redis==5.0.4); an in-memory dict is fine only for tests.
  • Read access to the agency traffic instructions — the source of the ISCI/Ad-ID, delivered as a mounted export or an API pull scoped to read-only.
  • Upstream normalization applied — billing codes standardized per Standardizing Billing Codes Across Traffic Systems so the crosswalk resolves identity, not field structure.
  • A house-number namespace — a station or market prefix (for example WXYZ) held in config so two stations never mint colliding house numbers from the same Ad-ID.

Step-by-Step Implementation

The crosswalk runs as a pure boundary function per creative: normalize the incoming code, classify it as ISCI or Ad-ID, look it up in the ledger, and either return the existing house number or mint a deterministic one — refusing to overwrite an entry that already binds a different creative. A collision is never silently resolved; it is surfaced as an explicit COLLISION outcome for the traffic desk.

ISCI and Ad-ID to house-number crosswalk flow An advertising code arrives as either a legacy 8-character ISCI or an 11 to 12 character Ad-ID, in mixed casing with whitespace. It is normalized to one canonical form and classified by shape. The classified code is looked up in the house-number ledger. On a hit, the existing house number is returned unchanged. On a miss, a deterministic house number is minted from the namespace and canonical code and written to the ledger, but only after a collision check confirms no different creative already holds that number; if one does, the record is routed to a COLLISION outcome for manual review rather than overwriting the binding. Agency code ISCI 8ch / Ad-ID 11-12ch ABCD1234 abcd1234h ABCD-1234-000H Normalize + classify strip / upper ISCI | AD_ID in ledger? Return existing house number WXYZ-0000482 · status RESOLVED num free? Mint + persist status MINTED Collision queue status COLLISION hit miss yes no

Figure — Crosswalk flow: an ISCI or Ad-ID is normalized, classified, and looked up; a hit returns the stable house number, a miss mints one deterministically only after a collision check clears the slot, and a clash is queued rather than overwritten.

Step 1 — Structured audit logging and typed models

Goal: emit greppable audit lines in the traffic-ops timestamp | level | module | spot_id shape and fix the models that carry a creative through the crosswalk. The audit log is the compliance artifact — every binding decision must be reconstructable from it alone.

python
from __future__ import annotations

import hashlib
import logging
import re
from datetime import datetime, timezone
from enum import StrEnum

from pydantic import BaseModel, field_validator

# Traffic-ops structured logging: timestamp | level | module | message(spot_id=...)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.taxonomy.house_number_crosswalk")

# ISCI: exactly 8 alphanumerics (legacy 4-char advertiser prefix + 4-char code).
# Ad-ID: 11 or 12 chars, 4-char registered prefix + 7 or 8 alphanumerics,
# optional trailing HD marker "H". Hyphens in display form are cosmetic.
ISCI_RE = re.compile(r"^[A-Z0-9]{8}$")
AD_ID_RE = re.compile(r"^[A-Z0-9]{11,12}$")


class CodeKind(StrEnum):
    ISCI = "isci"
    AD_ID = "ad_id"


class ResolveStatus(StrEnum):
    RESOLVED = "resolved"   # existing house number returned
    MINTED = "minted"       # new deterministic house number created
    COLLISION = "collision" # target slot already bound to a different creative
    REJECTED = "rejected"   # code failed ISCI/Ad-ID shape validation


class CreativeCode(BaseModel):
    raw_code: str
    advertiser: str | None = None

    @field_validator("raw_code")
    @classmethod
    def _shape(cls, v: str) -> str:
        # Normalize before matching so casing, spaces and display hyphens converge.
        cleaned = v.strip().upper().replace("-", "").replace(" ", "")
        if not (ISCI_RE.match(cleaned) or AD_ID_RE.match(cleaned)):
            raise ValueError(f"'{v}' is neither a valid ISCI nor Ad-ID")
        return cleaned


class HouseMapping(BaseModel):
    house_number: str
    canonical_code: str
    kind: CodeKind
    status: ResolveStatus
    mapped_at: str

Step 2 — Normalize and classify the incoming code

Goal: collapse casing, whitespace, and display hyphens into one canonical string, then decide whether it is an 8-character ISCI or an 11–12 character Ad-ID. Classification drives collision rules, because an ISCI and an Ad-ID for the same creative must resolve to one house number, not two.

python
def classify(canonical_code: str) -> CodeKind:
    # An 8-char code is a legacy ISCI; 11-12 chars is a modern Ad-ID.
    if ISCI_RE.match(canonical_code):
        return CodeKind.ISCI
    if AD_ID_RE.match(canonical_code):
        return CodeKind.AD_ID
    # Unreachable once CreativeCode validation has passed, but fail closed.
    raise ValueError(f"unclassifiable code '{canonical_code}'")

Because CreativeCode already normalizes in its validator, classify operates on clean input. The same discipline is applied one layer up in Validating Field Constraints in Python, which enforces the dictionary-level rules the crosswalk assumes are already met.

Step 3 — Mint a deterministic house number

Goal: derive a house number that is a pure function of the namespace and canonical code, so the same creative always mints the same number even if the ledger is rebuilt from scratch.

python
def mint_house_number(canonical_code: str, namespace: str) -> str:
    # Deterministic: hash (namespace, canonical_code) into a zero-padded slot.
    # Re-running against the same inputs yields a byte-identical house number.
    digest = hashlib.sha256(f"{namespace}:{canonical_code}".encode()).hexdigest()
    slot = int(digest, 16) % 10_000_000          # 7-digit house-number space
    house_number = f"{namespace}-{slot:07d}"
    logger.debug("minted %s for code=%s", house_number, canonical_code)
    return house_number

Step 4 — Resolve against the ledger with collision handling

Goal: compose the crosswalk. Look up the canonical code; on a hit return the stored house number, on a miss mint one and persist it — but refuse to bind a house number that already names a different creative.

python
class HouseNumberCrosswalk:
    def __init__(self, namespace: str, ledger: dict[str, str] | None = None) -> None:
        self.namespace = namespace
        # code -> house_number, and the reverse binding for collision checks.
        self._by_code: dict[str, str] = ledger or {}
        self._by_house: dict[str, str] = {}
        for code, house in self._by_code.items():
            self._by_house[house] = code

    def resolve(self, creative: CreativeCode) -> HouseMapping:
        now = datetime.now(timezone.utc).isoformat()
        code = creative.raw_code                 # already canonical post-validation
        kind = classify(code)

        existing = self._by_code.get(code)
        if existing is not None:
            logger.info("%s | resolved code=%s house=%s", code, code, existing)
            return HouseMapping(house_number=existing, canonical_code=code,
                                kind=kind, status=ResolveStatus.RESOLVED, mapped_at=now)

        house = mint_house_number(code, self.namespace)
        bound_to = self._by_house.get(house)
        if bound_to is not None and bound_to != code:
            # Hash slot already holds a DIFFERENT creative — never overwrite.
            logger.error("%s | COLLISION house=%s already bound to %s",
                         code, house, bound_to)
            return HouseMapping(house_number=house, canonical_code=code,
                                kind=kind, status=ResolveStatus.COLLISION, mapped_at=now)

        self._by_code[code] = house
        self._by_house[house] = code
        logger.info("%s | minted house=%s kind=%s", code, house, kind)
        return HouseMapping(house_number=house, canonical_code=code,
                            kind=kind, status=ResolveStatus.MINTED, mapped_at=now)

A representative log line reads 2026-07-17T14:22:07+00:00 | INFO | traffic.taxonomy.house_number_crosswalk | ABCD1234000H | minted house=WXYZ-0043118 kind=ad_id.

Verification & Testing

Correctness rests on two properties: determinism (a code always resolves to the same house number) and collision safety (a slot already bound to a different creative is never overwritten). Both are assertable against fixture data drawn from a real agency instruction.

python
xwalk = HouseNumberCrosswalk(namespace="WXYZ")

# 1. Determinism across casing, spaces, and display hyphens.
a = xwalk.resolve(CreativeCode(raw_code="ABCD-1234-000H"))
b = xwalk.resolve(CreativeCode(raw_code="  abcd1234000h "))
assert a.status is ResolveStatus.MINTED
assert b.status is ResolveStatus.RESOLVED    # second call hits the ledger
assert a.house_number == b.house_number       # same creative, one house number

# 2. ISCI and Ad-ID classify distinctly.
isci = xwalk.resolve(CreativeCode(raw_code="ABCD1234"))
assert isci.kind is CodeKind.ISCI
assert a.kind is CodeKind.AD_ID

# 3. Malformed codes fail closed at the model boundary.
try:
    CreativeCode(raw_code="XYZ")              # too short for either format
    assert False, "should have raised"
except ValueError:
    pass

Run the suite in CI against a golden fixture of code → house-number pairs so any change to the normalization or minting rules that would silently re-key inventory fails the build instead of shipping. Because mint_house_number is pure, replaying an archived instruction during an audit reproduces byte-identical house numbers.

Edge Cases & Failure Handling

  • ISCI and Ad-ID for one creative. A campaign often ships a legacy ISCI early and a registered Ad-ID later for the same film. Treat them as aliases: bind both codes to one house number by seeding the ledger with an alias map so the second code resolves rather than mints. Never let an alias mint a second house number, or the as-run splits across two invoices.
  • Hash-slot collision. Two distinct canonical codes can hash to the same 7-digit slot. The reverse-binding check catches this and returns COLLISION instead of overwriting; the operator assigns the loser a house number from a reserved manual band. Widening the slot space reduces the probability but never eliminates it, so the check is mandatory, not optional.
  • Malformed or truncated code. A code that survives transport but violates ISCI/Ad-ID shape is rejected at the CreativeCode boundary, not hashed into a garbage house number. This is usually an upstream normalization gap; apply billing code normalization before the crosswalk rather than loosening the validator, which would let unbillable creatives into the log.

FAQ

Why not just hand out the next sequential house number?

Because a sequence counter encodes arrival order, not identity, so re-ingesting the same instruction mints a second house number and splits the as-run across two invoices. A deterministic hash of the namespace and canonical code always yields the same house number for the same creative, which is the property audit replay depends on. The same idempotency reasoning underpins How to Map Legacy Spot IDs to Modern Schemas.

How do I tell an ISCI from an Ad-ID reliably?

Length after normalization is the discriminator: a legacy ISCI is exactly 8 alphanumerics, while an Ad-ID is 11 or 12 (a 4-character registered prefix plus 7–8 characters, optionally an HD marker). The classify function encodes this, and the field-level constraints it relies on are defined in the traffic field data dictionary. Never infer the kind from the hyphen pattern in the display form — hyphens are cosmetic and vary by supplier.

What happens when two creatives collide on the same house number?

The reverse-binding check returns a COLLISION status and logs the incumbent code rather than overwriting the slot, so no billing lineage is ever silently rebound. The traffic desk assigns the second creative a number from a reserved manual band. Retain the collision log line for audit; it is the evidence that the binding was resolved deliberately, which a billing reconciliation review will expect to see.

Should the crosswalk validate the whole record or just the code?

Just the code and its house-number binding. Resolve the rest of the record — durations, dayparts, sponsor IDs — against the full contract using the Pydantic validator pattern. Keeping identity resolution and full-record validation as separate passes means each fails for exactly one reason, which makes the collision or rejection cause unambiguous.