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.
- Cross-branch
oneOfambiguity — noconstdiscriminator. When each ticket subschema in aoneOfdoes not pin itsticket_typewith aconst, a permissive validator matches a VIP payload against the standard branch (or matches more than one branch and rejects the record for violatingoneOf’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. - Permissive
additionalPropertiesand missingrequiredarrays. JSON Schema defaults toadditionalProperties: true. Without an explicitfalseguard and a completerequiredlist 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. - 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$reftree on every record. Combined with an uncappedticketsarray, 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.
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
- Give every branch a
conston the discriminating key so only the matching subschema can validate a giventicket_type. - Factor the shared fields into a
ticket_base$defand compose each branch withallOf: [ticket_base, {branch-specific}]. - Do not use OpenAPI’s
discriminatoras a substitute —jsonschemaignores it; theconstis what actually routes validation.
"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
- Set
additionalProperties: falseat the root object, atattendee_profile, and inside every ticket branch. - List every non-optional field in each branch’s
requiredarray — the base fields inticket_base, the branch discriminator and its mandatory fields in the branch. - Bound overlapping keys with explicit types and formats (
format: uuid,pattern,enum) so a shared key can never coerce across branches.
"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
- Build one
Draft202012Validatorat startup and reuse it; it caches regex, format checkers, and the$reftree at construction. - Cap the array with
minItems/maxItemsso an oversizedticketslist is rejected at the gate rather than deserialized. - Route rejected payloads to the dead-letter queue that owns retries instead of failing inline, keeping the hot path allocation-light.
"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.
{
"$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.
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.
- Confirm the blast radius. Group your validation-violation logs by
pathover 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. - 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. - 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. - Fix the contract, not the data. If a legitimate new ticket type appeared, add a
const-pinned branch to theoneOfand bump the schema$idversion; if a field moved, that belongs in the mapping layer. Never hand-edit queued payloads. - 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 enableadditionalProperties: trueas anything but a logged, temporary measure. - Post-rollback validation. Drain-replay the DLQ through
validate_registration_payloadand confirm the violation path returns to zero:redis-cli LLEN dlq:schema-violationsshould fall to0.
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.
Related Link to this section
- 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
constrouting this page enforces. - Async Batch Processing — owns the dead-letter queue and retry machinery that quarantined multi-ticket payloads are routed to.