Engineering
Propose then confirm: a safe pattern for AI that writes to your database
Owners can run parts of Bookatu by chat, and every one of those requests is a write to a live database that other businesses share. We never gave the model write access. It proposes a structured change, we validate it like any form post, and a person confirms before anything is saved.
The short version
- The assistant never writes to the database. It proposes a change, and the owner confirms before anything is saved.
- A proposal is small, typed data that names one of a fixed set of operations. It is not SQL and not free text.
- We validate the proposal against a schema, the same way we validate a form post, and reject anything that does not match.
- Confirm runs the exact code the manual admin uses, with the same permission checks, so the AI is one more caller of a guarded door.
- The tenant comes from the signed-in session, never from the proposal, so a proposal can never reach another business's data.
Owners can run parts of the Bookatu admin by chat. Ask it to add a service, receive a delivery from a photo of a supplier invoice, or set up a consent form, and it does the fiddly part for you. All of those are writes to a live database that other people's businesses share. Handing a language model a direct line to that database was never something we were going to do.
A model is confident even when it is wrong. It might invent a price, or create a service that already exists under a slightly different name. If that went straight to the catalog, the owner would find out when a client booked the wrong thing. So the model got exactly zero write access, and we built a pattern around that limit rather than trying to remove it.
The model proposes, it does not write
The rule is one sentence. The assistant can read, and it can propose. Every change it wants to make comes back as a small piece of structured data that describes the change. Nothing in that data touches the database on its own. It is a request for a write, not the write itself.
A proposal names one operation from a fixed list and carries its arguments. A model that asks to create a product called Deep Conditioning at 45 dollars is handing us a form to check, not a command to run. Because the set of operations is closed, there is no way to phrase a proposal that does something we did not plan for.
// The model may only propose one of a fixed set of operations.
// Anything else is not a valid proposal and never reaches the database.
type Proposal =
| { op: "create_product"; name: string; priceCents: number; sku?: string }
| { op: "update_service"; id: string; name?: string; durationMin?: number }
| { op: "receive_stock"; sku: string; quantity: number };
// The allowed operations, as data. The model's output is matched
// against this, the same way a form post is matched against a schema.
const PROPOSAL_OPS = ["create_product", "update_service", "receive_stock"] as const;Because the proposal comes from a model, we treat it as untrusted input, exactly like a form a stranger filled in. It goes through the same kind of schema check we run on every server action. If the shape is wrong, if a number is missing, if the operation is not on the list, the proposal is rejected before the owner ever sees it. A malformed proposal is a bug in the draft, not a change to the catalog.
function validateProposal(input: unknown): Proposal | null {
const parsed = proposalSchema.safeParse(input);
if (!parsed.success) return null; // wrong shape, drop it
if (!PROPOSAL_OPS.includes(parsed.data.op)) return null; // unknown operation
return parsed.data;
}The owner confirms, nothing auto-writes
A valid proposal renders as a plain card in the chat. It says, in the owner's own language, what will change. Add a product called Deep Conditioning at 45 dollars. The owner can confirm it, edit a value first, or throw it away. Until they tap confirm, nothing has happened. There is no timeout that writes for them, and no quiet default that says yes.
A model that can only propose can be wrong all day and still never break your data.
The confirm button does not run some special AI code path. It calls the same function the manual admin form calls to create a product, with the same validation and the same permission checks. We spent a long time making that path safe. The assistant earns nothing by going around it, so it goes through it. The AI is one more caller of a door we already guard well.
Org-scoping is not the model's job
The one field the model never gets to decide is which business it is acting on. A proposal describes what to change, not whose data to change it in. The tenant comes from the signed-in session at the moment of confirm, and the write runs inside that tenant's scope. Even if a proposal tried to name another business, there is no seam to carry that name into the query. Every read and every write is fenced to the org on the session, the same fence the rest of the product uses.
This matters most for the reads that feed the model. When the assistant lists your services to reason about your request, that listing is already scoped to your org. The model never sees another tenant's catalog, so it cannot accidentally propose copying one business's prices into another.
Why the indirection is worth it
It would be less code to let the model write directly, and for a demo it would feel like magic. In production the indirection pays for itself the first time a model gets something wrong, which it will. A bad proposal is a card the owner declines. A bad write is a support ticket and a corrupted catalog. Adding a review step turns the model's mistakes into drafts, and a draft you did not like costs nothing.
The mental model we settled on is that the assistant is a very fast assistant, not a database account. It fills in the form and drafts the change. A person still says yes. That one boundary, the model proposes and a human confirms, is what lets us put AI next to a live catalog without losing a night of sleep over it.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs