Architecture Decision Record
ADR-072: QR Badge Check-In and Points Gamification in ADC
Status
Accepted (2026-08-13). Amended (2026-08-14): ADC shipped two attendee-self-recorded scan surfaces
(sponsor booth visits and room self check-in), a third CheckInScope, a sixth earn rule, and a
leaderboard display-name erasure path. Every decision below still holds (the opaque credential, one
aggregate carrying a scope, index-as-anti-farming, the ADC-local ledger, the opt-in leaderboard); the
"organizers scan attendees, never the reverse" posture is the one that acquired a recorded exception,
amended in place in the Decision and Rationale below.
Context
ADC wanted two conference-day capabilities that turn out to be one mechanism. Organizers want to know who actually attended which session, which the schedule cannot tell them: a bookmark is an intention and feedback arrives from a self-selected minority. Attendees want a reason to show up to a session they did not plan on and to leave feedback afterwards, which is a rewards problem rather than a data problem.
Registration and door admission are not part of this. ADC sells tickets through TicketLeap, and TicketLeap already scans people through the door on the morning of the event. Rebuilding arrival check-in would produce two systems with two different opinions about who is in the building, and the one holding the money would be right. What TicketLeap cannot do is tell an organizer that 60 people sat in the third-track talk at 2pm.
That framing left a set of open questions with no recorded answer:
- What does the QR actually carry? A signed token (JWT or an HMAC over the user id) verifies offline and needs no lookup. An opaque handle carries nothing and requires the server.
- Who scans whom? An attendee scanning a code posted on a room door needs no staff, but a photograph of that door code lets anyone check in from the hallway. An organizer scanning an attendee's badge needs a person with a device.
- Is a session check-in the same kind of thing as an event check-in? They differ only by whether a session is named.
- Where do points live? A points ledger is generic infrastructure by shape, and ADR-042 and this workspace's practice both say reusable infrastructure belongs to MMCA.Common. But no second consumer exists: MMCA.Store has no loyalty feature today.
- Is a leaderboard compatible with an attendee's privacy expectations? Ranking people by name is a publication of attendance behavior.
The framework half of the mechanism (the QR component and the camera capability) is ADR-071. This record decides what ADC does with it.
Decision
The badge credential is an opaque, server-verified handle
AttendeeBadge (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:17-24)
is one row per user holding a single Guid Credential, minted on first read of /my-badge by a
get-or-create command rather than a query
(.../Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs). Two
unique indexes cover it, on UserId and on Credential
(.../Engagement.Infrastructure/Persistence/EntityConfiguration/AttendeeBadgeConfiguration.cs:31-36).
- The credential is
Guid.NewGuid()and explicitly notGuid.CreateVersion7()(AttendeeBadge.cs:65-68): a v7 value embeds a timestamp and orders monotonically, which is exactly wrong for a bearer value that must be unguessable. Regenerate()(:59-63) issues a new credential, which revokes every previously printed or screenshotted copy in one write.- The wire format is
mmca-adc:badge:{credential}(.../Engagement.Shared/CheckIns/BadgePayload.cs:15-20).TryExtractCredential(:30-45) accepts the prefixed form or a bare GUID, case-insensitively, and rejectsGuid.Empty, so the manual path can take a typed value and the scanner can reject a foreign QR by prefix.
Organizers scan attendees, with two recorded self-service exceptions
/check-in (.../Engagement.UI/Pages/CheckIn/CheckInScan.razor:1-2) is [Authorize(Roles = "Organizer")]
and its writes carry [HasPermission(EngagementPermissions.CheckInManage)]
("engagement:checkin:manage", .../Engagement.Shared/Authorization/EngagementPermissions.cs:23,
applied at .../Engagement.API/Controllers/CheckInsController.cs:75, :99, :179). The attendee's page
/my-badge is authenticated-only and displays, never writes. Every CheckIn row therefore records both
parties: UserId (.../Engagement.Domain/CheckIns/CheckIn.cs:31) and CheckedInByUserId (:49).
Two later endpoints on the same controller are the exception, and are deliberately built as one: they
are attendee scans of a printed QR and carry no [HasPermission] at all.
POST /checkins/sponsor-visits (CheckInsController.cs:128-143) records a booth visit behind
[FeatureGate(EngagementFeatures.SponsorVisits)] (:130), and POST /checkins/room-visits
(:159-174) checks the caller into whatever session a room is hosting behind
[FeatureGate(EngagementFeatures.RoomCheckIn)] (:161). Neither takes an attendee from the request:
the identity comes from the token, as on /my-badge, and the room endpoint resolves the session
server-side from the room plus a configured grace window rather than accepting a session id
(:145-158; CheckInSettings.RoomCheckInGraceMinutes, 15 by default, read at
.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:52). For these rows the two
parties are the same person, which the aggregate says outright (CheckIn.cs:46-49).
The scan surface adapts to the head rather than branching on platform, per ADR-071:
ScannerAvailable => Scanner.IsSupported (CheckInScan.razor.cs:36) gates the camera card
(CheckInScan.razor:68), while the manual attendee-search panel is always rendered, because on a web
or Windows head that search is the check-in surface (CheckInScan.razor:108-113). The scan loop
discards a non-badge QR and keeps scanning rather than failing (CheckInScan.razor.cs:125-129).
One CheckIn aggregate carrying a scope
CheckInScope is Event = 0 / Session = 1 / Sponsor = 2
(.../Engagement.Shared/CheckIns/CheckInScope.cs:14, :17, :23), and one aggregate covers all
three: EventId is always set (CheckIn.cs:37), SessionId only for session scope (:40), and
SponsorId only for sponsor scope (:43), with
CheckInInvariants.EnsureTargetMatchesScope(scope, sessionId, sponsorId) enforcing the pairing in the
factory (:102). Session check-in is the case ADC actually runs; event scope exists because the
model costs nothing extra to support and the door remains TicketLeap's job. Sponsor scope was added
later and cost exactly one nullable column plus one index, which is the property this section claimed
in advance.
Idempotency and anti-farming are the same index
A repeat scan is answered, not written: CheckInProcessor.FindExistingAsync returns the prior row and the
processor reports AlreadyCheckedIn = true without a write
(.../Engagement.Application/CheckIns/Services/CheckInProcessor.cs:59-68), so no second integration event
is published. Three filtered unique indexes are the backstop under a concurrent double scan
(.../EntityConfiguration/CheckInConfiguration.cs:49-62): (UserId, EventId) filtered to [Scope] = 0,
(UserId, SessionId) filtered to [Scope] = 1, and (UserId, SponsorId) filtered to [Scope] = 2
(:60-62), all three also excluding soft-deleted rows. The third one is what makes a shared sponsor
deep link worth nothing past the first scan.
The points ledger uses the same construction for a different purpose. PointsEntry
(.../Engagement.Domain/Points/PointsEntry.cs:31, properties :34-46) is append-only with no mutators
at all, is marked IAuditedEntity so that absence of an update is provable in the data rather than
merely asserted (:31), raises PointsEntryChanged on create (:96-101), and carries a
unique index on (UserId, ActivityType, SubjectKey)
(.../EntityConfiguration/PointsEntryConfiguration.cs:46-48). That one index is simultaneously the
idempotency guard for redelivered integration events and the anti-farming rule: because the subject key is
session:{id}, event:{id} or sponsor:{id}
(.../Engagement.Shared/Points/PointsSubjectKeys.cs:18-31, max 64 chars at :13),
asking five questions in one session earns the question award exactly once.
Points are ADC-local, with the extraction path pre-paid
IPointsAwarder is deliberately conference-agnostic:
AwardAsync(userId, activity, subjectKey, occurredOnUtc, ct)
(.../Engagement.Application/Points/Services/IPointsAwarder.cs:35-40) names no event, session, or
conference concept, and the record for that choice is in the interface itself (:12-17): the ledger is
built so it can MOVE to MMCA.Common the day a second consumer (Store loyalty) exists, not before.
PointsSettings(section"Points",.../Engagement.Shared/Points/PointsSettings.cs:15-40) carries the rule values:EventCheckIn25,SessionCheckIn10,SessionFeedback15,EventFeedback15,QuestionAsked5,SponsorVisit5 (:37),LeaderboardSize10 (:40).- Zero disables a rule.
PointsAwardershort-circuits onvalue <= 0and writes nothing (.../Points/Services/PointsAwarder.cs:44-49); an absent config entry binds to 0 and takes the same path. - The awarded
Pointsvalue is snapshotted onto the entry at award time (PointsEntry.cs:40), so the ledger records what a rule was worth then. - A duplicate is success, not failure: the pre-check returns
Result.Success()(PointsAwarder.cs:55-62) and a lost race is caught and also returns success (:73-80).
Award triggers come from three different mechanisms, chosen per event source:
| Activity | Trigger | Subject key | Delivery |
|---|---|---|---|
EventCheckIn (25) |
AttendeeCheckedIn, event scope |
event:{id} |
Outbox integration event |
SessionCheckIn (10) |
AttendeeCheckedIn, session scope |
session:{id} |
Outbox integration event |
SessionFeedback (15) |
SessionFeedbackSubmitted |
session:{id} |
Outbox integration event |
EventFeedback (15) |
EventFeedbackSubmitted |
event:{id} |
Outbox integration event |
QuestionAsked (5) |
SessionQuestionChanged, Added only |
session:{id} |
In-module domain event |
SponsorVisit (5) |
AttendeeCheckedIn, sponsor scope |
sponsor:{id} |
Outbox integration event |
The three AttendeeCheckedIn rows are one method mapping wire scope onto an earn rule
(.../Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:63-97, sponsor branch at
:86-92; PointsActivityType.SponsorVisit = 6). Room self check-in has no rule of its own: it writes
an ordinary session-scoped CheckIn (.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:101),
so it earns the SessionCheckIn row above and inherits its once-per-session cap.
AttendeeCheckedIn (.../Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:22-30) is
raised inside CheckIn.Create (CheckIn.cs:112-119), so the outbox captures it in the same transaction
as the row (ADR-003), and it carries Scope as a string so a new scope stays additive. SponsorId
proved that: it was added as an optional last parameter (:29), so consumers keep deserializing payloads
that predate it. It is ADC's first broker self-consumption: the Engagement service both publishes and
consumes it (.../Services/MMCA.ADC.Engagement.Service/Program.cs:287-296 for the reasoning, :297-303
for the registrations).
The two feedback events are new to the Conference module
(.../Conference.Shared/Sessions/IntegrationEvents/SessionFeedbackSubmitted.cs:19-24,
.../Conference.Shared/Events/IntegrationEvents/EventFeedbackSubmitted.cs:18-22) and are raised on the
answer-create path only, never on the BR-107 upsert-update path
(AddSessionQuestionAnswerHandler.cs:131-134, AddEventQuestionAnswerHandler.cs:109-112). One feedback
form produces one event per answer row, and the shared subject key collapses them to one award.
QuestionAsked rides the existing in-module SessionQuestionChanged domain event, filtered to
DomainEntityState.Added (.../Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:48-49),
keyed by session rather than by question (:74).
The leaderboard is opt-in, and opting in is a row
LeaderboardOptIn (.../Engagement.Domain/Points/LeaderboardOptIn.cs:32, :35) holds UserId and a
DisplayName snapshot, resolved server-side from the caller's token claims rather than accepted from the
request body (SetLeaderboardParticipationHandler.cs:142-161; the request carries only Participate).
Opting out soft-deletes the row (LeaveAsync, :118-139, active.Delete() at :133) and rejoining
reactivates it (JoinAsync, :84-89, the BR-135 pattern), so nobody's name is on the board without a
live opt-in. Erasure is a separate, irreversible promise: EraseDisplayName() (LeaderboardOptIn.cs:119-130)
overwrites the published name in place when the account behind it is erased, and it is driven by a fourth
broker consumer, UserDeleted -> UserDeletedPointsHandler (Program.cs:302), because the published
name is the one piece of personal data the Identity-side erasure cannot reach across the database
boundary (Program.cs:283-285). The row itself survives (anonymize-in-place, ADR-005). GetLeaderboard
(.../Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs) reads only opted-in users' entries, folds
totals in memory, orders by total then display name, and assigns distinct sequential ranks.
Engagement.CheckIn and Engagement.Points are feature flags enforced with [FeatureGate] at the
controllers (CheckInsController.cs:33, PointsController.cs:34, ADR-031), and the two self-service
surfaces added two more, gated per action rather than per controller so each printed artifact can be
retired on its own: Engagement.SponsorVisits (.../Engagement.Shared/EngagementFeatures.cs:37) and
Engagement.RoomCheckIn (:44). GDPR export is extended in the
same pass: user_engagement_export.proto gains points_entries, leaderboard_opt_in and
leaderboard_display_name (:34-41) plus an EngagementPointsEntryExportItem message (:63-80); the
check-in history followed as check_ins = 6 (:45) with an EngagementCheckInExportItem message
(:82-101) carrying scope, event, session and sponsor.
Rationale
- An opaque credential makes the server the only interpreter. A JWT or HMAC badge would verify
offline, but the scanning device is online by necessity (it has to write a check-in row), so offline
verification buys nothing. What it costs is real: a signed token is self-describing, so a screenshot
leaks whatever claims it carries; it cannot be revoked without a revocation list, which is a second
store; and it is long, which makes a denser QR that is harder to read off a dim phone screen. A GUID is
short, meaningless to anyone holding it, and revoked by one
Regenerate(). - Scanning direction decides who can cheat. A code posted at a room door is a photograph away from
remote check-in by anyone in the building or on social media. Making the organizer's device the scanner
moves the trust to a person holding a permission, and the record of who scanned lands on every row.
That is why the organizer scan remains the primary path, and it is unchanged.
Amended 2026-08-14: ADC now also ships the other direction, as a bounded exception rather than a
reversal. Sponsor booth visits and room self check-in are printed QRs the attendee scans, so the code
is shareable and the argument above applies to them in full. What changed is that the exposure is
now priced instead of avoided: the once-per-subject filtered unique index caps a leaked sponsor link at
one award per attendee (
CheckInConfiguration.cs:60-62), the award is deliberately small and zero-able mid-conference (PointsSettings.cs:32-37), each surface has its own kill switch (EngagementFeatures.cs:37,:44), and the row still records both parties even when they are the same person (CheckIn.cs:46-49), so a self-recorded row is identifiable as one rather than indistinguishable from an organizer scan. Both flags ship on (.../MMCA.ADC.Engagement.Service/appsettings.json:21-22), so the exception is live, not dormant: turning either off is a configuration change that restores the original posture for that surface. - Session-first respects the product that already owns the door. TicketLeap is the system of record for admission, and a second arrival check-in would create a disagreement rather than a capability. The scope enum leaves event check-in modelled and available without ADC asserting ownership of it.
- One aggregate with a scope beats two aggregates that differ by a nullable column. Splitting
EventCheckInandSessionCheckInwould duplicate the factory, the integration event, the idempotency rule, the query surface and the migration for a distinction that a single enum expresses. The filtered unique indexes give each scope its own exact constraint anyway. - The anti-farming rule and the idempotency rule are genuinely the same rule. "Award this user once for this subject" is what makes a redelivered broker message safe and what makes a fifth question worth nothing. Expressing it once, as a unique index, means the two cannot drift apart, and the database is the arbiter rather than handler code.
- ADC-local is the honest place for the ledger today. Moving it to MMCA.Common now would add a public API surface and a lockstep release obligation (ADR-016) for a single consumer, and the shape of a second consumer is guesswork until Store actually needs loyalty. The cost of waiting was paid up front instead: the awarding vocabulary carries no conference nouns and the subject key is an opaque string, so the move is a project relocation rather than a redesign.
- Opt-in is the only defensible default for a leaderboard. A ranked list of names is a publication of who attended what. Snapshotting the display name at opt-in additionally keeps the board query out of Identity, so the reward surface does not become a second read path onto user records.
- Points are earned by facts the system already publishes. Check-in, feedback and question-asked are events that exist for their own reasons; the ledger is a consumer of them, so gamification adds no new write path to the features it rewards.
Trade-offs
- The badge credential is a bearer value. Anyone who photographs an attendee's screen can be checked
in as that attendee. The mitigations are that a badge scan is organizer-side, that every row records who
scanned, and that
Regenerate()invalidates a leaked code, but the value itself carries no proof of possession. QuestionAskedis at-most-once and says so. It rides an in-process domain event dispatched after commit, so a crash in that window silently loses one five-point award (SessionQuestionSubmittedPointsHandler.cs:23-32, whose body is additionally wrapped in a best-effort catch). Promotion to an integration event is a one-file change per side, deliberately deferred: five points do not justify an outbox row per question.- The feature flags gate the surface, not the ledger.
[FeatureGate]sits on the controllers and the two self-service actions, and no Engagement handler implementsIFeatureGated, so turningEngagement.Pointsoff hides the pages and the API while the event consumers keep accruing entries. That is recoverable (the ledger is append-only and correct) but it is not what "off" usually means. - Rule values are snapshotted, so history is mixed-rate. Changing
SessionCheckInfrom 10 to 15 does not restate earlier entries, which is right for an audit ledger and confusing on a leaderboard where two attendees with identical activity can hold different totals. - Duplicate-key detection is message-text matching.
DuplicateKeyDetection.IsDuplicateKeywalks the inner exception chain looking for "duplicate key", "UNIQUE KEY constraint" or "unique index" (.../Engagement.Application/Common/DuplicateKeyDetection.cs:35-48, rationale in its remarks:12-31) because the Application layer references neither EF Core nor SqlClient, and it deliberately does not match SQL Server error numbers 2601/2627. Layer purity is bought with a provider-dependent string comparison; the pre-check makes it a rare path, not a hot one. It started private toPointsAwarderand was lifted into a shared helper once a second lose-the-race handler needed it, soPointsAwarder(:74) andSetLeaderboardParticipationHandler(:103) now match on one copy of the wording rather than two. - The leaderboard folds totals in memory.
GetLeaderboardHandlerreads the opted-in users' entries and sums them client-side, because the Application layer has no EF Core to push aGROUP BYdown with. It is bounded by the opt-in population and fine at conference scale; it is not a design that survives a much larger ledger. - Broker self-consumption for an in-service concern.
AttendeeCheckedInis published and consumed by the same Engagement service, so an in-service award takes a broker round-trip. That is the price of keeping the award path identical to the cross-module ones (feedback arrives from Conference) and of leaving Engagement extractable, but it is a real hop for a local fact. - A once-per-subject rule caps engagement as well as farming. The second question an attendee asks in a session earns nothing, which is the anti-farming rule working as designed and also a small disincentive at exactly the moment a room is warming up.
- The export carries the activity as a number.
activity_typeis anint32rather than a proto enum (user_engagement_export.proto:70, with the reasoning at:64-69), because proto3 forces a zero member and 0 is reserved as "unset" on the C# side, so a reader of the raw export sees3rather thanSessionFeedback. The check-in export item repeats the trade forscope(:82-86).
Related
ADR-071 (the framework halves this consumes: the QR component on
/my-badge and the scanner capability behind /check-in),
ADR-003 (the outbox path AttendeeCheckedIn and the two feedback events
take, and the at-least-once delivery the unique index makes safe),
ADR-021 (the broker-dedup sibling; the points ledger's unique index is
a stronger, persisted form of the same guarantee),
ADR-020 (engagement:checkin:manage and
engagement:points:view-overview as capability grants rather than roles),
ADR-031 (the four Engagement.* gates on this surface, including the
two per-action ones, and the 404-not-403 posture), ADR-005 (the GDPR
export the points ledger, the check-in history and the leaderboard opt-in now extend, and the
anonymize-in-place rule EraseDisplayName() follows).