← 3. Architecture · Contents · Next → 5. Access rights
4. A decision¶
Everything in this chapter happens inside the controller, in a few milliseconds, with no network involved.
The path of a badge¶
flowchart TD
A["A credential is presented<br/>at a reader"] --> B["The wire it came in on<br/>produces a frame:<br/>which reader, and what it read"]
B --> C{"Is that reader<br/>declared here?"}
C -->|"no"| X["Ignored, and said so.<br/>Guessing would open the wrong door"]
C -->|"yes"| D["Resolve the reader to its door"]
D --> E["Normalise the value,<br/>then hash it"]
E --> F["Look up every local right<br/>holding that credential"]
F --> G["Decide, from the door's mode<br/>and the candidates found"]
G -->|"granted"| H["Open the door,<br/>the way that door is wired"]
G --> I["Record the passage,<br/>granted or not"]
H --> I
I --> J["Check the elapsed time<br/>against the decision budget"] The order matters. The door opens before the passage is written down. Writing it down means writing all the way to the controller's disk, not just to memory — the write isn't considered done until the disk itself confirms it, which takes a moment. Doing that before opening the door would make a driver wait on it.
Every decision is answered at the reader, including the refusals. A door that opens is its own answer; a door that refuses is silent, and somebody standing in front of a reader that does nothing cannot tell a refusal from a broken reader. So the reader shows green for two seconds and beeps once on a grant, red and three longer beeps on a refusal — over the bus for a peripheral, on its LED and buzzer pins for a reader on the header, and not at all for one where nobody wired either.
The frame¶
A frame says two things: which reader produced it, and the bytes that reader read. Not what kind of credential it is — that comes later, from the reader's own format.
"Which reader" is its wiring, and a controller listens on all three kinds at once (3. Architecture):
| Wiring | Written | How the frame arrives |
|---|---|---|
| An OSDP peripheral | bus:3 | The controller polls address 3 on the RS-485 line it shares with the others, and that poll comes back carrying a card |
| A reader on the header | wiegand:17,27 | The reader pushes the card's bits down two of the controller's own pins, unasked. Nothing polls it |
| The controller itself | onboard | Reader and controller are one box, and there is no wire between them. No hardware adapter reads one yet; the simulated bus presents on it, so the path is exercised end to end (17. The all-in-one reader) |
Several readers sharing one wire is why a bus address exists at all: one line carries a badge reader at the entrance and a camera at the exit without either mistaking the other's read for its own. A reader on the header has its own pair of pins instead, which answers the same question.
On the local demo stack the same frame arrives as one line of text over a network connection, naming the wiring it came in on, which is easier to type by hand while trying things out:
<wiring>:<value>
1:BDG-0001 a bus address, in the short form
bus:1:BDG-0001 the same read, written out
wiegand:17,27:15091A40 a card on the cabinet's own header
onboard:BDG-0001 the cabinet reading for itself
All of them feed the same path afterwards. Nothing beyond this point can tell which wire produced the frame — that is what lets one controller be a reader itself and drive three others.
A frame from a reader nobody declared is refused, not guessed. A wiring either appears in the controller's own topology, or it does not exist as far as the controller is concerned. Guessing would risk opening the wrong lane.
A PIN is a frame like any other¶
A keypad does not send a PIN. It sends 1, then 2, then 3, then 4, then the enter key — one keystroke at a time, whichever wire it is on.
The controller assembles them, per reader, and produces one frame when the entry is complete: PIN:1234. An entry nobody finishes is dropped after PIN_ENTRY_IDLE_S, so somebody who starts typing and walks away never has their first digits counted against the next person's.
The prefix is what lets one reader carry both a card slot and a keypad: its format branches on it, exactly as a combined QR-and-badge reader branches on QR:. The builtin Badge or PIN, by prefix format does that. Behind any format that does not branch, a PIN is reported as whatever that format reports, usually a badge number nobody holds.
Normalising the value¶
A camera reads 1-ABC-123. The provisioning system was given 1 abc 123. The same plate, spelled three ways.
licensePlate* → every non-alphanumeric character removed, then upper-cased
anything else → trimmed, then upper-cased
"1-ABC-123" as licensePlate → "1ABC123"
"1 abc 123" as licensePlate → "1ABC123"
"BDG-0001" as badgeNumber → "BDG-0001"
Plates lose their separators because a camera's idea of formatting is not the provisioning system's. A badge number keeps its dashes, because there they are part of the identity.
The same rule runs on both sides. The server applies it to a value before storing it; the controller applies it to a value it just read, before looking it up. The two have to agree exactly, or a plate that was correctly provisioned would simply never match at the door.
The normalised value is then run through a one-way scramble before it is ever compared or stored — deliberately slow to compute, because badge numbers and licence plates only come in so many combinations, and a fast method would let someone with a stolen controller's database try all of them in reasonable time. That scrambled form is the only one the controller ever keeps. The real value exists in exactly one place outside the credential itself: the message sent to the server, so a passage is still readable by a human there.
The rules¶
A decision is made from three things:
- the door's mode
- every locally held right that shares the credential just read
- the instant of the read
Nothing else — no clock of its own beyond that instant, nothing fetched, nothing left over from the read before.
flowchart TD
M{"Door mode"}
M -->|"locked"| L["Refused: DOOR_LOCKED<br/>(and it still records who tried)"]
M -->|"unlocked"| F["Opened: UNLOCKED_MODE<br/>(and it still records every read)"]
M -->|"free_access"| K{"Is the credential<br/>known here?"}
K -->|"no"| KU["Refused: UNKNOWN_CREDENTIAL"]
K -->|"yes"| KO["Opened: FREE_ACCESS_MODE<br/>(granted, denied or expired — doesn't matter)"]
M -->|"normal"| N{"Is the credential<br/>known here?"}
N -->|"no"| U["Refused: UNKNOWN_CREDENTIAL<br/>nobody to name"]
N -->|"yes"| C{"Does a covering right<br/>grant it?"}
C -->|"yes"| S["Opened: SUCCESS"]
C -->|"no"| V{"Is any right<br/>covering now?"}
V -->|"yes, but it denies"| D["Refused: DENIED_RIGHT"]
V -->|"none covers now"| E["Refused: EXPIRED_RIGHT"] Validity is half-open¶
A right's window includes the instant it starts, but not the instant it ends. A right valid from 08:00 to 18:00 covers a badge presented at exactly 08:00, and no longer covers one presented at exactly 18:00.
A right whose window has not started yet is refused the same way as one that has already lapsed: EXPIRED_RIGHT. Either way the controller is saying "you have a right here, it just does not apply right now" — which is the useful thing to tell someone standing at a door.
One credential, several rights¶
A credential can legitimately be held by more than one person. The case that drives the rule is a carpool: one plate, a driver whose right grants, a passenger whose right denies.
At least one covering, granting right opens the door.
Refusing whenever there's any ambiguity at all would strand a legitimate driver at the gate just because a colleague's right had been revoked. The order the rights happen to come back in never changes the outcome, and the record names the granting holder, never the denied one.
An expired grant does not rescue a denied right, either: if the only right covering this instant denies, the answer is DENIED_RIGHT.
A refusal still names who was refused¶
Whenever the credential is known at all, the decision still carries the matching right, even when it refuses. A denial that names who it turned away is far more useful than one that does not.
The one case with genuinely nobody to name is UNKNOWN_CREDENTIAL: there is no right to point at, and the record is kept with the raw value read and no person attached to it.
free_access, and how it differs from unlocked¶
Both skip checking whether a right currently grants, denies, or has expired. What they still disagree on is what counts as "known":
unlockedopens for any read at all, including a credential this controller has never heard of. It is "prop the door open".free_accessopens only for a credential this controller holds a right for — granted, denied, or expired no longer matters — and still refuses one it has genuinely never provisioned, the same refusalnormalgives. It is closer to "anyone who was ever issued a badge here gets waved through", with no time window or revocation slowing them down.
The three special modes still keep a record¶
unlocked, free_access and locked change what the door does, not whether the read gets written down. A lane propped open during roadworks still produces a record for every credential presented, including ones nobody recognises.
Why? A mode is a temporary, deliberate operational choice. If it also switched off the record, the period it covered would be a hole in the history — and that hole would show up exactly when an investigation needed it filled.
The reasons, and their levels¶
| Reason | Door | Level | Meaning |
|---|---|---|---|
SUCCESS | opens | info | A covering right granted it |
UNLOCKED_MODE | opens | info | The door is in unlocked |
FREE_ACCESS_MODE | opens | info | The door is in free_access, and the credential is known |
REMOTE_OPEN | opens | info | An operator opened it from a screen |
UNKNOWN_CREDENTIAL | refused | warn | This controller holds no right for that credential |
EXPIRED_RIGHT | refused | warn | A right exists, but none covers this instant |
DENIED_RIGHT | refused | warn | A covering right explicitly denies |
DOOR_LOCKED | refused | warn | The door is in locked |
DOOR_NOT_COMMISSIONED | refused | warn | Nothing is wired to the door: no relay terminal, no OSDP node |
UNREADABLE_FRAME | refused | warn | The reader's format could not resolve the frame to a type at all |
The level is what lets an operator filter noise from real trouble without reading every single row. An opening is info. Anything a driver would actually call about is warn.
DOOR_NOT_COMMISSIONED outranks the mode, including unlocked: a door with no output cannot open, and reporting an opening that never happened would put a line in the log that gets believed. It is deliberately distinct from DOOR_LOCKED — that is a decision somebody took, this is a commissioning step nobody has taken yet, and the two are fixed by different people.
UNREADABLE_FRAME is the odd one, because it is not about the person at the door. It means no branch of the reader's format matched, or the definition itself is unusable: a jammed format rather than a stranger. A frame whose leaf extracts no value is not this — it still has a type, so it is an UNKNOWN_CREDENTIAL with an empty value. A Wiegand frame that fails its parity check lands there, which is worth knowing before chasing the wrong fault.
EXPIRED_RIGHT versus UNKNOWN_CREDENTIAL¶
The distinction is worth its own paragraph, because it is the difference between a support call that resolves in thirty seconds and one that drags on.
A controller only holds active rights — the current version of each one, not a history of everything a person was ever granted. It is a cache, not an archive. Dropping a right the very instant it expires would turn a badge presented one minute late into UNKNOWN_CREDENTIAL, "we have never heard of you", when the truth is much less alarming: "your right ended last night".
So a lapsed right is kept around for a short grace window — seven days by default — before it is finally dropped. The cleanup runs each time the controller starts.
The demo stack sets that window much higher than seven days, so the expired case stays easy to demonstrate at any moment. A real deployment uses the default.
The decision budget¶
Every decision is timed by the controller itself, from the frame arriving to the passage being queued for the server. Past 300 milliseconds by default, it logs a warning naming how long it actually took and what the budget was.
It is a budget, not a timeout: nothing gets cancelled partway through. A decision that took too long still opened the door. What matters is that somebody finds out the site is running close to the edge before it becomes a real problem.
Reads are handled one at a time and in the order they arrive. A controller drives real doors, and letting two decisions on the same lane overlap is a hazard, not a speed win.
Opening from a screen¶
An operator can open a lane from the dashboard. It goes through the same logic as a badge would, so it produces the same kind of record — REMOTE_OPEN — just with no credential and nobody invented to attach it to.
flowchart LR
OP["Operator clicks Open"] --> API["The dashboard asks the API"]
API --> AUD["Recorded: WHO asked"]
API --> CMD["Sent on to the controller"]
CMD --> RT["The controller's own decision logic"]
RT --> REL["Relay pulses"]
RT --> LOG["Recorded: WHAT happened, REMOTE_OPEN"] Two records, two questions. The activity log answers "what happened at this door". A separate audit trail answers "who asked for it". Putting the operator's name in the first would blur the line between a credential presented at a reader and a person clicking a button on a screen. Leaving the opening out of the log entirely would leave a car through a door with nothing to explain it. Both records exist, and neither one pretends to be the other.
Asking to open a door a given controller does not actually drive is refused, and nothing gets logged for an opening that never happened.
Seeing it for yourself¶
make demo # every branch above, actually exercised and checked
This presents a series of credentials against a running local stack — known, unknown, expired, shared between a granted driver and a denied passenger — and checks that each one produces the reason this chapter says it should.
A decision needs rights. Where do they come from?
Next → 5. Access rights