Repository navigation
feat: RFID fobs as an identity — link a fob in a short window, resolve it for zaehlwerk #321
Description
Players check in at a table half with a key fob: an RC522 at the zaehlwerk hub reads it, then the player presses the button on their half (stuttgart-things/zaehlwerk-firmware#63). zaehlwerk claims the half and starts the match once both are taken (stuttgart-things/zaehlwerk#92). What zaehlwerk cannot know is who a fob is. That is this issue.
Fobs only. Company badges are not used, for corporate reasons. A badge held against the reader by mistake still yields a UID, which is what the rules below are for.
Model
Nothing new in the schema for the identity itself: a fob is a row in identities (ADR-0003) with provider = 'rfid'. A player may have several fobs, like several devices. Merging players (ADR-0017) moves fobs with the other identities.
Proposal: store a keyed hash, not the UID. subject = HMAC-SHA256(RFID_UID_KEY, uid). A raw card number in the database is something that identifies a card outside this app. A keyed hash only matches against what comes in. RFID_UID_KEY is an environment variable (invariant 2). If it is unset, the feature is off and a log line says so.
Linking a fob
Linking is explicit and short-lived. An unknown UID is never stored outside of it.
- A signed-in player opens „Fob koppeln“ on their profile. That opens a window of about 60 s.
- The player holds the fob to the reader at the table and presses their button (the normal check-in path).
- zaehlwerk asks to resolve the UID. The fob is unknown, and a window is open, so it is linked to that player. The answer is that player, which means linking also checks them in.
- The window closes after one fob or when it times out. The page shows that the fob is linked.
Only one window may be open at a time. A second player who opens one while another is open gets told to wait, so a fob cannot land on the wrong player. Where the window is kept has to survive more than one replica: a small table with an expiry, as an additive migration (invariant 8), not Redis (invariant 3).
The profile lists linked fobs, with the date linked and last used, and lets the player remove each one.
Resolving for zaehlwerk
A route under the existing /api, behind the same bearer token as /api/players, /api/operators and /api/results (ADR-0015, ADR-0023). For example:
POST /api/identities/rfid/resolve {"uid": "…", "half": "left"} → 200 {"player_id": "…", "checkin_id": "…"} or 404.
- Known → the player id. A check-in is recorded (player, half,
rfid, time), and its id comes back. Per ADR-0024 (docs(adr): let checked-in matches report without an operator #322) a result names both check-ins instead of an operator, so the check-in has to be created here, not asserted by zaehlwerk. - Unknown, window open → link, record the check-in, then the same answer.
- Unknown, no window →
404. Nothing is stored, and neither the UID nor its hash is logged. The log line says "unknown fob" and nothing that identifies the card.
The UID goes in the body, not the path, so it does not end up in access logs.
The handler returns JSON. That is in line with the convention: zaehlwerk is the external consumer, as with the other /api routes. The lookup goes through a repository interface (invariant 5). The rfid provider sits with the others behind the auth package (invariant 4). It is not an Authenticator, though: it does not sign in a browser, it only names a player for a trusted server.
ADR
The identity model changes and a new way in is added, so this needs an ADR in German (number 0025; 0024 is the operator decision in #322):
- the fob as an identity, and the keyed hash instead of the UID;
- linking only in an explicit window;
- that an unknown UID is neither stored nor logged;
- the relationship to zaehlwerk's ADR-0007 (zaehlwerk#92), which describes the other side of the same call.
Decided
- The operator rule. Checked-in matches report without an operator: ADR-0024, docs(adr): let checked-in matches report without an operator #322. The check-ins recorded above are the proof.
Open
- More than one table. The window and the check-in should belong to a table once there is more than one (Several tables: record, schedule, book — in three stages, once a second table stands in the office #280, Record equipment and table side per match #60). For now there is one.
Acceptance
- A fob linked in the window resolves to that player afterwards
- Every successful resolve records a check-in and returns its id
- An unknown fob outside a window:
404, no row, no UID or hash in the log - Two windows cannot be open at once
- A player can see and remove their fobs
- Without
RFID_UID_KEYthe route does not exist, and a log line says so - ADR-0025 written, and
docs/adr/index.mdupdated
Activity
patrick-hermann-sva commentedon Oct 13, 2026
Decided: checked-in matches need no operator
Written down as ADR-0024 in #322. It took 0024 because it came first, so the fob ADR of this issue is now 0025. The ADR firms up the proposal below: the result names both check-in ids, and Schmetterpause checks them against its own records.
When both players checked in by fob or by phone,
POST /api/resultstakes the result withoutoperator_id. ADR-0014 asks for an operator because a machine has no identity, so a person who watches has to vouch for the result. Here the two players vouched for themselves, at the table, each with their own fob or their own signed-in phone. That is stronger evidence than a name picked from a list.What the ADR says:
entered_via = 'checkin'. The provenance stays readable, in the same way Record how a result was entered, so the measurement can tell a tournament from a normal day #71 and ADR-0015 kept it. Extending the CHECK constraint is additive (invariant 8).reported_bymay be empty, but only forcheckin:check (reported_by is not null or entered_via = 'checkin'). Today the column isnot null. Relaxing a constraint touches no data. The alternatives are worse: the home player as reporter is exactly what ADR-0014 removed, and a "Zählwerk" observer account would make a machine look like a person.home_id/away_idon different halves, are unused and have not expired. zaehlwerk cannot claim it for two arbitrary ids.pending, as ADR-0015 says for the test phase. Since there is no reporter, either player may confirm, and that rule already exists. Counting checked-in results straight away would be a separate decision.checkinrows are shown in their own column, likescoreboard, and not counted in the verdict. A nullreported_bymust not count as a reporter incount(distinct reported_by).Generated by Claude Code
docs(adr): let checked-in matches report without an operator (#322)
docs(adr): make a fob an identity, linked only in a window (#324)
fix(db): count checkins and fob_link_window in db-counts.sql
fix(domain): document the two table halves
feat(fobs): link a key fob in a window and resolve it for the Zählwerk (