Event Taxonomy Schema Design: The Canonical Ingestion Contract

Event taxonomy schema design is the stage that defines the single canonical shape every attendee record must take before any downstream stage is allowed to touch it. It is one stage of the Core Architecture & Event Taxonomy pipeline, sitting at the ingestion boundary — the first hard checkpoint where raw payloads from ticketing platforms, CRM exports, and API webhooks converge into a unified validation layer. The taxonomy schema enforces strict data contracts at ingress: type safety, required-field presence, hierarchical categorization of access tiers and session tracks, and explicit routing metadata. Every record that clears this gate carries predictable structure that the rest of the system can trust without re-checking.

The failure mode this stage exists to prevent is the schema drift that turns a single vendor payload mutation into a cascade of downstream misprints. Without a hardened schema at ingress, transformations become brittle, badge generation failures propagate unpredictably across print queues and digital credential services, and a malformed CRM export can stall a check-in line. Unlike the attendee field mapping rules that resolve vendor-specific paths into internal fields, this stage owns the definition of those internal fields — the authoritative types, the invariant-versus-mutable split, and the versioning rules that let the taxonomy evolve without breaking previously ingested records. Mapping executes against the contract; schema design authors it.

The canonical schema gate: heterogeneous sources validated into one contract, then split by verdict and severity Three heterogeneous sources — a CRM webhook, a ticketing export, and a manual CSV — converge on the Canonical Schema Gate, which runs strict Pydantic v2 validation with extra=forbid and is pinned by a schema_version registry that records the exact contract each payload was validated under. The gate has two exits. A valid payload becomes an AttendeeIngestSchema record carrying a payload_hash correlation ID and flows downstream to Field Mapping and Badge Rendering. A rejected payload enters a severity router: a CRITICAL_FIELD_VIOLATION (external_id, ticket_tier, registered_at, email) routes to the manual_review_queue, while a NON_CRITICAL_MUTABLE failure routes to the default_tier fallback chain. Both rejected paths preserve the original payload in an audit reconciliation store. validated path → downstream rejected · original payload preserved for audit schema_version registry pins each record to its contract CRM webhook JSON push ticketing export bulk CSV / API manual CSV back-office upload Canonical Schema Gate strict Pydantic v2 extra="forbid" schema_version router model_validate(raw) pure function · one verdict valid valid → AttendeeIngestSchema normalized, typed record carries payload_hash id Field Mapping → Badge Rendering reject severity router critical non-critical CRITICAL_FIELD_VIOLATION → manual_review_queue NON_CRITICAL_MUTABLE → default_tier fallback original payload preserved audit reconciliation store

Scope Boundary: What Schema Design Owns Link to this section

To stay a stateless, deterministic gate, this stage enforces a hard boundary. It defines and validates the canonical contract and nothing more — it does not resolve vendor paths, render layout, or orchestrate retries. Anything outside that scope is delegated to an adjacent stage so a rendering fault or a broker outage can never propagate backward into schema state.

In-Scope (owned here) Out-of-Scope (delegated)
Canonical field definitions, types, and enum vocabularies Vendor source-path resolution and field aliasing → attendee field mapping rules
Invariant vs mutable contract split and coercion rules Extended custom-field routing into CRM systems → mapping custom registration fields to CRM databases
Polymorphic ticket-type modeling and discriminated unions Multi-ticket JSON Schema authoring detail → how to build JSON schemas for multi-ticket event types
Strict validation, quarantine flagging, and fallback classification Retry orchestration and dead-letter machinery → async batch processing
Schema versioning and backward-compatible evolution High-throughput intake buffering and validation runners → schema validation pipelines

The rate at which records arrive and the way they are pulled from vendors is not decided here either — that durability is co-owned with the upstream form API polling strategies. Keeping the gate a pure function is what makes it trustworthy: the same raw payload plus the same schema_version always produces the same verdict, which is the property that makes replays and reconciliation safe.

Invariant vs. Mutable Data Contracts Link to this section

