I built FunilChat AI, a multi-tenant WhatsApp CRM with an AI agent that answers leads
before a human ever opens the app, for WA Financial Services, a firm that helps
Brazilian immigrants in the US with tax and immigration paperwork. Solo developer, one
VPS, no ops team, no staging environment. Every migration runs straight against
production.
Two constraints shaped almost every decision:
- No security budget, real security stakes. End customers submit SSN/ITIN/EIN- equivalent documents. This isn't a fintech with a compliance team; every control (isolation, MFA, encryption, malware scanning) had to be built into the product itself.
- One system, many paying clients, no ops team to run a fleet of databases. WA Financial Services was tenant one, but the plan from day one was to sell the same product to other businesses. Onboarding a new tenant had to take minutes, not a new deployment.
The isolation decision
The obvious multi-tenant pattern is one database (or schema) per client. I didn't do
that. Instead: one shared Postgres database, one schema, and the tenant boundary
enforced by row-level security (RLS) policies on every tenant-scoped table (21 of them),
keyed off a session variable the backend sets on every request.
The FastAPI backend sets that session variable on every request it handles. From there,
RLS does the actual enforcement: a query running under the API's database role simply
cannot see another tenant's rows, even if application code somewhere forgets a
WHERE tenant_id = ... clause. The database refuses the row before the app ever sees
it, not "if the code remembers to filter."
The cost of that choice showed up somewhere I didn't expect: the automation layer. The
product also runs an n8n workflow (it holds the AI agent and the handoff logic), and
n8n can't easily set a per-request session variable the way the API does. So its
database role runs with BYPASSRLS — a real, permanent blind spot in an otherwise
fail-closed model, one that has to be reviewed by hand every time a new workflow
touches the database. I'd rather have zero exceptions, but the alternative (rewriting
the tenant boundary check into every n8n node by hand) was worse: more surface area for
exactly the kind of mistake RLS exists to prevent.
What broke: a table grant isn't a sequence grant
A few months in, a production n8n workflow failed to create a handoff notification. The
Postgres error named a sequence, not a table — and the row-level insert itself looked
like something that should have been allowed.
Root cause: the migration that created the notifications table granted INSERT on the
table to both database roles, but only granted USAGE and SELECT on the table's
auto-increment sequence to the API's role. Postgres treats table privileges and
sequence privileges as two separate grants, and the automation role's default
privileges had never been set up to inherit sequence access the way table access had
been. Every INSERT that needed a new auto-generated ID from that role failed silently
at the sequence, while everything else on the table worked fine — which is a confusing
failure to read from the outside, because the obvious suspect (the RLS policy) was
innocent.
Fix: a follow-up migration added the missing
GRANT USAGE, SELECT ON SEQUENCE ... TO <automation_role>. The more useful fix was
turning it into a standing rule instead of a one-off patch: every migration that
creates a table the automation workflows write to now includes the sequence grant in
the same file as the table grant, checked while writing the migration instead of
discovered later from a production error.
The other invariant I actually care about
The AI and a human agent can both be looking at the same WhatsApp conversation, and the
one thing that can't happen is both replying, or the AI replying after a human already
stepped in. A customer deciding whether to trust a stranger with their tax documents
seeing two different voices talk over each other in one thread does more damage than a
slow reply ever would.
The mechanism is deliberately boring: a per-conversation flag. A human agent hits
"Assume," the flag flips, and the AI workflow checks that flag immediately before every
reply and stays silent if it's set. Thirty minutes after the last human reply, it
releases automatically, so a conversation nobody explicitly handed back doesn't stay
stuck in silence. Nothing here is negotiated between AI and human at message time — it's
a boolean checked before the AI is allowed to speak, which is also why it was cheap to
make airtight.
Where it stands
In continuous production since April 2026, on release 2.21.0, 21 tables under row-level
security, 307 commits on the main branch. Numbers pulled straight from the repo, not
rounded up for effect.
I wrote this case up in full on my portfolio, with all four architecture decisions
(this post covers two), every invariant, and the diagram:
joaoborba.dev/cases/funilchat-ai.
I'm a solo full-stack developer based in Brazil, looking for remote work, ideally with
teams in North America or Western Europe. Happy to go deeper on any of this in the
comments.
Top comments (1)
The sequence-grant blind spot is real and so easy to miss. Table perms and sequence perms being two independent grants bites everyone eventually - the failure mode where the INSERT is allowed but the auto-id lookup is not produces an error that points at the wrong suspect (here, RLS) every time.
The BYPASSRLS role for n8n is the honest tradeoff, but the standing rule (sequence grant lives in the same migration as the table grant) is the part people will thank you for. Did you end up enforcing it with a lint/CI check on migrations, or is it still discipline-based?