Badge Layout Architecture: Deterministic Generation & Fallback Chains

The badge layout architecture is the stateless transformation boundary that turns a validated attendee record into a print-ready asset, and it is one of the four stages owned by the Core Architecture & Event Taxonomy section. Its single job is narrow on purpose: consume a normalized payload, resolve the correct versioned template, and emit a deterministic PDF or rasterized label — nothing else. The failure mode this component exists to prevent is a check-in line that stops moving because one malformed record, one missing font, or one corrupted template revision stalled the renderer holding the print queue. By treating badge generation as a pure function of (payload, template_version), throughput scales linearly across worker pods, reprints replay byte-identically, and a fault in one attendee’s badge never contaminates the next.

This page defines that boundary precisely: what the renderer is allowed to do, what it must delegate to adjacent stages, the exact data contract it accepts, the fallback chain that keeps it producing output under template failure, and the observability surface an on-call engineer needs to resolve an incident in under fifteen minutes. Everything upstream — normalization, deduplication, tier assignment — belongs to the attendee field mapping rules; everything downstream — spooling, dispatch by location, printer selection — belongs to the PDF routing workflows layer. Keeping those seams sharp is what makes this stage independently deployable and independently debuggable.

Scope Boundary Link to this section

The renderer stays fast and testable only because its responsibilities are explicitly bounded. Anything not in the left column is delegated to an adjacent stage and must not leak into the hot path.

In-Scope (this component owns) Out-of-Scope (delegated to adjacent stages)
Validating the AttendeeBadgePayload contract at entry Producing the canonical record — owned by field mapping rules
Resolving a versioned template via the fallback chain Defining the taxonomy of tiers and tracks — owned by the event taxonomy schema
Deterministic coordinate layout and font embedding Encoding the scannable payload — owned by QR code generation and barcode threshold tuning
Emitting an idempotent PDF plus a render receipt Delivering the PDF to a printer or kiosk — owned by PDF routing workflows
Routing unrenderable payloads to a dead-letter queue Draining and replaying that queue — owned by async batch processing
Enforcing glyph-length and control-character sanitization Network isolation and mTLS to the print VLAN — owned by security boundary configuration

The renderer holds no session state, opens no upstream connections, and performs no runtime template authoring. It receives an already-resolved payload and an already-compiled asset bundle, which is what lets a single render call be replayed months later during a forensic reprint and produce the exact same bytes.

Data Contract & Validation Gates Link to this section

Generation begins with a rigid input contract. The layout engine accepts only render-safe primitives, and any deviation is rejected before template resolution begins. The contract below is derived from the event taxonomy schema design — field types, nullability, and locale rules originate there — and is re-validated at this boundary as defense in depth rather than trust in the caller.

PYTHON
from typing import Optional
from pydantic import BaseModel, ConfigDict, field_validator

ALLOWED_BADGE_TYPES = frozenset({"standard", "vip", "speaker", "staff"})

class AttendeeBadgePayload(BaseModel):
    """Render-safe input contract for a single badge.

    Every field is a primitive the renderer can place without further
    lookups. No nested objects, no lazy relations, no upstream I/O.
    """
    model_config = ConfigDict(strict=True, extra="forbid", frozen=True)

    attendee_id: str
    full_name: str
    organization: Optional[str] = None
    badge_type: str
    qr_data: str
    print_locale: str = "en-US"

    @field_validator("full_name", "organization", mode="before")
    @classmethod
    def strip_control_chars(cls, v: Optional[str]) -> Optional[str]:
        """Coerce display strings: drop control chars, clamp glyph length."""
        if v is None:
            return v
        cleaned = "".join(ch for ch in v if ch.isprintable())
        return cleaned[:64]

    @field_validator("badge_type")
    @classmethod
    def validate_type(cls, v: str) -> str:
        if v not in ALLOWED_BADGE_TYPES:
            raise ValueError(
                f"Invalid badge_type: {v!r}. Allowed: {sorted(ALLOWED_BADGE_TYPES)}"
            )
        return v

