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.
James
Emma
Sarah
Tom
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 noticeboardcommand 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.
Try Claim Space 18 first, then release your own space and try again.
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.
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.
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 }; }); });}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 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.
🅿️ Space 27 available
Thursday 24 September
key = teams:claim:9c1e4b
- // press Claim. Twice is fine.
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.
delivery attempts
Parked as failed after 8 attempts (~21 min). The booking still stands.
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.
what people can see
Tom
Sarah
James
OneSpot
teams_noticeboard_messages
announced_status
available→claimed
✓ card edited to claimed
✓ announced once
↻ event redelivered → already told, no post
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.
shared
→ the noticeboard
- releaseId
- siteId
- siteName
- parkingSpaceId
- spaceLabel
- date
- status
owner
→ one person's private chat
- recipientUserId
- releaseId
- siteName
- spaceLabel
- date
- claimedByDisplayName: "Emma"
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.
step 5 of 7
BEGIN … COMMITOne 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 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.
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 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.
- One image. A multi-stage Dockerfile produces a non-root image that runs the compiled bot with
node dist/bot.js, alongsideGET /healthand aGET /readythat 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, sosslmode=requirecan'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.
- core 113
- db (PostgreSQL) 236
- Teams adapter 106
- Teams bot 85
- web demo 13
- 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.