Badge Reprint Handling: Reprinting Without Minting a Second Credential
An attendee walks up to the help desk with a snapped lanyard, a coffee-stained badge, or a badge that printed with last year’s logo, and asks for a new one. Handing them a fresh print is trivial; doing it safely is the entire problem this stage owns, and it is one of the stages inside the On-Site Check-In & Print Routing section. A badge is not just a name card — its barcode or QR is an access credential that opens session doors, unlocks catering, and gates the exhibitor floor. If a reprint simply generates a second scannable code, the event now has two live credentials for one paid registration: the lost badge, wherever it is, still works, and so does the replacement. That is how one ticket becomes two people in a capped session, or how a badge fished out of a bin grants a stranger entry. This component exists to guarantee that reprinting is a supersede, not a duplicate: the moment a replacement is issued, the previous credential must stop working.
The distinction this stage enforces is between the durable registration (the attendee, unchanged) and the disposable credential (the specific scannable artifact, versioned and revocable). What a credential is even allowed to open — which scanners honor which scopes at which doors — is delegated to security boundary configuration; this stage decides only which credential version is currently valid and publishes that verdict. The end-to-end operational walkthrough of issuing a replacement while invalidating the old one lives in reprinting without duplicating access credentials. This page defines the reprint contract, the versioning-plus-revocation implementation, and the observability an on-call operator needs when a scanner reports a credential it should not.
Scope Boundary Link to this section
Reprint handling is a narrow authority: it decides which credential version is current and makes that switch atomic. It does not define access policy and it does not run scanners — it only publishes the fact that a version was superseded.
| In-Scope (this component owns) | Out-of-Scope (delegated to adjacent stages) |
|---|---|
Accepting a reason-coded ReprintRequest and validating it against the current credential |
Verifying who the attendee is — a resolved attendee_id is assumed as input |
| Minting the next credential version and marking the prior version superseded | Defining what a credential scope may open — owned by security boundary configuration |
| Writing the superseded credential id to the revocation list with a reason and timestamp | Distributing that list to edge scanners and enforcing it — owned by security boundary configuration |
| Guaranteeing supersede + revoke are one atomic transaction | Rendering and routing the replacement PDF to a printer — owned by print queue orchestration |
| Idempotency so a double-clicked reprint issues one version, not two | The full at-desk operational drill — owned by reprinting without duplicating credentials |
The controller never mutates the registration, never issues raw scanner policy, and never prints. It advances a version counter and appends a revocation entry inside a single transaction, which is what lets an auditor months later reconstruct exactly which physical badge was live at any instant.
Data Contract & Schema Link to this section
A reprint crosses the boundary as a ReprintRequest. The contract is built around two ideas the rest of the stage depends on: a reason code from a closed set (because “why was this reprinted” drives both fraud analytics and whether the old credential is revoked or merely re-rendered), and supersede semantics — the request must name the version it believes is current, so a stale help-desk tablet cannot silently revoke the wrong generation.
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
class ReprintReason(str, Enum):
LOST = "lost" # physical badge missing — MUST revoke the old credential
DAMAGED = "damaged" # torn/unreadable — revoke, the artifact may still scan
MISPRINT = "misprint" # wrong data/layout — revoke, never left the desk
NAME_CHANGE = "name_change" # attendee data changed — revoke, re-render from source
REISSUE_SAME = "reissue_same" # duplicate copy, same version — does NOT revoke
class ReprintRequest(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
request_id: str = Field(..., min_length=8, max_length=64, pattern=r"^[a-zA-Z0-9\-]+$")
attendee_id: str = Field(..., min_length=8, max_length=36, pattern=r"^[a-zA-Z0-9\-]+$")
supersedes_version: int = Field(..., ge=1) # version the desk believes is current
reason: ReprintReason
operator_id: str = Field(..., pattern=r"^op-[0-9]{1,5}$")
requested_at: datetime
@property
def revokes_prior(self) -> bool:
# Only REISSUE_SAME leaves the old credential valid; everything else supersedes.
return self.reason is not ReprintReason.REISSUE_SAME
request_id is the idempotency token: a help-desk operator who double-taps “reprint,” or a client that retries on a flaky venue network, must produce exactly one new version, so the controller keys its dedupe on this. supersedes_version encodes an optimistic-concurrency check — the request asserts which generation it is replacing, and the controller rejects it if reality has already moved on, which stops two operators at two desks from each superseding version 1 and racing to issue conflicting version 2s. reason is a closed enum because the reason is policy: revokes_prior derives directly from it, so reissue_same (an attendee who wants a spare identical copy) does not blacklist the credential they are still holding, while lost absolutely does. operator_id records accountability — every credential invalidation is attributable to a person — and frozen=True makes the request an immutable record of intent suitable for the audit trail.
Deterministic Implementation Link to this section
The core invariant is that issuing the new version and revoking the old one are a single atomic step. If they can happen apart, there is a window where either two credentials are valid (revoke lagged the issue) or zero are (issue lagged the revoke and the attendee is now locked out). The implementation models each attendee’s credential as a monotonically versioned record and treats supersede as a compare-and-swap on the version, bundled with the revocation-list append in one transaction. Versioning — rather than mutating a single credential row in place — is what makes the history reconstructable: every generation the attendee ever held is retained, exactly one is marked current, and the revocation list is the negative index scanners consult.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Credential:
credential_id: str # cred-{attendee_id}#{version} — globally unique per generation
attendee_id: str
version: int
status: str # "current" | "superseded"
@dataclass
class CredentialStore:
current: dict[str, Credential] = field(default_factory=dict) # attendee_id -> current
revoked: dict[str, dict] = field(default_factory=dict) # credential_id -> {reason, at}
history: list[Credential] = field(default_factory=list) # append-only, all versions
processed: dict[str, str] = field(default_factory=dict) # request_id -> credential_id
class SupersedeConflict(Exception):
"""The request tried to supersede a version that is no longer current."""
def issue_reprint(store: CredentialStore, req: ReprintRequest) -> Credential:
"""Atomically mint the next credential version and revoke the prior one."""
# 1. Idempotency: a retried request returns the credential it already produced.
if req.request_id in store.processed:
return next(c for c in store.history
if c.credential_id == store.processed[req.request_id])
live = store.current.get(req.attendee_id)
if live is None:
raise SupersedeConflict(f"no current credential for {req.attendee_id}")
# 2. Optimistic concurrency: the desk must be superseding the TRUE current version.
if live.version != req.supersedes_version:
raise SupersedeConflict(
f"stale supersede: desk saw v{req.supersedes_version}, current is v{live.version}"
)
# 3. Atomic supersede: issue vN+1 and revoke vN together, never apart.
new = Credential(
credential_id=f"cred-{req.attendee_id}#{live.version + 1}",
attendee_id=req.attendee_id,
version=live.version + 1,
status="current",
)
superseded = Credential(**{**live.__dict__, "status": "superseded"})
store.current[req.attendee_id] = new
store.history.append(new)
if req.revokes_prior:
store.revoked[live.credential_id] = {
"reason": req.reason.value,
"revoked_at": req.requested_at.isoformat(),
"operator_id": req.operator_id,
}
store.processed[req.request_id] = new.credential_id
# replace the superseded snapshot in history for an accurate audit view
store.history = [superseded if c.credential_id == live.credential_id else c
for c in store.history]
return new
def is_admitted(store: CredentialStore, credential_id: str) -> bool:
"""Scanner-side check: a credential opens a door only if it is not revoked."""
return credential_id not in store.revoked
if __name__ == "__main__":
store = CredentialStore()
v1 = Credential("cred-att-20000002#1", "att-20000002", 1, "current")
store.current[v1.attendee_id] = v1
store.history.append(v1)
req = ReprintRequest(
request_id="rq-lost-0001", attendee_id="att-20000002",
supersedes_version=1, reason=ReprintReason.LOST,
operator_id="op-42", requested_at=datetime.fromisoformat("2026-07-16T09:12:00"),
)
v2 = issue_reprint(store, req)
assert v2.version == 2 and v2.status == "current"
assert is_admitted(store, "cred-att-20000002#2") is True # new badge works
assert is_admitted(store, "cred-att-20000002#1") is False # lost badge is dead
# A retried double-tap returns the SAME v2 — no third credential is minted.
assert issue_reprint(store, req).credential_id == v2.credential_id
# A second desk trying to supersede the now-stale v1 is rejected, not raced.
stale_req = ReprintRequest(
request_id="rq-other", attendee_id="att-20000002", supersedes_version=1,
reason=ReprintReason.LOST, operator_id="op-9",
requested_at=datetime.fromisoformat("2026-07-16T09:13:00"),
)
try:
issue_reprint(store, stale_req)
raise AssertionError("expected SupersedeConflict")
except SupersedeConflict:
pass
print("OK current:", store.current["att-20000002"].credential_id,
"revoked:", list(store.revoked))
Three properties keep this correct under a busy help desk. First, supersede and revoke share one transaction, so a scanner never sees a moment with two valid credentials or none. Second, idempotency on request_id means a retried or double-submitted reprint yields the same version — the failure mode where an impatient operator taps twice and mints two live badges is closed at the source. Third, the optimistic supersedes_version check turns a lost-update race between two desks into a clean SupersedeConflict that the operator resolves by re-reading current state, rather than a silent divergence where two “current” credentials exist. Note that reissue_same deliberately skips the revocation append: an attendee who wants an identical spare keeps the one in their hand working, which is the one case where a second valid artifact is intended.
Production Debugging & Observability Link to this section
Every reprint emits a structured event, because the revocation decision is exactly the thing a security incident will ask you to reconstruct. Each carries attendee_id, the old and new credential_id, the reason, the operator_id, and whether the prior credential was revoked. The single most important derived metric is the count of live credentials per attendee, which must never exceed one for any reason other than reissue_same.
{
"level": "info",
"event": "credential_superseded",
"trace_id": "reprint-att-20000002",
"attendee_id": "att-20000002",
"old_credential_id": "cred-att-20000002#1",
"new_credential_id": "cred-att-20000002#2",
"reason": "lost",
"revoked_prior": true,
"operator_id": "op-42"
}
In a Datadog or ELK pipeline, index reason as a monitored dimension (a spike in lost at one door can indicate theft or a broken lanyard supply, not just clumsiness), operator_id to spot an operator who is reprinting without revoking, and revoked_prior so a false on any reason other than reissue_same fires immediately — that is the exact signature of a duplicate-credential bug. Correlate trace_id forward to the door-scanner logs owned by security boundary configuration: a credential_superseded event should be followed, within the revocation-list propagation window, by the old credential_id being denied at the edge. The highest-value alarm is a reconciliation query — count(status:current) GROUP BY attendee_id HAVING count > 1 — because a value above one is a live second credential walking the floor, and it should page rather than merely log.
Performance & Memory Constraints Link to this section
The controller is transaction- and propagation-bound, not compute-bound: minting a version is cheap, but the revocation list has to reach every edge scanner fast enough that the old badge is dead before the attendee leaves the desk.
| Component | Constraint | Mitigation |
|---|---|---|
| Revocation-list propagation | An edge scanner caching a stale list keeps admitting a revoked credential | Push deltas over a pub/sub channel, cap cache TTL to seconds, and fail-closed on unknown-but-recent credentials at high-security doors |
| Revocation list size | Grows monotonically over a multi-day event with heavy reprints | Store as a compact set keyed on credential_id; scope it to the live event and archive on teardown |
| Supersede transaction contention | Two desks reprinting the same attendee serialize on one version row | Lock per attendee_id; the optimistic-concurrency check makes the loser fail fast rather than block |
| Idempotency key store | request_id dedupe map grows with reprint volume across replicas |
Back it with Redis SET NX EX, not process memory, with a TTL that outlives the retry window |
| Offline scanner fallback | A door scanner that loses uplink cannot fetch new revocations | Ship signed short-lived credential tokens so an offline scanner can still reject anything past its validity, degrading safe |
Propagation latency is the constraint that actually causes incidents: the version switch is instantaneous at the center, but a scanner on a stale cache is a window in which the lost badge still opens doors. Keeping the revocation channel a low-latency push — and having high-security doors fail closed on a credential they cannot verify — is what shrinks that window to seconds.
Incident Triage Checklist Link to this section
When a scanner reports a credential it should not, or the live-credentials-per-attendee alarm fires, work these in order. Target MTTR is under fifteen minutes.
- Identify the duplicated registration. Run the reconciliation query for any attendee with more than one current credential:
psql -c "SELECT attendee_id, count(*) FROM credentials WHERE status='current' GROUP BY 1 HAVING count(*)>1;". Each row is a live second credential to hunt down. - Confirm the revocation actually landed. For the offending old credential, check the list:
redis-cli SISMEMBER revoked:credentials cred-att-20000002#1. A0means the revoke never happened or was rolled back — the supersede was not atomic. - Check propagation to the edge. Confirm the scanners have the current list version:
curl -s http://scanner-north.local:8080/health | jq '.revocation_list_version'against the center’s current version. A lagging scanner is admitting on stale data — force a refresh. - Contain at the door. If a revoked credential is actively being used, blacklist it directly at the affected scanners rather than waiting for propagation:
redis-cli PUBLISH revocation:urgent cred-att-20000002#1, which pushes an immediate deny. - Repair the atomicity gap. If step 2 showed a missing revoke, re-run the supersede for the attendee through the idempotent
issue_reprintpath so the old credential is added to the list without minting yet another version; because it is idempotent onrequest_id, this is safe to retry. - Validate recovery. Re-run the step-1 query and confirm zero attendees with multiple current credentials, then scan the old badge against a door and confirm a deny. The full at-desk procedure and its rollback are documented in reprinting without duplicating access credentials.
Frequently Asked Questions Link to this section
Why version the credential instead of just reprinting the same barcode? Because reprinting the same code creates two physical artifacts carrying one live credential, and the event has no way to tell which one is in whose hands. Versioning issues a new, distinct code and marks the prior one superseded, so the revocation list can turn off exactly the badge that was lost while the replacement works. The registration underneath never changes — only the credential generation advances — which keeps the attendee’s history intact while making each physical badge individually revocable.
What stops two help-desk operators from each reprinting the same attendee?
The supersedes_version optimistic-concurrency check. Each request asserts which version it believes is current; the first supersede advances the attendee to version 2, and the second operator’s request — still naming version 1 — fails with a SupersedeConflict instead of racing to create a conflicting second version 2. The operator re-reads the current state and sees the reprint already happened, rather than silently minting a duplicate.
Does reissue_same really leave the old badge valid on purpose?
Yes, and it is the one deliberate exception. When an attendee wants a spare identical copy — a second badge to keep at their hotel, or a re-print of an undamaged badge — revoking the one in their hand would lock them out. So reissue_same skips the revocation append and both artifacts of that version remain valid. Every other reason code (lost, damaged, misprint, name_change) supersedes and revokes, because in those cases a second working credential is a security defect, not a convenience.
Related Link to this section
- On-Site Check-In & Print Routing — the parent section that frames how reprint handling connects to check-in, queueing, and printer failover on the event floor.
- Reprinting Without Duplicating Access Credentials — the at-desk operational drill and rollback for issuing a replacement while the old credential is revoked.
- Security Boundary Configuration — defines what a credential scope may open and owns distributing and enforcing the revocation list at the door scanners.
- Print Queue Orchestration — renders and routes the replacement badge PDF once this stage has issued the new credential version.
- Check-In Kiosk Sync — decides when a recognized-again attendee should trigger a reprint at all, upstream of the supersede logic here.
Up: On-Site Check-In & Print Routing — the section these on-floor stages sit inside.