# OneSpot: Giving the Office Parking Chat a Referee

Engineering case study by Muhammad Usman Mateen.
Canonical article: https://usmanmateen.com/research/onespot
Published 2026-10-07.

> The full text of the article. The figures on the page are not reproduced here.

At the office, some people pay for an allocated parking space, and on the days they're out it sits empty. The fix was a group chat: "Space 27 is free today", followed by a scramble of "I'll take it" replies. OneSpot is that same chat with a referee. It's a Microsoft Teams app where an owner releases a space for a day, it appears on a shared noticeboard, and a colleague claims it in one tap. If two people tap at the same moment, exactly one of them gets it. This is how that guarantee is built, from the domain rules down to PostgreSQL triggers, and what the first pilot changed.

OneSpot is a pilot candidate. It has been piloted in a developer Microsoft 365 tenant; production hosting and registration are the organisation's IT decisions and sit outside the repository.

## “Space 27 is free today”

Allocated parking is paid for, but a paid space is only useful on the days its owner comes in. On every other day it's an empty rectangle of tarmac that someone else would happily use. The workaround, like in most offices, was informal: post in a group chat when your space is free, and whoever replies first takes it.

That works until it doesn't. A chat can't stop two people taking the same space, because "first" means first to be *read*, not first to be sent. It keeps no history, so nobody can say who parked where last Thursday. And it tells whoever looks after parking nothing about who pays for which bay on which weekday.

Those three gaps became the brief: one system of record for allocations and one-day releases, a claim that can't be double-booked, and a noticeboard that stays in step with the database. The loop the whole product serves is four words long: *release → discover → claim → return*.

## Deliberately small

The first decision was what OneSpot is *not*. It isn't a parking-management platform. There are no payments, gates, number-plate cameras, sensors, maps, visitor or EV flows, analytics, or AI. People were already arranging parking in Microsoft Teams, so that's where the product lives. Nobody has to visit a separate website.

- *Personal chat* for every employee: Home, My parking, Available spaces and My bookings, all as Adaptive Cards with buttons to release, withdraw, claim and give up.
- *A noticeboard* in a group chat or a team channel, where each released space is a card with a Claim button. A shared conversation becomes the noticeboard the first time a Parking Admin uses OneSpot there; moving it later takes an explicit `@OneSpot noticeboard` command and a confirmation.
- *Parking Admin*, a personal Teams tab for HR and facilities: people, weekday allocations, spaces, identity linking, activity, and an Excel template for bulk edits.

Underneath, the parking domain knows nothing about Teams, Entra, SQL or the ORM. It's ports and adapters, and ESLint fails the build if the core imports infrastructure. Chat, the noticeboard and the admin tab all call the same application services, so each rule lives in exactly one place.

## One rule underneath everything

Most of OneSpot's correctness comes down to a single sentence, which the codebase calls the *effective-entitlement invariant*: a person has at most one effective parking entitlement per site per calendar date. An entitlement is either your own allocation for that day, *unless you've released it*, or a booking you've claimed.

It sounds like bookkeeping, but it answers every awkward question at once. Can an owner who released their space claim a different one? Yes, because releasing gave up their entitlement. Can they take their release back while holding someone else's space? No, that would make two. Can HR give somebody an allocation on a day they've already claimed a space? No, for the same reason. Try it below.

An earlier version had a narrower rule: an owner with an unclaimed release had to withdraw it before claiming anywhere else. Whether an owner could claim depended on whether their own release had been claimed, which left a documented three-way race between a claimant giving a space back, the owner withdrawing, and the owner claiming elsewhere. Restating the rule as a count of entitlements removed the special case, and a forced-concurrency test now replays that race.

## Two people, one tap

The moment the product exists for is the one the group chat got wrong: several people press Claim on the same space within milliseconds. Checking "is it still free?" and then inserting a booking is the classic race, because two requests can both see "free" before either writes.

