Ebrebook: a guarantee only exists where the database can see it
Ebrebook is a booking platform that serves several businesses from a single installation, each with its own subdomain. It organises bookings and gives control over available slots, with a focus on local and tourism businesses: hair salons, restaurants, equipment rental, activities.
Losing time or customers over bookings, calls or reminders? The assessment is free.
Book an assessmentWhat it is and where it stands
It is an in-house product and it is in development: it has no customers in production yet. What follows is not a success story, it is what we learned building it and, above all, auditing it with the instruction to find faults instead of confirming what works.
What is hard about managing available slots?
A calendar tells you whether there is something at that time. A booking system has to tell you whether you can book, which is a different question.
The difference comes down to three things. Resources are exclusive —a table, an employee, a kayak cannot be sold twice—, there is real concurrency because two people can ask for the same slot at the same time, and the product's four business types have different rules: an appointment takes up a professional, a table takes up a physical object, and an activity is a place counter that is exclusive to no one.
Where does a double booking really appear?
Intuition says it is a speed problem: if the system responds quickly, there is no time for two people to take the same slot. That is false.
The problem is that time passes between checking and writing. Ana asks whether Thursday at ten is free and is told yes. Bruno asks the same and is also told yes, because at that instant it is still true. Both write. Nobody has done anything wrong: the truth expired between the question and the answer. However fast you go, that window is never zero.
The defences that do work are two, and they are independent. On the public paths, the application asks the database for exclusive control of the resource before confirming, so the second request waits its turn and, when it gets there, already sees the slot as taken. Underneath, three PostgreSQL exclusion constraints make it impossible to store two overlapping bookings for the same professional, the same table or the same resource.
Verified with real concurrent requests, not simulated ones: the exact same slot, partial overlap and the boundary case —a booking that starts just as the other ends, which must be allowed—. All three correct, and the one that loses gets a clear message, not a server error.
And still there was a double booking
With those two defences in place, a restaurant could sell the same table twice.
When a group does not fit at any table, the engine joins several. It stored the first one as a proper data field and wrote the others as text in the notes field: "additional tables: T3".
The constraint could not see it. T3 did not appear in any column the database could look at. And the lock did not help either, because there was no race to win: the check asked about the proper data field and answered, honestly, that T3 was free.
The result, reproducible without concurrency and every time: six people assigned to a table for two, at the same time. And the more success the restaurant had with large groups, the more often it would happen.
The fix was to model each occupied table as its own row and move the constraint there. The old one was removed instead of being left alongside: two constraints for the same invariant are two sources of truth, and the old one was a strictly weaker version.
An invariant you want the database to guarantee has to live in a column the database can see. In free text it is invisible to any constraint, however well designed.
How do you stop one business from touching another's data?
Read isolation was well handled from the start, and for a structural reason: the business's identity is derived from the caller's account, never from the address the request comes from. A business cannot read, modify or cancel another's booking because that object simply is not in its data set. There is no check to evade.
A detail that looks minor and is not: the error for an identifier that exists in another business is byte-for-byte identical to the one for an identifier that does not exist in any. A 403 would confirm that the resource exists and allow enumeration. An indistinguishable 404 does not.
But write isolation was another story, and this one was found by the audit, not by us.
The API forms declared the references to employees, services, tables and resources as ordinary fields. By default, that means "any in the database", not scoped by business. And employee identifiers are public: a business's schedule exposes them to anyone who knows its subdomain.
The full chain, verified end to end: read a competitor's public staff without authenticating, create a legitimate booking in your own business, and reassign it to one of the competitor's employees. The victim's schedule lost four real slots, and nothing notified them. The same with blocks of arbitrary range: the other business's entire schedule, in one go.
Why was the fix not the obvious one?
Two decisions, and the second is the interesting one.
The first: searching for the affected fields by introspection instead of by hand turned up twenty-two across twelve forms, not the three that had been identified. They were fixed with a mechanism that hooks into field construction and scopes them automatically, not with a list someone has to remember to maintain. Fix the class of fault, not the fault.
The second: the obvious safety net was to add the business to the three exclusion constraints. That would have been a mistake. Today those constraints compare two bookings for the same employee without looking at whose they are, and that is why they reject the attacker's booking. Putting the business inside the expression, two bookings from different businesses for the same employee would stop colliding: the guarantee would be weakened instead of strengthened.
The right net was a different one: a composite foreign key that makes the database reject the inconsistency even if someone tries to write it through direct SQL, bypassing the application entirely.
Adding a defence is not the same as adding the right defence. A badly chosen constraint can leave you more exposed than not having one, while making you feel the opposite.
Why did 178 green tests not catch it?
This is the uncomfortable part and the one we take away most.
There were 178 tests, all passing. They covered the engine-backed public paths well. Five of the audit's six findings lived exactly where the coverage did not reach: the panel's generic CRUD, booking modification, merged tables, intermediate states and the clock change.
And there is a specific trap that nearly produced a false pass in the audit itself. By default, Django tests run inside a transaction that is rolled back at the end, and two threads inside that transaction do not see each other's writes. A concurrency test written that way either hangs or "proves" there are no double bookings when there are. A false green is worse than not testing at all, because you publish a claim backed by a test that tested nothing.
A green suite measures what it measures. The useful question is not how many tests there are, but which paths none of them touch.
What does AI do here, and what does it not do yet?
Predicting no-shows and demand by time slot is on the roadmap, and today the honest answer is that it is not viable: there is no history. Zero bookings with a known outcome, zero repeat customers, zero occupancy records.
What is built is the gate that says so. The system refuses to train below a threshold —five hundred outcomes with fifty positive cases for no-shows, eight weeks and fifty-six distinct days for occupancy— and returns a fallback answer instead.
That fallback is a rule-based heuristic with weights set by judgement, not learned: how far ahead the booking is made, whether the customer is new or returning, whether there is a phone number for reminders, whether a reminder was sent. It works from day one without any historical data, it can be explained to the business and, importantly, it keeps generating the labels a future model will need.
It is the ladder we always apply: heuristic first, and you only climb a rung when the previous one proves to fall short. A trained model is useless on the day there is no data, which is exactly the day a product launches.
What is missing and what would we do differently?
What is still open, without embellishment: in the repair-shop flow, a vehicle that comes in for repair frees up its slot and another customer can book it. The internal panel does not check for overlaps and relies only on the database, so it returns a server error where it should say "that slot is already taken". Rescheduling a booking through the API is broken in both directions. And the engine treats every day as if it had twenty-four hours, which produces phantom slots on the two clock-change weekends.
There are no cleaning or preparation times between bookings, and no hold on a slot while the customer fills in the form.
What we would do differently: frame the product around one specific need with proven demand, instead of building a system that serves four business types at once. The four paradigms share a single table, and that decision is what makes a state designed for repair shops affect the availability of a hair salon.
Tech stack
- Django
- Django REST Framework
- PostgreSQL 16
- GiST range constraints
- Celery
- Redis
- Docker Compose
- Caddy