← RESEARCHCase study15 MIN READ

2026 · 15 min read · Engineering case study · Workplace tool

OneSpot logo

OneSpot

Giving the Office Parking Chat a Referee

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.

By Muhammad Usman Mateen

242526272829
ThursdayJames is working from home
Parkinggroup chat

OneSpot

✅ Space 27 claimed

Thursday 24 September

Claimed by Emma

OneSpot

✅ Space 27 claimed by Emma · Thursday 24 September.
The whole product in one loop. The owner releases a day, the noticeboard offers it, one tap claims it, and the allocation comes back to its owner the next day. Card text is OneSpot’s own; the car park is an illustration.

release → discover → claim → return · 1 Node process · PostgreSQL 17 · 550+ automated tests · no Microsoft Graph

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.

01

“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.

Before OneSpotIllustration · the problem

James

Space 27 is free today

Emma

I'll take it

Sarah

I'll take it!

Tom

…who actually got it?
272628?
A chat orders messages by when they're read, not when they're sent. Two people can both believe they replied first, and nothing records who parked where.

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.

02

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.

03

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.

One entitlement per person, per site, per dayFrom the code · rules and messages verbatim
2718yoursSarah’s · released
your entitlements on Thursday1 / max 1

Try Claim Space 18 first, then release your own space and try again.

You are James, and it's Thursday. Space 27 is yours; Sarah has released Space 18. Try taking two spaces at once. Every refusal here is the domain's own error code and message, and the same rule is checked again by a PostgreSQL trigger.

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.

04

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.

Ten people press Claim at onceFrom the tests · concurrency.test.ts
Space 27🔒 row lockspace_releases · FOR UPDATE
1 booking9 refused9 × This space has already been claimed.
Each car is one claim request. They all reach the release row together; the first to lock it books the space, and each one after re-reads the row and is refused. The integration test forces exactly this: it holds the lock, waits until PostgreSQL reports every claim blocked, then lets go, and expects one booking and nine refusals.

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.

