Designing a Fail-Closed Access Gate in TypeScript

A worker walks up to a turnstile on a construction site. Should it open?

That is a smaller question than it looks, and getting it wrong is more expensive than it looks. Say yes when their OSHA-30 lapsed two days ago, or when their site badge was revoked this morning, and you’ve let an out-of-compliance worker onto a live jobsite — the exact thing a workforce-compliance platform exists to prevent. Say no when you shouldn’t and you’ve stalled a $100M project at the gate.

So the decision has to be right, and — just as important — you have to be able to prove afterward why it was made. This post walks through how I build that: a pure, fail-closed decision function, and an immutable audit trail. The code is TypeScript; the ideas are stack-agnostic.

The gate is a pure function

The single most useful decision I made was to keep the gate free of the world. It doesn’t read a database. It doesn’t call Date.now(). It doesn’t hit the network. It takes everything it needs as arguments and returns a decision:

export function evaluateAccess(input: GateInput): AccessDecision {
  const { worker, site, certifications, now } = input;
  // ...decide...
}

now is a parameter, not a call to the system clock. certifications are passed in, not fetched. That one constraint buys three things:

  1. It’s trivially testable. No mocks, no fake timers, no test database. You hand it a worker with an expired credential and assert DENY. Every edge case is a one-line test.
  2. It runs anywhere. The same function can execute in a Node service, or — because it has no dependencies — in an edge worker sitting right next to the turnstile, where you want the decision even if the network is having a bad day.
  3. It’s honest. When all the inputs are explicit, you can’t accidentally hide a database lookup or a clock read inside the “business logic.” The decision is a function of its inputs and nothing else.

Fail closed, always

Here is the rule that everything else serves: when the gate is unsure, it denies.

A gate that opens when it’s confused is worse than no gate, because it looks like enforcement. Every ambiguous case routes to DENY:

That last one matters more than it seems. The function is total: it never throws. A thrown exception is a landmine, because somewhere up the stack there’s a try/catch that logs the error and — if nobody thought hard about it — falls through to opening the door. By making bad input return a DENY with a reason instead of throwing, there is no error path that can accidentally become an open gate.

const nowMs = Date.parse(now);
if (Number.isNaN(nowMs)) {
  return deny([{ code: "INVALID_REQUEST", message: "Evaluation time is missing or invalid." }]);
}
if (!site)   return deny([{ code: "UNKNOWN_SITE",   message: "Site is not registered." }]);
if (!worker) return deny([{ code: "UNKNOWN_WORKER", message: "Worker is not on record." }]);

Every DENY carries a reason

An access decision that’s just a boolean is useless to the human standing at the gate. So the gate checks each of the site’s required credentials and returns a structured reason for each failure:

for (const certType of site.requiredCerts) {
  const held = workerCerts.filter((c) => c.type === certType);
  if (held.length === 0) {
    reasons.push({ code: "MISSING_CERT", certType, message: `Required credential ${certType} is not on file.` });
    continue;
  }
  if (held.some((c) => isValidAt(c, nowMs))) continue; // any valid one is enough
  // none valid — report the most actionable failure (expired > revoked)
}

Two details worth calling out. First, “any valid credential of this type is enough” — a worker who renewed their OSHA-30 has both the old (expired) and new (valid) record, and the new one should let them in. Second, the expiry boundary is deliberate and strict: a credential expiring at 17:00:00 is invalid at exactly 17:00:00. now < expiresAt, not <=. On a safety credential you don’t grant a grace second, and — more importantly — you write that decision down as a test so nobody “fixes” it later.

Now the kiosk can display “OSHA-30 expired on 2026-06-29” instead of a red X, and the worker’s foreman knows exactly what to renew.

The audit trail is the actual product

Here’s the part teams underinvest in until an incident forces them to: the record of what happened is often the real deliverable. After a safety event, an OSHA visit, or an insurance claim, someone will ask “who was on site, and were they cleared?” The answer has to be trustworthy — which means it can’t be something an admin (or an attacker with a database connection) can quietly rewrite.

So every check-in — allowed and denied — is appended to a hash-chained log. Each entry commits to the hash of the entry before it:

const body = { seq, at, workerId, siteId, deviceId, decision, reasons, prevHash };
const hash = createHash("sha256").update(canonical(body)).digest("hex");

Because each row’s hash depends on the previous row’s hash, editing or deleting any historical entry breaks the chain from that point forward. A verify() pass re-derives the chain and reports the first seq that doesn’t match. You can’t flip a past DENY to an ALLOW, and you can’t make a check-in disappear, without it being detectable. (This is the same tamper-evident pattern I used for legal invoice numbering in a compliance product called Sequent — the mechanics transfer directly.)

The log is append-only by construction: there is no update or delete method to call.

What this is not

This is a focused slice, not a platform. Real deployments need Postgres behind the store, actual badge/biometric identity resolution at the device, per-project multi-tenancy and RBAC, an alerting job that emails a subcontractor’s admin at 30/14/7 days before a credential lapses, and a durable, periodically-anchored audit chain. But the shape holds at every size: keep the decision pure, fail closed, explain every denial, and write down what you did in a way you can’t quietly rewrite.

That’s the whole discipline. A boring function and an honest ledger — which is exactly what you want standing between an unqualified worker and a live site.