OneSpot closes it with row locks taken in one fixed order. A claim first locks the *claimant's* own user row, then the *release* row. Every other claimant of that space queues on the same release row. When the winner commits, the next one in the queue wakes up, re-reads the release under READ COMMITTED, sees the booking, and gets a polite refusal. A partial unique index on (space, date) for active bookings sits behind the lock in case anything ever slips past it.

The integration tests don't hope for a race; they force one. A separate connection holds the contended lock, the test launches every claim, polls PostgreSQL's `pg_stat_activity` until every one of them is reported waiting on a lock, and only then lets go. The headline test does this with ten claimants, and asserts one booking and nine copies of *This space has already been claimed.*

### Why the lock order matters

Every transaction takes its locks in the same sequence and never goes back: the person, then the idempotency record, then the allocation, then the release, then the booking. Each transaction takes at most one person lock and only as its first lock, so two transactions can never each hold something the other is waiting for. The person lock is `FOR NO KEY UPDATE`, which doesn't block the foreign-key checks other people's rows make, so unrelated work never waits on it.

## Enforced three times

Application code is the first line of defence, not the only one. The entitlement invariant is enforced three times, independently:

- *Domain rules* in pure functions, used both by the commands and by the screens, so a card never offers a button the command would refuse.
- *A per-person lock* that serialises every operation that could add an entitlement for that person.
- *Database triggers* that take the same lock and re-count entitlements whenever a booking becomes active, a release stops being active, or an allocation changes, whatever code wrote the row.

The triggers exist for the code that hasn't been written yet: an operator script, a future admin feature, a hand-typed fix at two in the morning. A raw `INSERT` that would give someone two spaces fails with a named constraint, which the persistence layer translates back into a domain error. Serializable isolation was considered and rejected: it needs retry loops everywhere and produces less predictable errors than an explicit lock.

The rest of the schema follows the same idea. Copied columns are pinned to their parents by composite foreign keys, so a booking can't disagree with its release about which space or date it's for. Exclusion constraints stop two people holding one space on the same weekday. Whether a release is *claimed* isn't stored at all; it's derived from whether an active booking exists, so it can't drift.

## A double-click isn't two bookings

Locks stop two *people* colliding. They don't help when the same person's request arrives twice, which in Teams is routine: an impatient double-click, a flaky network, or Teams retrying an action it thinks timed out. Teams gives no identifier that stays the same across those retries, so OneSpot makes one.

Every button carries a nonce created when the card is rendered, and the command's idempotency key is `teams:<verb>:<nonce>`. Inside the transaction, after the person lock, the command looks up that key. If it has already run with the same input, it returns the stored result marked `replayed: true`, changes nothing and sends nothing. If the key comes back with *different* input, it's refused. Keys are scoped to the person, so two colleagues pressing the same shared button are still two separate commands.

## Teams can be down; parking can't

The first version sent notifications after the database commit. That's a quiet bug: if the process dies between the commit and the message, the booking exists and nobody is told. It was replaced with a transactional outbox.

Each command writes its event rows in the same transaction as the parking change, so an event exists exactly when the change does. Rejected commands and idempotent replays write none. A dispatcher in the bot process leases due events with `FOR UPDATE SKIP LOCKED`, delivers them in order, and retries failures with exponential backoff: ten seconds, then twenty, then forty, doubling each time under a one-hour cap. After eight attempts, about twenty-one minutes after the first, the event is parked as failed. Delivery is at least once and never affects the booking: a Teams outage delays the message, it doesn't undo the claim.

## What the pilot changed

The noticeboard started out editing its cards in place: when a space was claimed, its card changed from *available* to *claimed*. In the pilot, someone claimed a space, the database was correct, and the conversation looked as if nothing had happened. The edited card was far up the history, and nobody scrolls up.

