PAUL MK

Case study · pre-pilot

ParkSense

A commercial parking platform for the Kenyan market — and the clearest example I have of building something where being wrong costs somebody real money.

Cameras read plates at the gate. An on-site Go agent runs the lane and keeps running when the network does not. Drivers pay by M-Pesa on exit, straight to the operator’s own till. A barrier opens on a dry contact. Around that sit an operator console, an admin console, guard and driver apps, permits, enforcement, reconciliation, a reseller channel and the billing that collects the platform’s own subscription revenue.

It is a commercial product that has not launched, so this page publishes the problems and the reasoning rather than the request path. Nothing here is a finding, and nothing here would help someone rebuild it.

Scalemeasured 2026-09-05
Size of the ParkSense codebase, measured on disk
Python API196 files · ~29,700 lines
Go edge agent37 files · ~6,200 lines
HTTP routes168 handlers
Test files44, holding 486 API tests
SurfacesOperator console, admin console, guard PWA, driver PWA, marketing site
Built byOne person

Line counts measure size, not quality. They are here for one reason: without them a reader cannot tell whether this is a weekend project or a platform, and the rest of the page reads differently depending on which it is.

Five problems worth writing about

Not a feature list. These are the places where the obvious implementation is wrong, and where the reasoning is the part that transfers to any other system.

Problem 01

A dropped link must never double-charge, and never lose a session.

A gate is not a data centre. The link to it fails — a router reboots, an ISP drops, someone unplugs the switch to vacuum. Meanwhile cars keep arriving and leaving, and each one is a payment.

So the on-site agent owns the lane rather than asking permission for it. It writes locally first, in a mode that survives an abrupt power cut mid-write, and replays upstream when the link returns. Replay is the dangerous half: a naive retry loop bills a driver twice for one exit, and a duplicate charge is a refund, an apology and a phone call from an operator who now trusts the system less than the paper tickets it replaced.

Each pending item is therefore claimed under a lease before it is sent, so exactly one replay attempt owns it at a time and a crash mid-flight resumes rather than duplicates. That property is worth more than throughput here: this system handles tens of vehicles a minute at its busiest, and correctness under failure is the only thing anyone will remember.

Problem 02

A wrong read must never charge a stranger.

Plate recognition is probabilistic. Rain, headlight glare, a bent plate, a lens someone smeared while reversing — all of it degrades a read, and the system cannot tell a poor read from a good one by looking at the result.

The naive design trusts whatever the camera returns. That means a misread on exit charges a person who was never in the car park, which is not a bug you get to fix quietly. It is a bug the driver tells other drivers about.

So reads are not single events. Several frames vote, weighted by confidence, and the result is corrected against the plate formats that are actually legal in the region rather than accepted as free text. Below a confidence bar the exit still calculates the fee but withholds the payment prompt entirely and queues the session for a human.

The asymmetry is the whole point, and it is worth stating plainly because it is the kind of thing that separates a system built by an engineer from one built to a spec: a bad entry read costs the operator one session of revenue. A bad exit read charges a stranger. Those are not the same mistake and they must not have the same failure mode.

Problem 03

The barrier opens on exit. Always.

Including when the plate is flagged. Including when the fee is unpaid. Including when the platform itself is down.

A barrier that refuses to lift is not a payment control, it is a person detained in a concrete box, possibly at night, possibly alone, because a piece of software formed an opinion about their number plate. The cost of a trapped driver — to the operator, to the driver, and to anyone who hears about it — is larger than any fee that could have been collected.

So enforcement happens after the fact, through records and human decisions, and never through the physical gate. This closed off a whole category of feature requests, and it is written down with its reasoning so that a future engineer under deadline pressure has to argue with the decision rather than quietly discover the constraint and route around it.

Problem 04

Money never rests with the platform.

The obvious design routes payments through the platform and takes a cut. It is also the design that turns a software company into something a central bank has an opinion about — holding other people’s money, even briefly, is a regulated activity in most jurisdictions and certainly in this one.