Each field carries an explicit rule. attendee_id is the correlation key threaded through every log line and the dead-letter envelope, so it is required and never defaulted. full_name and organization pass through a mode="before" coercion that strips non-printable characters and clamps to 64 glyphs — this runs before type validation so that an oversized or control-character-laden string is repaired rather than rejected, preventing layout overflow and buffer-shift exploits. badge_type is constrained to a closed set because it selects both the template family and the color treatment; an unknown value must fail loudly rather than silently pick a default. extra="forbid" guarantees that an upstream schema drift adding a stray field is caught here instead of being silently ignored, and frozen=True makes the payload immutable so the same object cannot be mutated between the validation gate and the draw calls. Missing critical identifiers fail fast with a structured ValidationError whose .errors() list carries field-level rejection reasons, which the caller wraps into a dead-letter envelope.

Deterministic Implementation Link to this section

The core rendering layer uses Python ReportLab for vector-accurate PDF output, choosing deterministic coordinate mapping over the reflow unpredictability of an HTML/CSS engine. Template resolution walks a strict precedence chain — event-specific override, then track-specific default, then a minimal global fallback — and every template is versioned, cached in-memory under an LRU policy, and validated against a SHA-256 checksum registry before instantiation. The Designing Scalable Badge Templates with Python ReportLab guide covers the coordinate system, font-embedding strategy, and element positioning in depth; the code here is the runtime contract that consumes those templates.

Template resolution fallback chain with per-tier checksum gates A resolution plan is walked most-specific first through three tiers: event_override at fallback_level 0, track_default at level 1, and global_fallback at level 2. Each tier loads its asset and passes through a checksum gate that verifies the bytes against a shared SHA-256 checksum registry. If a gate passes, the tier renders: render_badge produces a byte-stable, idempotent PDF receipt. If a gate fails because the checksum mismatches or the asset is missing, the chain drops to the next tier. When the global tier's gate also fails, the chain is exhausted and the payload is diverted to the dead-letter queue as a TemplateExhausted event for later replay. The same checksum registry guards every tier. checksum pass → render mismatch · missing asset → next tier checksum registry SHA-256 · guards every tier event_override fallback_level 0 · most specific track_default fallback_level 1 global_fallback fallback_level 2 · minimal checksum gate SHA-256 verify checksum gate SHA-256 verify checksum gate SHA-256 verify load_asset load_asset load_asset pass pass pass mismatch / missing → level 1 mismatch / missing → level 2 chain exhausted render_badge() → render receipt idempotent PDF · byte-stable first checksum-valid tier wins dead-letter queue TemplateExhausted · replay later
PYTHON
import logging
import hashlib
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import BinaryIO

from reportlab.lib.pagesizes import A6
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas

logger = logging.getLogger("badge.render")


@dataclass(frozen=True)
class BadgeTemplate:
    template_id: str
    checksum: str          # expected SHA-256 of the compiled asset
    version: str
    fallback_level: int    # 0=event, 1=track, 2=global


@dataclass(frozen=True)
class ResolutionPlan:
    """Ordered candidates, most-specific first."""
    candidates: tuple[BadgeTemplate, ...]


class ChecksumMismatch(Exception):
    pass


class TemplateExhausted(Exception):
    """All fallback tiers failed; caller must route to the DLQ."""


def _verify(template: BadgeTemplate, asset_bytes: bytes) -> None:
    actual = hashlib.sha256(asset_bytes).hexdigest()
    if actual != template.checksum:
        raise ChecksumMismatch(
            f"{template.template_id}@{template.version}: "
            f"expected {template.checksum[:12]}, got {actual[:12]}"
        )


@contextmanager
def managed_canvas(output_stream: BinaryIO):
    """Guarantees canvas.save() flushes and closes the PDF stream."""
    c = canvas.Canvas(output_stream, pagesize=A6)
    try:
        yield c
    finally:
        c.save()


def resolve_template(plan: ResolutionPlan, load_asset) -> BadgeTemplate:
    """Walk the fallback chain; return the first checksum-valid template."""
    for template in plan.candidates:
        try:
            asset = load_asset(template.template_id, template.version)
            _verify(template, asset)
            return template
        except (ChecksumMismatch, FileNotFoundError) as exc:
            logger.warning(
                "template fallback",
                extra={
                    "template_id": template.template_id,
                    "fallback_level": template.fallback_level,
                    "reason": type(exc).__name__,
                },
            )
            continue
    raise TemplateExhausted("no template survived checksum verification")


