← 3. Architecture · Contents · Next → 5. Access rights
4. A decision¶
Everything in this chapter happens inside the cabinet, 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 reader bus produces a frame:<br/>bus address, credential type, value"]
B --> C{"Is that address<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(door mode, candidates, now)"]
G -->|"granted"| H["Pulse the relay"]
G --> I["Queue the passage,<br/>granted or not"]
H --> I
I --> J["Compare the elapsed time<br/>against the decision budget"] The order matters. The relay fires before the passage is written. Writing to the queue is a durable write with an fsync; putting it in front of the relay would make a driver wait for a disk.
The frame¶
A reader reports three things: which address on the bus it sits at, what kind of credential it read, and the value.
On a cabinet with READER_BUS=osdp, that comes off an RS-485 line as an OSDP reply. On the local stack, READER_BUS=simulated accepts the same information as one line of text over TCP:
<bus address>:<credentialType>:<value>
1:badgeNumber:BDG-0001
Both feed the same channel. Nothing above the bus can tell which one produced the frame. That is what keeps the hardware adapter replaceable.
A frame from an address nobody declared is refused, not guessed. An address appears in the cabinet's local wiring or it does not exist; a cabinet that guessed would open the wrong lane.
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 function exists on both sides, in the cabinet and in the cloud, and it has to agree exactly: the cloud hashes the normalised value before sending it, and the cabinet hashes the normalised read to look it up. A disagreement would mean a plate that is provisioned and never matches.
The normalised value is then hashed with SHA-256. That hash is the lookup key, and the only form the cabinet stores. The plaintext value exists in exactly one place: the outbound event, so that a passage is readable in the cloud.
The rules¶
decide takes the door's mode, the candidate rights, and the instant of the read. It is a pure function: no clock of its own, no I/O, no allocation.
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 -->|"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¶
validFrom is inclusive, validTo is exclusive. A right valid from 08:00 to 18:00 covers 08:00 and does not cover 18:00.
A right whose window has not started yet is refused as EXPIRED_RIGHT, the same as one that has lapsed. The cabinet is answering "you have a right here, it does not apply now". That is the useful answer 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 on ambiguity would strand a legitimate driver at the gate because a colleague's right had been revoked. The order the rights come back in does not change the outcome, and the passage names the granting holder, not the denied one.
An expired grant does not rescue a denied right: if the only right covering now denies, the answer is DENIED_RIGHT.
A refusal still names who was refused¶
Whenever the credential is known, the decision carries the matched right, even on a refusal. A denial that names its holder is far more useful than one that does not.
The only case with nobody to name is UNKNOWN_CREDENTIAL: there is genuinely no one to point at, and the passage is recorded with the raw value and a null user.
The two special modes still journal¶
unlocked and locked change what the door does, not whether the read is recorded. A lane propped open during roadworks still produces a passage for every credential presented, including credentials nobody knows.
Why? A mode is a temporary operational decision. If it also switched off the journal, the period covered by it would be a hole in the record, and that hole would appear exactly when an investigation needed it.
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 |
REMOTE_OPEN | opens | info | An operator opened it from a screen |
UNKNOWN_CREDENTIAL | refused | warn | This cabinet 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 |
The level is what lets an operator filter noise from trouble without reading every row. Openings are info; anything a driver would call about is warn.
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 does not.
A cabinet holds only active rights. It is a cache, not an archive. Purging a right the instant it expires would turn a badge presented one minute late into UNKNOWN_CREDENTIAL: "we have never heard of you", when the truth is "your right ended last night".
So lapsed rights are kept for a grace window, EXPIRED_RIGHT_GRACE_DAYS, seven days by default, and dropped past it. The purge runs when the controller starts.
The local stack sets the window very high, so the expired case stays demonstrable at any time. Production uses the default.
The decision budget¶
Every decision is timed by the cabinet itself, from the frame arriving to the passage being queued. Past DECISION_TIMEOUT_MS (300 ms by default) it logs a warning naming the elapsed time and the budget.
It is a budget, not a timeout: nothing is cancelled. A decision that took too long still opened the door; what matters is that somebody knows the site is running close to the edge.
Frames are handled one at a time and in order. A controller drives real doors, and interleaving two decisions on the same lane is a hazard, not a throughput win.
make load # a burst of passages, with p50, p95 and the worst one measured
Opening from a screen¶
An operator can open a lane from the dashboard. It goes through the same runtime as a badge, so it produces the same kind of record: REMOTE_OPEN, with no credential and no user invented for it.
flowchart LR
OP["Operator clicks Open"] --> API["POST /doors/{id}/actions/open"]
API --> AUD["Control plane audit:<br/>WHO asked"]
API --> CMD["MQTT command<br/>force_open"]
CMD --> RT["The cabinet's runtime"]
RT --> REL["Relay pulses"]
RT --> LOG["Activity log:<br/>WHAT happened: REMOTE_OPEN"] Two journals, two questions. The activity log answers "what happened at this door"; the control plane audit 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. Losing the opening from the log entirely would leave a car through a door with nothing to explain it. Both entries exist, and neither pretends to be the other.
Opening a door this cabinet does not drive is refused, and nothing is logged for an opening that did not happen.
Seeing it for yourself¶
make demo # every branch, asserted
make badge VALUE=BDG-0001 # a known badge
make badge VALUE=BDG-9999 # an unknown one
make badge VALUE=1-ABC-123 ADDR=2 TYPE=licensePlate
The decision rules have their own unit tests in edge/wardn-domain/src/decision.rs. They include the carpool case, the half-open interval, and the fact that ordering does not change the outcome.
A decision needs rights. Where do they come from?
Next → 5. Access rights