Skip to content

feat: RFID fobs as an identity — link a fob in a short window, resolve it for zaehlwerk #321

Closed

Description

@patrick-hermann-sva

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.

  1. A signed-in player opens „Fob koppeln“ on their profile. That opens a window of about 60 s.
  2. The player holds the fob to the reader at the table and presses their button (the normal check-in path).
  3. 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.
  4. 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

Open

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_KEY the route does not exist, and a log line says so
  • ADR-0025 written, and docs/adr/index.md updated

Activity

  1. patrick-hermann-sva commented on Oct 13, 2026

    @patrick-hermann-sva
    ContributorAuthor

    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/results takes the result without operator_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:

    • A new 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_by may be empty, but only for checkin: check (reported_by is not null or entered_via = 'checkin'). Today the column is not 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.
    • What proves the match: the result carries both check-in ids, and Schmetterpause checks that they exist, are its own, belong to home_id/away_id on different halves, are unused and have not expired. zaehlwerk cannot claim it for two arbitrary ids.
    • Every other path keeps the operator. A match started on zaehlwerk's page with names from the dropdown still needs one.
    • It stays 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.
    • Definition of Done: checkin rows are shown in their own column, like scoreboard, and not counted in the verdict. A null reported_by must not count as a reporter in count(distinct reported_by).

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions