Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Field note

Talking to door controllers: what ISAPI teaches about typed outcomes

A boolean cannot carry what an access-control panel answers. Three measured facts (a failed write with HTTP 200 and no status, an acknowledged write that changed nothing, a reused digest nonce the panel refused) turned a 27-member interface whose every write returned a boolean into a closed set of typed outcomes, a classification order, a read-back rule and a fresh challenge per request; asking which way a door fails added a deadline on anything that unlocks one.

Filed
2026-10-16
Topic
Shipping products
Reading
9 min
Tags
#access-control#devices#isapi#dotnet#resilience

Part 1 of 2 Devices at the edge

  1. Talking to door controllers: what ISAPI teaches about typed outcomes
  2. Panel passwords are sealed to the host that uses them
Next Panel passwords are sealed to the host that uses them

The claim

A device integration should answer with a closed set of typed outcomes, never a boolean. It should read the transport's status first and the device's own status second, treat a body with no status as proof of nothing, verify every write that is meant to converge by reading the object back, present a fresh authentication challenge on every request, and put a deadline on anything that unlocks a door. None of this is theory. Most of these rules came from a measurement against a real panel, taken while Hexavera Access was being re-planned in October 2026; the deadline came from asking which way a door fails.

Context

Hexavera Access reaches access-control hardware over ISAPI, the vendor's HTTP API, which authenticates with HTTP Digest (RFC 7616) and answers in XML or JSON. The first connector had one interface with 27 members, every write returning a boolean. A body without a statusCode counted as success. An unknown door action was sent as "open". The canonical models carried the vendor's storage layout, and two of the three capability flags defaulted to true and were never set.

A probe on 2026-10-05 covered three panels of a widely used vendor, two fingerprint terminals (DS-K1T502DBFWX-C and DS-K1T805MBFWX, firmware V1.9.1) and a door controller (DS-K2702WX-E1(P), firmware V1.7.2), with ledgers that recorded every exchange: write-and-restore on the two terminals, read-only on the controller. Three facts decided the redesign.

  1. A failed fingerprint write answered HTTP 200 with no statusCode. The connector would have called it a success.
  2. A write of the reader's default verification mode answered statusCode 1 and changed nothing. The vendor's documentation lists statusCode as "0 (OK), 1 (OK), 2 (Device Busy), 3 (Device Error), 4 (Invalid Operation), 5 (Invalid XML Format), 6 (Invalid XML Content), 7 (Reboot Required)", so 1 reads as success. The device acknowledged and did not apply.
  3. A probe client that reused a digest nonce was answered once and refused the second time, with HTTP 401 and the message "Triggered by illegal login." That the reuse caused the refusal is an inference, since another client dialled the same panel from the same address; the standard allows it, since RFC 7616 says "an implementation might choose not to accept a previously used nonce" and describes the nonce count as the way a server detects replay: "if the same nc value is seen twice, then the request is a replay."

A boolean true could therefore mean applied, acknowledged and ignored, or failed with a 200. That is not a contract.

The outcome set

Every operation returns exactly one of these, and the set is closed. A connector that meets an answer it cannot classify returns Indeterminate for a write and NotDelivered for a read, never success.

Outcome Meaning What the site does with it
Applied(observed) The device accepted the request and, for a convergent write, a read-back observed the desired state terminal success; stores what was observed
AlreadyInState The device already held the desired state terminal success
Refused(code, vendorDetail) The device understood and refused, or accepted and did not apply terminal; the device and item pair is blocked with its reason and an alarm
NotSupported(capability) The device lacks the capability terminal; the device's capability document is refreshed
AuthRejected(lockHint) Authentication failed after credentials were sent closes the device's gate; holds its commands; alarm
Busy(retryAfter) Importing, upgrading, or a search timed out on the device retries with jitter; never abandons a revocation
NotDelivered(transportFault) The request did not reach the device, or a read did not complete safe to retry; counts towards the device's breaker
Indeterminate(reason) A write may or may not have taken effect convergent: read back, apply again if needed; momentary: Unknown
TransportNotSecure The operation carries or returns a secret and the link is not verified TLS terminal; blocked, and the reason is shown

In C#, the set is an abstract record with one sealed record per case. The language cannot close a hierarchy for you, so the conformance suite has to: a connector that returns anything outside the set fails it.

public abstract record Outcome
{
    public sealed record Applied(string? Observed) : Outcome;
    public sealed record AlreadyInState : Outcome;
    public sealed record Refused(RefusalCode Code, string? VendorDetail) : Outcome;
    public sealed record NotSupported(Capability Capability) : Outcome;
    public sealed record AuthRejected(TimeSpan? LockHint) : Outcome;
    public sealed record Busy(TimeSpan? RetryAfter) : Outcome;
    public sealed record NotDelivered(TransportFault Fault) : Outcome;
    public sealed record Indeterminate(string Reason) : Outcome;
    public sealed record TransportNotSecure : Outcome;
}

Why not exceptions for refusals? Because a refusal is an outcome every caller handles, not an error; a full capacity table or an overlapping schedule is Tuesday, not a fault.

