How to Build JSON Schemas for Multi-Ticket Event Types: Fixing Silent Drops and Badge Misprints

Symptom Statement Link to this section

A registration payload carrying more than one ticket shape — a VIP with lounge access alongside a group ticket with a lead attendee — clears the frontend but then vanishes or misprints somewhere downstream. Operators see ValidationError exceptions in the ingestion pipeline, VIP fields landing on standard-admission badges, and webhook payloads that never reach the correct fulfillment lane. Under a high-throughput registration window the same schema that passed a single-ticket smoke test starts silently dropping records and backing up the print queue. This page is a runbook for that exact failure surface: authoring the discriminated-union JSON Schema that lets a single event_registration payload carry heterogeneous ticket types without cross-branch leakage or validator overhead. It is part of the event taxonomy schema design stage of the Core Architecture & Event Taxonomy pipeline — the parent cluster owns the canonical single-attendee contract; this page owns the polymorphic ticket array inside it.

Root Cause Analysis Link to this section

Three concrete, independently reproducible faults account for nearly every broken multi-ticket validation run. They compound during load, but each has a distinct trigger and a distinct fix.

  1. Cross-branch oneOf ambiguity — no const discriminator. When each ticket subschema in a oneOf does not pin its ticket_type with a const, a permissive validator matches a VIP payload against the standard branch (or matches more than one branch and rejects the record for violating oneOf’s exactly-one rule). The result is either a badge printed at the wrong access level or a spurious rejection of a well-formed ticket.
  2. Permissive additionalProperties and missing required arrays. JSON Schema defaults to additionalProperties: true. Without an explicit false guard and a complete required list at every object level, unknown keys leak into the attendee record and absent fields pass validation, so the payload clears the gate and corrupts the downstream attendee field mapping rules with coordinates or QR payloads that were never actually present.
  3. Uncompiled validators and unbounded arrays in the hot path. Calling jsonschema.validate() in a loop re-derives the validator, recompiles every regex, and re-resolves the $ref tree on every record. Combined with an uncapped tickets array, per-record cost climbs until the validation workers back up the queue and memory balloons — the failure reads as a throughput incident, not a schema defect.

A fourth trap deserves a callout: the discriminator keyword is an OpenAPI 3.x extension, not part of JSON Schema Draft 2020-12. The jsonschema Python library ignores it entirely during validation, so a schema that “documents” its union with discriminator but omits const is functionally undiscriminated.

Const-discriminated oneOf routing for a multi-ticket registration payload An event_registration payload carrying a bounded tickets array enters a oneOf router that must match exactly one subschema per ticket. The router fans each ticket to three const-pinned branches — ticket_vip (const ticket_type "vip"), ticket_standard (const "standard"), and ticket_group (const "group") — and every branch closes with additionalProperties false. A ticket that matches exactly one branch exits to field mapping and then badge render. A ticket that matches no branch, or overlaps more than one because a const discriminator is missing, peels off to a dead-letter queue as a structured schema-violation error for retry. matched branch → field mapping · badge render no match · matches >1 branch → dead-letter queue event_registration incoming payload tickets[ ] minItems 1 · maxItems 50 oneOf router exactly-one match ticket_vip const ticket_type = "vip" additionalProperties: false ticket_standard const ticket_type = "standard" additionalProperties: false ticket_group const ticket_type = "group" additionalProperties: false field mapping resolve internal names badge render correct access layer no match / matches >1 dead-letter queue structured error · retry

Symptom-to-Resolution Matrix Link to this section

Cross-Branch oneOf Ambiguity Link to this section

Symptoms

  • A VIP ticket prints on a standard-admission badge layout (or vice versa).
  • A structurally valid ticket is rejected with a oneOf “is valid under more than one schema” error.
  • Webhook routing sends a group booking down the single-attendee lane.

Root cause. Without a const on ticket_type inside each branch, the branches overlap on their shared base fields, so the validator cannot deterministically pick exactly one subschema. This is the discriminated-union mechanism the event taxonomy schema design contract relies on to keep polymorphic ticket shapes from collapsing into one permissive object.

Fix

  1. Give every branch a const on the discriminating key so only the matching subschema can validate a given ticket_type.
  2. Factor the shared fields into a ticket_base $def and compose each branch with allOf: [ticket_base, {branch-specific}].
  3. Do not use OpenAPI’s discriminator as a substitute — jsonschema ignores it; the const is what actually routes validation.
JSON
"ticket_vip": {
  "allOf": [
    { "$ref": "#/$defs/ticket_base" },
    { "properties": { "ticket_type": { "const": "vip" } },
      "required": ["ticket_type"], "additionalProperties": false }
  ]
}

Permissive additionalProperties and Field Leakage Link to this section

Symptoms

  • A vendor adds an undocumented key and it silently appears in the attendee record.
  • A required field is absent yet the payload validates and a blank badge prints.
  • Type coercion slips through where two ticket types share a key with divergent value types.