def render_badge(
    payload: AttendeeBadgePayload,
    plan: ResolutionPlan,
    load_asset,
    output: BinaryIO,
    trace_id: str,
) -> dict:
    """Deterministic, idempotent badge renderer with fallback routing."""
    template = resolve_template(plan, load_asset)

    # Idempotency key: identical (payload, template version) => identical bytes.
    payload_hash = hashlib.sha256(
        f"{payload.model_dump_json()}|{template.version}".encode()
    ).hexdigest()[:16]

    with managed_canvas(output) as c:
        c.setFont("Helvetica-Bold", 14)
        c.drawString(10 * mm, 90 * mm, payload.full_name.upper())
        c.setFont("Helvetica", 10)
        c.drawString(10 * mm, 82 * mm, payload.organization or "GENERAL ADMISSION")
        c.drawString(10 * mm, 74 * mm, f"TYPE: {payload.badge_type.upper()}")
        c.setFont("Helvetica", 7)
        c.drawString(10 * mm, 8 * mm, f"ID: {payload.attendee_id} | CHK: {payload_hash}")

    receipt = {
        "status": "rendered",
        "attendee_id": payload.attendee_id,
        "template_id": template.template_id,
        "template_version": template.version,
        "fallback_level": template.fallback_level,
        "payload_hash": payload_hash,
        "trace_id": trace_id,
    }
    logger.info("badge rendered", extra=receipt)
    return receipt

Three properties make this safe under production concurrency. First, resolution is pure with respect to the checksum registry: a template only renders if its bytes match the expected hash, so a partially-synced or hot-patched asset can never produce a subtly wrong badge — it fails the guard and drops to the next tier. Second, the payload_hash folds in the template version, so it doubles as an idempotency key: a retry with the same input against the same template version produces the identical hash and identical bytes, letting the routing layer deduplicate reprints. Third, managed_canvas guarantees canvas.save() runs in a finally block, releasing the PDF stream even when a draw call raises — the leak that otherwise slowly starves a long-lived worker pod of file handles. When resolve_template exhausts every tier it raises TemplateExhausted, and the calling worker is responsible for wrapping the payload in a diagnostic envelope and routing it to the dead-letter queue rather than blocking the print queue.

Production Debugging & Observability Link to this section

Because the renderer is stateless, every diagnostic must ride on the log line and the render receipt — there is no local state to inspect after the fact. Each attempt emits a structured event keyed by trace_id (which correlates back to the originating registration event) and attendee_id. A successful render logs at info; a fallback logs at warning with the fallback_level that was activated; an exhausted chain logs at error immediately before the payload is diverted.

JSON
{
  "level": "warning",
  "event": "template fallback",
  "trace_id": "reg-7f3a9c2e",
  "attendee_id": "att_00e21b",
  "template_id": "kubecon-2026-vip",
  "fallback_level": 0,
  "reason": "ChecksumMismatch",
  "render_duration_ms": 4.1
}

In a Datadog or ELK pipeline, index these fields by name: trace_id as the cross-stage correlation facet, fallback_level as a monitored dimension (any sustained rise in level-1 or level-2 renders means a primary template is silently broken), template_version to pin the exact asset revision in play, and render_duration_ms to catch font-subsetting or asset-bloat regressions before they become queue backpressure. A saturation alert on count(fallback_level >= 1) / count(*) crossing a few percent is the earliest signal that a template push went out with a stale checksum. When the chain is exhausted, the dead-letter envelope carries the full plan.candidates list plus the last exception per tier, so the design-ops team can reproduce the exact resolution path without access to production state or any PII — only the attendee_id and payload_hash leave the boundary, never the rendered fields.

Text rendering is sandboxed as part of this boundary: no raw HTML, JavaScript, or SVG is ever interpolated into a template, and all display strings have already passed the control-character and glyph-length coercion in the contract above. That sanitization is co-owned with the security boundary configuration, which draws the network trust boundary around rendering and dispatch so that an unsigned print job can never reach a spooler.