The classification order, and the read-back rule

  1. A transport fault before the request is sent is NotDelivered. A timeout or a reset after a write was sent is Indeterminate; after a read it is NotDelivered.
  2. Then the HTTP status. The vendor documents that when a request fails "the device will return the HTTP status code and ISAPI error code".
  3. Then the device's status fields: statusCode, subStatusCode, errorCode, the last one decimal on the panels and hexadecimal in the vendor's documents, so both are parsed.
  4. A body with no statusCode is not success by itself. For a read it must parse into the expected document. For a write it proves nothing.
  5. Every convergent write is verified by a read of the same object before it is Applied(observed). A write the device acknowledged and did not apply becomes Refused(NotApplied), with an alarm, and is not retried in a loop.

Only canonical values cross the boundary: door action and mode, direction, credential kind, event kind, verification factor, link trust mode, refusal codes. The raw vendorCode and vendorDetail travel for diagnostics, and nothing branches on them. Capabilities are read from the device, never from documents; the vendor says of its own tables that "the values shown in this document are only examples for reference". An unknown capability is null, never true.

A fresh challenge on every request

The connector's HTTP client keeps PreAuthenticate off. With it on, .NET sends the Authorization header "with subsequent requests ... without waiting to be challenged by the server". .NET 10's handler implements that for Basic only, so today the setting would not replay a Digest nonce; after fact three, that is not left to an implementation detail on a device that locks accounts: the terminals report issupIllegalLoginLock true. Each request takes the 401 challenge and answers it. A 401 after the handshake closes the device's gate and raises an alarm, with no retry loop, because a retry loop against a locking device is how you lock yourself out of a building.

The cost is measured: 211 requests to one terminal and 190 to the other in the write-and-restore probe, every one two exchanges, and not one 401 after the handshake.

Deadlines, and what fails which way

Every call carries a deadline, and the host runs one lane per device with one call in flight. Momentary commands (a door pulse) run at most once, carry a notAfter, and become Unknown when the result is lost; a lost pulse shows Unknown, never a second opening. Convergent commands (a person's cards, a schedule) are retried until terminal, superseded by newer generations, and read back.

Door modes split by which way they fail. A locking mode (a lockdown) converges with no deadline: it fails secure, and applying it late is still right. An unlocking mode (held open for an emergency or for deliveries) carries a notAfter, and a panel that could not apply it before then never applies it later: the command ends Refused(Expired), the desired mode returns to normal, and the operator is told. The reason is the one that decided it: a panel that returns hours after an emergency would unlock a door nobody is watching. An emergency open is also time-boxed and ends with an explicit restore.

One more measured fact shaped a rule here. On a terminal, a remote open was followed in the same second by "Remain Closed Stopped" in the event log, with the remote close seven seconds later. The event order is a fact and the cause is an inference, so a remote open on a door in lockdown is refused unless the operator confirms the release.

Secrets travel only over verified TLS

The terminals return person PINs in clear on read, and the vendor documents that fingerprint templates can be read back (a read not yet verified on the test panels). So PINs, duress and door codes and templates are written only over a link whose trust mode is verified TLS (the appliance's own CA, or a pinned leaf certificate), and no enumeration that returns PINs runs over anything else. A panel left on plain HTTP or on unverified TLS receives cards only and TransportNotSecure for the rest. The site's choice of a weaker mode is permitted, explicit, visible at startup and in the status surface, and it still covers cards, revocations and events.

What it cost

  • Two exchanges per authenticated request, accepted for the lock it avoids.
  • A read-back after every convergent write, which adds a read to every write of a person sync and makes Applied mean something.
  • A late unlock is dropped, not delayed; an operator decides again.
  • A conformance suite that every connector passes over a fake device built from captured, redacted payloads.

What a team can take from this

Before you design a device contract, probe the device with a ledger and keep every exchange. Then make the outcome type say what the device can actually answer, read the transport before the body and the body before you believe anything, and read back what you wrote. Decide for each command which way it fails, and give a deadline to the ones that fail open. And treat authentication as a thing a device can take away from you: fresh challenge, no retry loop, alarm.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Previous Pre-MVP: make it right, never complyBefore the first release there is nothing to stay compatible with. Code that adapts to older code, an older schema or stale development data is a defect, not a kindness. The rule, how it was found, where it has held and where it is shaping decisions not yet built, and the day it should end.Next SignalR between a cloud and its sitesA hub that pushes changes from a cloud to on-premises sites is a security boundary and a lifecycle problem: who may connect, what each connection hears, how long an admission holds, and how a client comes back after the server closes it. The design note: a service-client policy, a group per tenant, a fifteen-second sweep with the same check requests get, and a client that starts itself again because automatic reconnect does not follow a server-side Abort.

In this entry

  1. The claim
  2. Context
  3. The outcome set
  4. The classification order, and the read-back rule
  5. A fresh challenge on every request
  6. Deadlines, and what fails which way
  7. Secrets travel only over verified TLS
  8. What it cost
  9. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.