Root cause. Every object defaults to accepting extra keys, and an incomplete required array lets absent fields pass. The gate reports success while the record is already malformed.

Fix

  1. Set additionalProperties: false at the root object, at attendee_profile, and inside every ticket branch.
  2. List every non-optional field in each branch’s required array — the base fields in ticket_base, the branch discriminator and its mandatory fields in the branch.
  3. Bound overlapping keys with explicit types and formats (format: uuid, pattern, enum) so a shared key can never coerce across branches.
JSON
"required": ["event_id", "registration_timestamp", "attendee_profile", "tickets"],
"additionalProperties": false

Uncompiled Validators in the Hot Path Link to this section

Symptoms

  • Validation latency climbs steadily as batch size grows.
  • The validation workers back up and the print queue stalls under load.
  • Memory for the validation service grows per record rather than staying flat.

Root cause. jsonschema.validate() re-instantiates the validator on every call, and an uncapped tickets array lets a single oversized payload dominate CPU and memory. The remedy is to compile once and cap array size.

Fix

  1. Build one Draft202012Validator at startup and reuse it; it caches regex, format checkers, and the $ref tree at construction.
  2. Cap the array with minItems/maxItems so an oversized tickets list is rejected at the gate rather than deserialized.
  3. Route rejected payloads to the dead-letter queue that owns retries instead of failing inline, keeping the hot path allocation-light.
JSON
"tickets": { "type": "array", "minItems": 1, "maxItems": 50,
             "items": { "$ref": "#/$defs/ticket_polymorph" } }

Minimal Working Implementation Link to this section

The schema below is a complete Draft 2020-12 contract: a strict event_registration root, a bounded tickets array, and a const-discriminated oneOf composed from a shared ticket_base. Every object closes with additionalProperties: false.

JSON
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://events.internal/schemas/v2/event_registration.json",
  "title": "Event Registration Payload",
  "type": "object",
  "properties": {
    "event_id": { "type": "string", "format": "uuid" },
    "registration_timestamp": { "type": "string", "format": "date-time" },
    "attendee_profile": {
      "type": "object",
      "properties": {
        "email": { "type": "string", "format": "email" },
        "full_name": { "type": "string", "maxLength": 120 }
      },
      "required": ["email", "full_name"],
      "additionalProperties": false
    },
    "tickets": {
      "type": "array",
      "minItems": 1,
      "maxItems": 50,
      "items": { "$ref": "#/$defs/ticket_polymorph" }
    }
  },
  "required": ["event_id", "registration_timestamp", "attendee_profile", "tickets"],
  "additionalProperties": false,
  "$defs": {
    "ticket_polymorph": {
      "oneOf": [
        { "$ref": "#/$defs/ticket_vip" },
        { "$ref": "#/$defs/ticket_standard" },
        { "$ref": "#/$defs/ticket_group" }
      ]
    },
    "ticket_base": {
      "type": "object",
      "properties": {
        "ticket_id": { "type": "string", "format": "uuid" },
        "ticket_type": { "type": "string" },
        "purchase_timestamp": { "type": "string", "format": "date-time" },
        "status": { "type": "string", "enum": ["active", "cancelled", "pending"] }
      },
      "required": ["ticket_id", "ticket_type", "purchase_timestamp", "status"]
    },
    "ticket_vip": {
      "allOf": [
        { "$ref": "#/$defs/ticket_base" },
        {
          "properties": {
            "ticket_type": { "const": "vip" },
            "lounge_access": { "type": "boolean" },
            "dietary_restrictions": { "type": "array", "items": { "type": "string" } }
          },
          "required": ["ticket_type", "lounge_access"],
          "additionalProperties": false
        }
      ]
    },
    "ticket_standard": {
      "allOf": [
        { "$ref": "#/$defs/ticket_base" },
        {
          "properties": {
            "ticket_type": { "const": "standard" },
            "seat_assignment": { "type": "string", "pattern": "^[A-Z]\\d{2}$" }
          },
          "required": ["ticket_type", "seat_assignment"],
          "additionalProperties": false
        }
      ]
    },
    "ticket_group": {
      "allOf": [
        { "$ref": "#/$defs/ticket_base" },
        {
          "properties": {
            "ticket_type": { "const": "group" },
            "group_size": { "type": "integer", "minimum": 3, "maximum": 20 },
            "lead_attendee_id": { "type": "string", "format": "uuid" }
          },
          "required": ["ticket_type", "group_size", "lead_attendee_id"],
          "additionalProperties": false
        }
      ]
    }
  }
}

The Python side compiles the validator once, checks the schema itself is well-formed, validates a representative multi-ticket payload, and proves that a cross-branch violation is caught rather than silently accepted.

PYTHON
import json
import logging
from typing import Any

from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError

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

# Load and compile once at import — never inside the per-record loop.
with open("schemas/v2/event_registration.json", "r", encoding="utf-8") as f:
    RAW_SCHEMA = json.load(f)