The single most important design decision at this boundary is separating invariant identifiers from mutable attributes. Invariant fields — the unique external identifier, the ticket tier classification, and the immutable registration timestamp — drive routing decisions, entitlement checks, and audit reconciliation. They can never be guessed or silently defaulted, because a fabricated value produces a wrong or unscannable credential. Mutable attributes — dietary preferences, session selections, accessibility requirements, emergency contacts — are subject to post-registration updates and require idempotent merge logic rather than strict rejection.

Encoding that split directly in the schema is what lets validation fail correctly. A missing invariant is a hard error that halts the record; a missing mutable field resolves to a safe baseline and the record proceeds. When designing for real registration flows you will hit nested ticket hierarchies, conditional field requirements, and polymorphic attendee objects — the how to build JSON schemas for multi-ticket event types guide covers the discriminated-union structure that handles those variations without introducing schema drift or collapsing distinct ticket shapes into one permissive object.

The Data Contract Link to this section

Production validation needs more than basic type checking. It requires a stateless, deterministic gate that aggregates errors, applies safe coercion only where explicitly permitted, and surfaces actionable diagnostics. The contract below uses Pydantic v2 with strict mode enabled so that implicit conversions — a string silently becoming an int, "false" becoming True — cannot mask upstream corruption. Every field is either an enforced invariant or a mutable attribute with an explicit default.

PYTHON
from pydantic import BaseModel, ConfigDict, Field, EmailStr, StrictStr, field_validator
from typing import Optional
from datetime import datetime

class AttendeeIngestSchema(BaseModel):
    # strict: no silent coercion; extra=forbid: reject unknown keys at the gate
    model_config = ConfigDict(strict=True, extra="forbid", str_strip_whitespace=True)

    # --- invariant fields (hard-rejected on failure) ---
    external_id: StrictStr = Field(..., min_length=1, max_length=64)
    ticket_tier: StrictStr = Field(..., pattern=r"^(general|vip|speaker|staff|press)$")
    registered_at: datetime
    email: EmailStr
    schema_version: StrictStr = Field(default="v2.0.0", pattern=r"^v\d+\.\d+\.\d+$")

    # --- mutable attributes (safe defaults; merge post-registration) ---
    display_name: Optional[StrictStr] = Field(default=None, max_length=120)
    dietary_flags: list[StrictStr] = Field(default_factory=list)
    session_ids: list[StrictStr] = Field(default_factory=list)
    fallback_eligible: bool = False

    @field_validator("ticket_tier", mode="before")
    @classmethod
    def canonicalize_tier(cls, v: object) -> object:
        # Only casing is normalized here; unknown tiers still fail the pattern,
        # so a typo becomes a hard error rather than a mis-tiered badge.
        return v.strip().lower() if isinstance(v, str) else v

Each field earns its place. external_id is the identity anchor every downstream stage joins on, so it is length-bounded and non-empty. ticket_tier is constrained to a closed vocabulary by pattern, which means an unrecognized tier is a validation error rather than a badge printed at the wrong access level; its before validator normalizes only casing, never inventing a value. registered_at and email are invariants that gate entitlement and delivery. schema_version travels with every record so that when the taxonomy evolves, historical payloads can be reprocessed against the exact version they were built for. The mutable fields all carry default_factory or None defaults, so their absence never blocks a record. extra="forbid" rejects unknown keys outright, which is what surfaces a vendor adding an undocumented field before it silently leaks downstream.

Deterministic Validation & Quarantine Routing Link to this section

The gate itself is a pure function: it accepts a raw dictionary and returns either a normalized model instance or a structured verdict describing exactly why the payload was rejected and where it should be routed. Nothing is ever silently dropped. A stable content hash is computed first so the verdict carries a correlation ID even when validation fails before any field is readable.

PYTHON
import hashlib
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Optional
from pydantic import ValidationError

logger = logging.getLogger("ingestion.schema")

CRITICAL_FIELDS = {"external_id", "ticket_tier", "registered_at", "email"}