booking-service.ts
async claimRelease(actor, input, options = {}) {  const request = parseInput(claimReleaseInputSchema, input);  const { idempotencyKey } = parseCommandOptions(options);  return this.deps.unitOfWork.transaction(async (repositories) => {    const claimant = await lockActor(repositories, actor); // lock order 1    return executeOnce(repositories, { actorUserId: claimant.id, idempotencyKey,      command: 'claim-release', request, codec: claimCodec }, async () => {      // Lock order 3: the release row.      const release = await repositories.releases.findById(request.releaseId, {        lock: 'update',      });      if (!release) throw new DomainError('RELEASE_NOT_FOUND');      const activeBooking = await repositories.bookings.findActiveForRelease(release.id);      const claimantDay = await loadDayEntitlementFacts(repositories, { ... });      assertCanClaim({ claimant, release, activeBooking, claimantDay, ... });      const booking = await repositories.bookings.create({ ... });      await repositories.events.append({ type: 'space-claimed', ... });      return { booking, created: true };    });  });}
Claiming a space, abridged. Lock the claimant, run once per idempotency key, then lock the release row. Concurrent claimants of one space queue on that row; whoever arrives after the winner re-reads it and is refused.

05

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 same rule, three timesFrom the code · simplified
1. Domain rulesassertCanClaim() · pure functionsbypassed
2. Per-person lockusers row · FOR NO KEY UPDATEbypassed
3. Database triggerassert_single_effective_entitlement()refused
PostgreSQL · COMMIT
ERROR 23P01 · one_effective_entitlement_per_user_site_date
The domain refuses the claim before anything is written. If code ever bypassed the application, the trigger takes the same person lock, re-counts, and refuses the row with a named constraint, which the persistence layer translates back into a domain error.

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.

06

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.

Press it as often as you likeFrom the code · idempotency key per rendered button

🅿️ Space 27 available

Thursday 24 September

key = teams:claim:9c1e4b

presses 0bookings 0
  1. // press Claim. Twice is fine.
Every press of this card's button carries the same key, because the nonce was made when the card was rendered. The first press books the space; every repeat finds the stored result and replays it: nothing is written, nothing is announced.

07

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.

Teams is down; the booking isn'tFrom the code · EventDispatcher defaults
ClaimclaimRelease()
one transactionbookings +1parking_events +1COMMIT ✓ · Space 27 is Emma's
DispatcherFOR UPDATE SKIP LOCKED
Microsoft Teamsunavailable

delivery attempts

✕10s✕20s✕40s✕80s✕160s✕320s✕640s✕

Parked as failed after 8 attempts (~21 min). The booking still stands.

The booking and its event commit together. Delivery happens afterwards and only ever retries the message. The delays are the dispatcher's defaults: 10 s doubling per attempt, capped at an hour, failed after the eighth.

08

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.

Editing a card nobody can seeFrom the pilot · OneSpot's own message text

what people can see

Tom

Anyone know if the lift is fixed?

Sarah

Working from home Friday 👋

James

Is the 08:10 train running?

OneSpot

✅ Space 27 claimed by Emma · Thursday 24 September.

teams_noticeboard_messages

announced_status

available→claimed

✓ card edited to claimed

✓ announced once

↻ event redelivered → already told, no post

The card that introduced the space is far up the history. Editing it alone left the conversation looking unchanged, so every change now also posts a one-line announcement. A redelivered event finds announced_status already at claimed and posts nothing.

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.

09

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.

A shared update has nowhere to put a nameFrom the code · SpaceClaimedNotification

shared

→ the noticeboard

  • releaseId
  • siteId
  • siteName
  • parkingSpaceId
  • spaceLabel
  • date
  • status
no field for a name

owner

→ one person's private chat

  • recipientUserId
  • releaseId
  • siteName
  • spaceLabel
  • date
  • claimedByDisplayName: "Emma"
The shared half goes to the noticeboard and has no field for a person. Only the owner's private half carries the claimant's name, and the noticeboard's ‘Claimed by’ line is read from current state at render time, not from this event.

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.

10

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.

From a tap to a row, and backFrom the code · routes and checks as implemented
Microsoft Entra IDthe bot's app registration · tenant · object ids
Microsoft Teamsclient · Adaptive Cards · tab
Bot ConnectorBot Framework · Microsoft-run
OneSpot processone container · node dist/bot.js · non-root/api/messages/admin/admin/api/*
PostgreSQL 17locks · constraints · triggers · outbox

step 5 of 7

BEGIN … COMMIT

One transaction

claimRelease locks Emma's user row, checks the idempotency key teams:claim:<nonce>, locks the release row, inserts the booking and a parking_events row, and commits. A second claimant would wait on the release row here.

The chat path goes through Microsoft's Bot Connector; the admin tab talks to the same process directly with an Entra single sign-on token. Both end in the same application services and the same PostgreSQL rules. The tab skips the Bot Connector, which is why that box fades in its walkthrough.

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.

11

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.

What OneSpot takes from an Entra tokenFrom the code · verifyTabAccessToken() · values fictional
Microsoft Entra ID→ access token for OneSpot’s API
{}

scp · checked

The delegated scope must include access_as_user. An application role in its place is not accepted, so an app-only token can't stand in for a person.

stored identity

entra:a0a0a0a0-0000-4000-8000-00000000c0de:00000000-0000-4000-8000-000000000042

then users.is_admin decides what the tab may do

The payload of a Parking Admin tab token, with fictional values. Select a claim to see what the server does with it. Four are checked, two become the identity, and everything that merely describes the person is ignored.

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.

12

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.

Where it runsDocumented target · not created by the repo
Azure PipelinesVerify → Scan → Image → Teams ZIP
Azure Container Registryimage pushed when configured
Container runtime chosen by ITnode dist/bot.js · non-root · secrets as env vars (Key Vault)
Azure Database for PostgreSQLFlexible Server 17 · private · TLS verify-full
Microsoft Entra ID · a new, organisation-owned app registration for the bot and the tab API
Dashed boxes are infrastructure the organisation's IT team creates: the registry, the approved container runtime and the database, plus a production Entra app registration. The Teams package is uploaded through the Teams admin center, not by the pipeline.
  • 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.

13

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.

553 tests at the recorded baselineFrom the README · 17 September 2026
  • core 113
  • db (PostgreSQL) 236
  • Teams adapter 106
  • Teams bot 85
  • web demo 13
The suite sizes the repository recorded on 17 September; later commits, such as the automatic noticeboard set-up, added more. The database tests run against a real PostgreSQL rebuilt from the migrations on every run, which is where the race and trigger tests live, and refuse any database whose name doesn't end in _test.
  • 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.