Draft202012Validator.check_schema(RAW_SCHEMA)   # fail fast on a malformed schema
VALIDATOR = Draft202012Validator(RAW_SCHEMA)     # caches regex, formats, $ref tree


def validate_registration_payload(payload: dict[str, Any]) -> bool:
    """Validate a multi-ticket payload; log the first violation path on failure."""
    try:
        VALIDATOR.validate(payload)
        return True
    except ValidationError as err:
        logger.warning(
            "schema.violation path=%s message=%s",
            list(err.absolute_path), err.message,
        )
        return False


# --- verification: a valid mixed-ticket payload passes; a leaked VIP field fails ---
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)

    valid = {
        "event_id": "3f1a2b4c-0000-4000-8000-000000000001",
        "registration_timestamp": "2026-07-02T09:00:00Z",
        "attendee_profile": {"email": "[email protected]", "full_name": "Ada Lovelace"},
        "tickets": [
            {"ticket_id": "3f1a2b4c-0000-4000-8000-000000000010", "ticket_type": "vip",
             "purchase_timestamp": "2026-07-01T12:00:00Z", "status": "active",
             "lounge_access": True},
            {"ticket_id": "3f1a2b4c-0000-4000-8000-000000000011", "ticket_type": "group",
             "purchase_timestamp": "2026-07-01T12:01:00Z", "status": "active",
             "group_size": 5, "lead_attendee_id": "3f1a2b4c-0000-4000-8000-000000000099"},
        ],
    }
    assert validate_registration_payload(valid) is True

    # A standard ticket carrying a VIP-only key must be rejected, not coerced.
    leaked = json.loads(json.dumps(valid))
    leaked["tickets"][0]["ticket_type"] = "standard"   # now the vip keys are illegal
    assert validate_registration_payload(leaked) is False

    logger.info("verification OK — const routing and additionalProperties both enforced")

The if __name__ block is the verification step: it confirms a genuinely mixed VIP-plus-group payload validates, and that flipping a branch’s ticket_type without cleaning its branch-specific keys is caught by additionalProperties: false rather than slipping through. Persisting the character and layout limits those tickets eventually drive belongs alongside the badge layout architecture definitions, not inside this schema.

Memory & Performance Constraints Link to this section

Resource Constraint Mitigation
Validator construction jsonschema.validate() recompiles the validator every call Build one Draft202012Validator at import and reuse it in the loop
tickets array An unbounded array lets one payload dominate CPU and memory Cap with minItems/maxItems; reject oversized lists at the gate
$ref resolution Recursive or deeply nested $ref chains inflate the resolution tree Keep all sub-schemas flat in $defs; the contract above avoids recursion entirely
Large payloads Custom-field blocks >10 MB balloon resident memory on parse Chunk or stream with ijson at the HTTP gateway before schema evaluation
Format checking format: uuid/date-time regex runs per field per record Compiled once by the shared validator; do not re-instantiate per record

Incident Triage & Rollback Link to this section

Target under 15 minutes to containment. Work the list top-down; nothing is destructive until the final step.

  1. Confirm the blast radius. Group your validation-violation logs by path over the last 15 minutes. A single dominant path (e.g. tickets.0.ticket_type) means a vendor changed a ticket vocabulary; a spread means a wholesale payload-shape change.
  2. Re-check the schema in isolation. python -c "import json; from jsonschema import Draft202012Validator as V; V.check_schema(json.load(open('schema.json')))" — this proves the schema itself is well-formed before you touch payloads.
  3. Quarantine, do not bypass. Point the webhook dispatcher at the dead-letter queue (/dlq/schema-violations) for async triage; never flip validation off to restore throughput.
  4. Fix the contract, not the data. If a legitimate new ticket type appeared, add a const-pinned branch to the oneOf and bump the schema $id version; if a field moved, that belongs in the mapping layer. Never hand-edit queued payloads.
  5. Rollback. Repoint the validator at the previous schema version (schemas/v1/event_registration.json) and alert badge layout and fulfillment services to expect legacy field formats during the window. Never enable additionalProperties: true as anything but a logged, temporary measure.
  6. Post-rollback validation. Drain-replay the DLQ through validate_registration_payload and confirm the violation path returns to zero: redis-cli LLEN dlq:schema-violations should fall to 0.

Security boundary note. Never return raw validation errors to clients. Map each ValidationError path to a generic 400 Bad Request with a sanitized error code so an attacker cannot enumerate the schema from rejection messages.

  • Event Taxonomy Schema Design — the parent stage that owns the canonical single-attendee contract this multi-ticket array lives inside; start here for the invariant-versus-mutable split.
  • Attendee Field Mapping Rules — the next stage, which resolves each validated ticket’s fields into internal names and inherits any leakage this schema fails to block.
  • Badge Layout Architecture — the downstream consumer that renders each ticket type to its correct layout, relying on the const routing this page enforces.
  • Async Batch Processing — owns the dead-letter queue and retry machinery that quarantined multi-ticket payloads are routed to.