@dataclass
class ValidationReport:
    payload_hash: str
    is_valid: bool
    errors: list[dict[str, Any]] = field(default_factory=list)
    quarantine_reason: Optional[str] = None
    fallback_chain: Optional[str] = None

def validate_ingest_payload(
    raw: dict[str, Any],
) -> tuple[Optional[AttendeeIngestSchema], ValidationReport]:
    # Stable hash (sorted keys) — reproducible correlation ID, unlike built-in hash()
    payload_hash = hashlib.sha256(
        json.dumps(raw, sort_keys=True, default=str).encode()
    ).hexdigest()[:12]

    try:
        normalized = AttendeeIngestSchema.model_validate(raw)
        logger.info("schema.valid", extra={
            "external_id": normalized.external_id,
            "payload_hash": payload_hash,
            "schema_version": normalized.schema_version,
        })
        return normalized, ValidationReport(payload_hash=payload_hash, is_valid=True)

    except ValidationError as exc:
        errors = [{
            "field_path": ".".join(str(loc) for loc in e["loc"]),
            "constraint": e.get("type", "unknown"),
            "message": e["msg"],
            "input_value": str(e.get("input", ""))[:50],  # truncated: no PII bloat
        } for e in exc.errors()]

        # Route by severity: an invariant breach cannot be salvaged with a default
        failed_critical = any(
            e["field_path"].split(".")[0] in CRITICAL_FIELDS for e in errors
        )
        if failed_critical:
            reason, chain = "CRITICAL_FIELD_VIOLATION", "manual_review_queue"
        else:
            reason, chain = "NON_CRITICAL_MUTABLE_FAILURE", "default_tier_assignment"

        report = ValidationReport(
            payload_hash=payload_hash, is_valid=False, errors=errors,
            quarantine_reason=reason, fallback_chain=chain,
        )
        logger.warning("schema.quarantined", extra={
            "payload_hash": payload_hash, "reason": reason, "chain": chain,
        })
        return None, report

The routing logic is the heart of the stage. A failed invariant — a missing external_id, a malformed email, an out-of-vocabulary ticket_tier — has no safe default, so the record is halted and routed to the manual-review queue with its full error envelope intact. A failed mutable field — an unsupported dietary_flags value, a missing display_name — triggers a fallback chain that applies organizational defaults while preserving the original payload for audit reconciliation. This severity split is what keeps print queues moving without ever trading away data integrity: the gate never stalls the whole batch over a cosmetic field, and it never ships a fabricated identity to satisfy throughput.

Production Debugging & Observability Link to this section

Fast incident resolution rests on diagnostics that map directly to field-level constraints. The ValidationReport captures the exact field_path, the constraint type that fired, and a truncated input value safe for log retention. The payload_hash is emitted as a trace correlation ID so ops can reconstruct which upstream payload broke without ever storing PII in a log aggregator — the hash points to the record, the record stays in the encrypted store. Failed payloads are serialized with their error envelope and pushed to the dead-letter queue that owns retries, never dropped inline. A representative quarantine event looks like this:

JSON
{
  "event": "schema.quarantined",
  "payload_hash": "9f2c1a7be4d0",
  "reason": "CRITICAL_FIELD_VIOLATION",
  "chain": "manual_review_queue",
  "errors": [
    {"field_path": "ticket_tier", "constraint": "string_pattern_mismatch", "input_value": "VVIP"},
    {"field_path": "email", "constraint": "value_error", "input_value": "not-an-address"}
  ],
  "level": "warning"
}

That single line tells the on-call engineer what breached (ticket_tier received an undefined VVIP value), which payload to pull by hash, and that the failure was critical enough to hold for review rather than auto-default. Wire the structured fields into your platform of choice: a Datadog facet on @reason shows the quarantine-rate split at a glance, and an ELK filter on errors.field_path isolates a drift class in seconds. When a single field_path spikes, cross-reference the source system’s API changelog — most ingestion failures originate from vendor payload mutations, not internal schema defects. Because the same envelope shape is shared with the upstream schema validation pipelines, triage tooling stays uniform across the whole intake tier. For mapping the constraint vocabulary back to its source rule, the JSON Schema standard defines the predictable keywords (type, pattern, minLength, required) that Pydantic’s error types mirror.