So every change now does two things. It edits the original card, so the old Claim button can't go stale, and it posts a short announcement where people are actually reading. The second part needed care, because the outbox delivers at least once. Each noticeboard row records what the conversation has been told, `announced_status`, which only moves forward while the row is locked. Retries, races and redeliveries announce once. The first post of a space needs no announcement: the card introducing it is one.

Posts are also *state-based*. Delivery doesn't trust the event's idea of what happened; it re-reads the release's current state and makes the card match. A late, repeated or out-of-order event can therefore never show the wrong thing. If somebody deletes a card in Teams, OneSpot forgets it and posts a fresh one the next time the space can be claimed.

## Privacy by the shape of the payload

A shared noticeboard is read by a lot of people, so what it can say is limited by types rather than by good intentions. The payload for a shared update has fields for the site, space, date and status, and no field that could hold a name or a user id. Private messages are separate payloads addressed to one person.

The noticeboard does show who *holds* a space ("Claimed by Emma"), because colleagues need to know it's gone. That name never travels on the event. It's read from current state at render time, so a re-rendered card always names whoever holds the space now. The person who released it is never named. Whether claimants are named at all is one flag on the Teams adapter, which is where an organisation-wide privacy setting would attach.

## From a tap to a row, and back

Operationally, OneSpot is one Teams app, one Node process, one Docker image and one PostgreSQL database. Chat and the Parking Admin tab are two faces of the same process, on the same port; there is no second web service to deploy, secure or keep in step. Step through what happens when somebody presses Claim, or when HR opens the admin tab.

The split that matters is between the reply and everything else. The card that replaces the one Emma pressed comes back on the same HTTP request, rendered from the database after the commit. Every other message, the noticeboard announcement, her confirmation in her personal chat and the owner's "Space 27 has been claimed", goes through the outbox and out through the Bot Connector afterwards, authenticated with the bot's own credentials. If Teams is slow, the claim has already happened.

Cards carry ids, never facts. A button sends a verb, a release id and a nonce, and the payload is parsed against a strict schema, so a forged card with an extra user id or status field is simply invalid. In a shared conversation only Claim is accepted, because replacing a shared card changes what everyone sees.

## Who's asking: Microsoft Entra ID

OneSpot never sees a password. Everything it knows about who somebody is comes from Microsoft Entra ID, and one Entra app registration does three jobs. It is the bot's identity: its client id and secret authenticate inbound Bot Framework traffic and OneSpot's outbound calls to the Bot Connector. It is the API the admin tab asks for tokens to, with the Application ID URI `api://<public-hostname>/<client-id>` and a delegated scope, `access_as_user`, that the Teams clients are pre-authorised for. And its tenant is the boundary: activity from a tenant outside the allow-list gets an unsupported-account message and no parking.

The tab's token is checked by the server, not trusted from the browser. Its signature is verified against the tenant's own Entra signing keys, the issuer must be that tenant, the audience must be OneSpot's API, and the delegated scope must include `access_as_user`; an application role isn't accepted in its place. What survives is two GUIDs. The object id is only unique within a tenant, so the identity OneSpot stores is `entra:<tenant-id>:<object-id>`, lower-cased. Names, UPNs and email claims in the token are ignored.

### What it deliberately doesn't ask Microsoft for

- *No Microsoft Graph.* There's no Graph client and no Graph permission in the Teams package: no directory search, no mailbox, no files. The tab's single sign-on token is for OneSpot's own API, not Graph.
- *No reading the room.* The bot doesn't request permission to read channel or chat messages. In a shared conversation it only sees what's addressed to it, and its own buttons.
- *No borrowed roles.* A Microsoft 365 or Teams administrator isn't a Parking Admin. That role is a flag in OneSpot's own database, granted by an existing Parking Admin (the very first by an operator command) and re-checked on the server for every admin call. Mapping an Entra group to it is deliberately left for later.

Recognising people still needs one join between Entra and HR's records. The first time someone opens OneSpot's personal chat, the bot reads that member's work account from the Bot Connector and uses it, once, to match a record HR created. After that the Entra identity is the key, and the email is just a label.