Payment instead moves from the driver directly to the operator’s own till, authorised with the operator’s own provider credentials. The platform observes the settlement and records it; it never custodies it. Revenue is a subscription rather than a percentage.

That decision costs real money — fees cannot be netted out of the flow, so they have to be invoiced and chased separately. It is still obviously correct. The alternative buys a slightly better revenue mechanic in exchange for a licensing question, and a licensing question is not a thing a pre-revenue product survives.

Problem 05

One operator must never see another operator’s cars.

Multi-tenancy is where the severe bugs live, and they are almost never at the login. They are one query deep, in the handler nobody reviewed, where an identifier arrives from a request and is looked up without also being scoped to the tenant it belongs to.

The scoping here is enforced on every write rather than checked at the edge, because an edge check protects the routes that existed when it was written and nothing added afterwards. Operator staff are bound to the specific sites they work at, not merely to an operator, so a franchise cannot read across its own branches by accident either.

This is the same bug class the published Semgrep rule catches, which is not a coincidence. Writing the rule came out of finding the shape repeatedly by hand and deciding a snapshot was worth less than a ratchet.

Decisions, and what each one cost

Design choices with their reasoning — none of these is a finding
ParkSense security decisions and the reasoning behind each one
Money never touches the platformThe STK push goes from the driver straight to the operator’s own till, signed with the operator’s own Daraja credentials. That removes the payment-institution regulatory question entirely; revenue is subscription instead of a cut.
Exit always opens the barrierUnconditionally, including for a blocklisted plate. Refusing to let a vehicle leave is detaining a person, and a trapped driver costs more than any unpaid fee. The tradeoff is documented rather than hidden.
Low-confidence reads do not chargeBelow a confidence threshold, an exit calculates the fee but withholds the payment prompt and queues the session for human review. A bad entry read costs one session’s revenue; a bad exit read charges a stranger.
Reputation flags are never automaticThe cross-operator unpaid-exit counter is never incremented by code. A dirty lens at one site would otherwise defame a driver at every other site, so flagging is owner-only, manual, and requires a written reason.
Separation of duties on payoutsOperators cannot approve their own payouts. Those routes were deleted rather than re-guarded, because a guard is a thing someone can later loosen and a missing route is not.
Credentials are write-onlyPayment credentials are Fernet-encrypted at rest and returned to nobody — not operators, not support, not an owner. Only masked tails are ever read back.

Verified state

checked, not asserted
Verification state of the ParkSense codebase
API tests486 passing, 0 failed
Migrations19, single head, round-trip verified on real TimescaleDB
API ↔ UI wiring72 endpoints wired, 0 phantom calls
Lintruff, 0 errors
Dashboardtsc -b, eslint and build clean
Edge agentGo build and vet clean
Mock data in the UInone — enforced, not aspirational

Every row is a claim about the code, and every one was checked rather than remembered. None of them is a claim about the physical world.

What is not done, and why that matters

The recognition loop is proven end to end against a real camera — a phone streaming over RTSP to the agent, which read real plates and opened real sessions. That retired the largest technical unknown in the project, and it was worth doing before any of the rest.

What has not happened is a gate: a camera mounted at gate height and angle, moving vehicles, weather, a boom barrier on a dry contact, and a week of genuine traffic.

Until that happens the physical claims are simulation-proven, not field-proven, and the two are not the same thing. The plate models in particular cannot reach production quality without footage from real cameras at real angles in real light — which is a thing one pilot site produces and no amount of further building does.

I could describe this project as finished. The backend is feature-complete, the tests pass, the deploy stack exists. But “built and never touched real hardware, marked done” is the exact failure this whole site argues against, so it says pre-pilot instead — and the finish line is one friendly gate, not another sprint.

That is also the honest answer to the obvious question about the rest of this site. Anyone can call their own work rigorous. The test is whether they will tell you what is still unproven when nobody would have checked.

Book a walkthroughAll projects