Performance & Memory Constraints Link to this section

Validation is CPU-bound and runs once per attendee, so at conference scale the per-record cost and its interaction with the worker pool dominate. The constraints that actually bite during an opening-morning registration burst are below, with the mitigation for each.

Component Constraint Mitigation
Model compilation Re-deriving the Pydantic core schema per call wastes CPU under a multi-thousand-record burst AttendeeIngestSchema compiles its validator once at import; reuse the class, never rebuild it per record
Worker concurrency (CPU-bound) The GIL serializes strict validation, so threads give no real speedup Scale with processes (Gunicorn/Celery workers ≈ CPU cores), not threads
DLQ producer connection A per-quarantine broker connect exhausts the pool during a drift storm Pool connections (max_connections ≈ 2× worker count) and publish through a shared client
Queue prefetch depth Unbounded prefetch lets a poison-pill backlog balloon worker memory Cap prefetch_count at 32–64 and offload retries to async batch processing
Payload hashing Hashing very large custom-field blocks adds latency per record Hash the raw dict once with sorted keys; reuse the digest as both correlation ID and dedupe key

The recurring theme is that the gate should stay a pure, allocation-light function and push every stateful or blocking concern — connections, retries, queue depth — outward to the batch tier built to hold it.

Incident Triage Checklist Link to this section

When quarantine-rate alerts fire, the target is under 15 minutes to containment. Work the list in order; every step is non-destructive until the final rollback.

  1. Confirm the blast radius. Query your log platform for schema.quarantined grouped by errors.field_path over the last 15 minutes. A single dominant path (e.g. ticket_tier) means a vendor added or renamed an enum value; a spread across paths means a wholesale payload-shape change.
  2. Inspect the DLQ depth. redis-cli LLEN dlq:schema_ingest — a fast-climbing depth confirms an active drift rather than a stale backlog. Peek without draining: redis-cli LRANGE dlq:schema_ingest 0 4.
  3. Pull one failing payload by hash: redis-cli HGETALL dlq:schema_ingest:<payload_hash>. Compare its keys against AttendeeIngestSchema to see exactly which constraint fired and whether the value is a new-but-valid tier or genuine corruption.
  4. Fix the contract, not the data. If the vendor introduced a legitimate new tier, extend the ticket_tier pattern and bump schema_version; if the field moved, that belongs in the mapping layer, not here. Never hand-edit records in the queue — the fix must reproduce for every replayed payload.
  5. Deploy and drain-replay. Ship the version-bumped schema, then requeue the DLQ back through validate_ingest_payload. Watch schema.valid rise and LLEN dlq:schema_ingest fall to zero. Because the gate is stateless, a rolling redeploy completes in under two minutes.
  6. Rollback path. If the new pattern wrongly admits a bad value, revert to the previous schema_version — every record carries the version it was validated under, so reprocessing against the prior contract is never destructive.

Frequently Asked Questions Link to this section

Why enable Pydantic strict mode instead of letting it coerce types? Because silent coercion is exactly how upstream corruption reaches a badge. In lax mode a ticket_tier of 1 or an email that is really an integer would coerce and validate; in strict mode they fail at the gate, where the diagnostic is cheap and the record can be quarantined. The one place coercion is allowed — casing on ticket_tier — is explicit and bounded by a before validator that still rejects unknown values.

Should a payload with an unsupported dietary flag be rejected or defaulted? Defaulted. Dietary flags are mutable attributes, so a failure routes to the default_tier_assignment fallback chain and the record proceeds with the original payload preserved for reconciliation. Hard rejection is reserved for invariant fields — external_id, email, ticket_tier, registered_at — where no safe default exists.

How do I evolve the taxonomy without breaking records already ingested? Bump schema_version, add new fields with explicit defaults so older payloads still validate, and widen enum patterns only additively. Because every record carries the version it was validated under, historical records replay against their original contract while new records use the new one — which is what makes reprints and forensic replays trustworthy.