Performance & Memory Constraints Link to this section

Badge rendering is CPU- and allocation-bound, not I/O-bound, because the asset bundle is mounted read-only at pod startup. The constraints below are the ones that actually bite during a registration surge.

Component Constraint Mitigation
Template cache Unbounded caching leaks memory as event count grows Bounded LRU (e.g. 128 compiled templates); evict by version, never by mutation
Font subsetting Full embedding inflates every PDF and CPU cost Pre-subset fonts into the read-only bundle at build time; renderer embeds subsets only
Worker concurrency (GIL) ReportLab draw calls are CPU-bound and hold the GIL Scale with process-based workers (one interpreter per core), not threads
Canvas lifetime Unclosed canvases leak file handles under sustained load managed_canvas context manager forces save() in finally
Asset bundle I/O Runtime disk reads contend during peak bursts Mount the bundle read-only at startup; zero runtime template I/O on the hot path
DLQ depth Silent checksum failures can flood the dead-letter queue Alert on DLQ depth and on fallback_level >= 2 rate; auto-pin event to the last stable tier

The GIL constraint is the one most often gotten wrong: because each render_badge call is a tight CPU loop, adding threads does not add throughput — it adds contention. Horizontal scaling is process-per-core, with each pod given a strict CPU and memory quota via the orchestrator so a single pathological template cannot exhaust a node.

Incident Triage Checklist Link to this section

When badges stop printing or the DLQ starts filling, work these steps in order. Target MTTR is under fifteen minutes.

  1. Confirm the symptom class. Check whether renders are failing or merely falling back: curl -s localhost:9090/metrics | grep badge_fallback_level. A spike in fallback_level>=1 means a primary template is broken but output is still flowing; a spike in TemplateExhausted means output has stopped.
  2. Inspect the dead-letter queue depth. redis-cli -n 3 LLEN dlq:badge:render. A climbing depth confirms exhausted chains are being diverted; peek the newest envelope with redis-cli -n 3 LINDEX dlq:badge:render 0 and read its fallback_level and last-exception fields.
  3. Verify the checksum registry. For the failing template, compare expected vs. on-disk: sha256sum /assets/templates/<template_id>@<version>.pdf against the registry entry redis-cli HGET template:checksums <template_id>@<version>. A mismatch means a bad or partial asset push.
  4. Hot-patch or pin. If a checksum is wrong, re-sync the correct asset into the read-only volume (no pod restart needed), or immediately pin the event to its last stable tier: redis-cli SET template:pin:<event_id> track_default EX 3600. Pinning forces every render to skip the broken tier.
  5. Rollback the template push if needed. If the bad asset came from a template deploy, roll the registry back to the prior version tag and let the LRU evict the poisoned entry: redis-cli HSET template:checksums <template_id>@<version> <previous_checksum> then redis-cli DEL template:cache:<template_id>.
  6. Validate recovery and drain. Confirm fallback_level and TemplateExhausted rates return to baseline, then hand the quarantined payloads to the async batch processing drain job to replay them through the now-healthy chain.

Frequently Asked Questions Link to this section

Why ReportLab instead of an HTML-to-PDF engine? Because badge layout must be deterministic and byte-reproducible for reprints and audits. HTML/CSS reflow depends on the renderer version, available fonts, and subtle box-model differences, so the same record can produce visually different badges across engine upgrades. ReportLab’s explicit coordinate model gives the same input the same bytes every time, which is what makes the payload_hash a valid idempotency key.

What happens if every fallback tier fails? The chain raises TemplateExhausted, the worker wraps the payload in a diagnostic envelope and routes it to the dead-letter queue, and the print queue keeps moving for every other attendee. Nothing is silently dropped — the envelope carries the full candidate list and per-tier exceptions so the record can be replayed once a template is fixed.

Can the renderer repair a bad payload on the fly? Only within the narrow, declared coercions in the contract — stripping control characters and clamping glyph length on display strings. It never invents an attendee_id, guesses a badge_type, or fills a blank name. Structural repairs belong upstream in the field mapping rules; the renderer’s job is to fail loudly on anything it cannot place safely.