### Two people called James Smith are two people

An early version refused first use when an unlinked record had the same display name and no work email, to avoid creating a duplicate. It meant a name could stop a real, verified employee from getting in. Now the verified person is always created, the pair is flagged as a possible duplicate in Parking Admin, and an administrator resolves it. The same thinking runs through the Excel template HR uses: the visible columns are a name and five weekdays, and a hidden OneSpot ID column means two people with the same name can't be mixed up.

## Where it runs

The repository builds everything needed to run OneSpot and creates none of the infrastructure. That line was drawn on purpose: the Azure subscription, the production Entra app registration, the container runtime and the database belong to the organisation's IT and security teams, and the repository documents what they need to provide rather than assuming it.

- *One image.* A multi-stage Dockerfile produces a non-root image that runs the compiled bot with `node dist/bot.js`, alongside `GET /health` and a `GET /ready` that checks PostgreSQL. Seed and reset scripts aren't in it.
- *One pipeline.* Azure Pipelines runs Verify (format, lint, typecheck, the tests against a throwaway PostgreSQL, and the build), a scanning stage that is still a placeholder for an approved scanner, the image build (pushed only once a container registry is configured) and the Teams app package. It has no deploy stage.
- *Secrets as environment variables.* The app has no Key Vault SDK; the documented approach is to inject secrets, preferably from Azure Key Vault, as environment variables at runtime.
- *Strict TLS to the database.* Production PostgreSQL is a private Flexible Server, and the client verifies both the certificate chain and the hostname, the equivalent of libpq's `verify-full`, so `sslmode=require` can't quietly skip the check.
- *Migrations as a job.* Schema changes run once, as a separate step, not every time the bot starts.

## Where it stands, and what I'd keep

OneSpot is a pilot candidate. Chat, the noticeboard and Parking Admin are implemented and have been piloted in a developer Microsoft 365 tenant. `pnpm check` (lint, format, typecheck, tests and a production build) is the gate, and it's green. Hosting, the production app registration and the catalogue listing are decisions for the organisation's IT and security teams, and nothing in the repository creates them.

- *Write the invariant down as one sentence.* Every rule about releasing, claiming and allocating turned out to be a consequence of it, and the special cases that didn't fit were the ones hiding bugs.
- *Put the last line of defence in the database.* Locks and triggers protect the data from code paths that don't exist yet.
- *Test races by forcing them.* Holding the lock until PostgreSQL reports every contender waiting turns a flaky timing test into a deterministic one.
- *An event is a nudge, not a fact.* Re-reading state on delivery made late and repeated events harmless.
- *Pilots find what tests can't.* The backend was right; the conversation just didn't show it. One short announcement fixed a problem no assertion would have caught.

The smallest lesson was about Teams itself. Setting an optional manifest property uploaded cleanly, then Teams stored a blank value beside it and failed validation of its own stored copy. The fix was to leave the property out, and the packager now refuses empty values before anything is uploaded.

---

The car park, race, outbox, noticeboard and data-flow animations are illustrations of mechanisms in the source; they are not recordings of production traffic. Card wording, error messages, retry timings, lock order, routes, token checks and test counts are taken from the repository; the test breakdown is its recorded baseline of 553, and later commits have added more. Production infrastructure is shown as the documented target, which the repository does not create. Space numbers and names (Space 27, Emma, James) are the fictional ones its own fixtures and demos use.

Microsoft, Microsoft Teams, Microsoft Entra, Microsoft 365, Azure, Azure DevOps, Excel, PostgreSQL, Node.js, Docker and the other names and logos shown are trademarks of their respective owners, used only to identify the technologies OneSpot is built with. The Microsoft Entra and Azure icons are Microsoft's architecture icons, used as their terms permit in architecture diagrams and documentation. This article is not affiliated with, endorsed or sponsored by any of them.
