MuninMunin
Sign inStart free
Home/Journal/A schema for five apps, not five schemas.
Engineering · 6 min read

A schema for five apps, not five schemas.

Why we put CRM, KB, CMS, Outreach, Conversations — and now Analytics — behind a single Postgres, and how row-level security carries the load. The title says five because Analytics arrived after it was written.

A team at a whiteboard mapping one unified contact record across Sales, Support, Content, Outreach, and Knowledge modules.
One contact, every module — mapped on the whiteboard.

Most B2B platforms grow by acquisition: each module ships with its own schema, its own auth model, its own idea of what a "contact" is. After a few years, the integration layer eats the engineering team.

Munin took the opposite bet. CRM, KB, CMS, Outreach, and Conversations all share one Postgres database, one identity model, one tenancy column, and one set of org_id-scoped RLS policies. This post is about what that's actually like to build on, and where it bites.

Why put five apps behind one Postgres schema?

Because the customer record is one record. A contact in the CRM is the same contact the helpdesk is talking to and the same contact a campaign targets. Munin runs every module on one Postgres database with exactly one contacts table, one org_id column, and one set of row-level security policies. Splitting that into five rows in five schemas isn't an architecture — it's a synchronisation project waiting to happen.

This is the database half of an argument I've made elsewhere: if the apps are going to expose themselves as tools rather than as pages, they had better agree on what they're describing. An agent that has to reconcile three versions of the same customer is doing the integration work we were trying to delete.

So we don't. There's exactly one contacts table, exactly one org_id on it, exactly one set of RLS policies. The CRM module reads it. The Conversations module reads it. The Outreach module reads it. They all see the same row, with the same scopes. It is also why the CRM can fill itself in from support conversations — the agent reading a closed thread is looking at the row an outreach campaign will target, not a copy of it.

How does row-level security carry multi-tenancy?

RLS is the contract, not the safety net. Every query passes through Postgres's RLS engine, so application code never filters by org_id — it cannot, because the queries don't contain it. The application's only job is to set the tenant context on the session. The database's job is to refuse to do anything else.

RLS gets a bad reputation because most teams that try it bolt it on after the fact. Treated as the contract from the first migration, it removes a whole category of bug: the application code can't bypass the boundary even by accident, because the database itself is enforcing the policy.

sqlthe shape of it
-- the tenant boundary, once, in the database
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;

CREATE POLICY contacts_tenant ON contacts
  USING      (org_id = current_setting('munin.org_id')::text)
  WITH CHECK (org_id = current_setting('munin.org_id')::text);

-- and every index leads with the same column
CREATE INDEX contacts_org_email_idx ON contacts (org_id, email);

-- the application's entire contribution to tenancy:
--   SET LOCAL munin.org_id = '...'
-- after that, a forgotten WHERE clause is a bug, not a breach.

Why does a stateless protocol change where tenancy lives?

The word doing the work in that snippet is LOCAL. The tenant context is established per request, from the authenticated caller, and torn down with the transaction — it is never a thing the server remembers about you between calls. When we made that choice it read as ordinary hygiene. It aged into a load-bearing decision on 28 July 2026, when the MCP 2026-07-28 specification landed: SEP-2575 removed the initialize/initialized handshake and SEP-2567 removed the Mcp-Session-Id header along with the protocol-level session it carried. Protocol version, client info, and client capabilities now travel in _meta on every request instead of being exchanged once at connection time — the changelog is the short version.

The practical consequence is worth stating plainly, because a protocol with no session of its own pushes servers toward handing the model explicit ids to thread between calls. A record id that leaks out of one tenant's conversation and gets replayed against another is a query returning zero rows. Not because the application caught it — because the database cannot be asked the question. An id in Munin is a name, not a key.

Did adding a sixth module break the bet?

No, and this is the clearest evidence I have that it paid. Analytics landed after this post was written — polymorphic page-view and search-query events — and it went onto the same database, the same org_id, the same policies, with no new schema and no sync job. Adding a module cost a table and a set of tools, not a quarter. It is also what makes a page view and a contact joinable in a single query, which is the whole mechanism behind asking which article closed the deal.

Which makes the title wrong by one. There are six modules now, not five. I'd rather leave the receipt than quietly renumber it.

What are the downsides of one shared schema?

Three real ones, and none of them are hypothetical. Migrations are global, so a change to the contact schema ripples through every module and has to be coordinated. RLS policy debugging is its own skill — when a query returns nothing, check the session GUC before you check anything else. And RLS predicates land in every query plan, so you pay for org_id-prefixed indexes.

Each of those has a shape worth knowing before you commit:

  1. Global migrations. There is no such thing as shipping a contact-schema change to one module. Every module that reads the table has to be ready in the same deploy, so the coordination cost scales with the number of modules rather than with the size of the change.
  2. GUC-first debugging. The failure mode of RLS is silence — a correct query returning zero rows because the session context was never set. It looks like missing data rather than a permissions error, which sends people hunting in the wrong place for an hour.
  3. Index discipline. An index that doesn't lead with org_id is an index the policy predicate can't use. We have rewritten more of them than we planned to, and the rule is now boring: tenancy column first, always.

None of these are dealbreakers, and each one is a cost you pay once in exchange for a cost you would otherwise pay continuously. Five schemas means five migrations, five deploy orders, five sets of credentials, and a sync job between every pair that has to stay correct forever. One schema means a migration is a single event with a single rollback, a query joining a conversation to a contact to a campaign is just a query, and adding a sixth module cost a table and a set of tools rather than a quarter. The index work is real and it ends. The integration work it replaces never would have.

Would we make the same call again?

All of it. The synchronisation problems we're not having are worth the migration coordination we are.

There's a second dividend I didn't anticipate when we started, which is that one schema with one tenancy column makes the exit cheap. Every module gets a symmetric export/import pair, and an agent can walk all of them to move a whole tenant somewhere else — which is the part of the business model I would defend hardest. You cannot offer that credibly on top of five schemas. You can barely offer it at all.

Frequently asked questions

Can Postgres row-level security handle multi-tenancy on its own? Yes, if it's the contract rather than an afterthought. A policy on every tenant-scoped table, checked with both USING and WITH CHECK, means reads and writes are both constrained by the database. The application never puts org_id in a WHERE clause, so it can never forget to.

Should I use one database per tenant or row-level security? Database-per-tenant gives you the hardest isolation and the worst migration story — every schema change is a fleet operation. RLS in one database gives you one migration and puts the burden on policy correctness and index design. We chose RLS because we ship modules often and wanted adding one to cost a table, not a quarter.

How do you set the tenant context for RLS in Postgres? SET LOCAL munin.org_id = '...' at the start of the transaction, derived from the authenticated caller, torn down when the transaction ends. LOCAL is the important word: the context never outlives the request, so a connection returned to the pool carries nothing with it.

Does row-level security slow down queries? It adds a predicate to every plan, which is real but manageable. The fix is to lead every index with the tenancy column — (org_id, email) rather than (email) — so the predicate is satisfied by the index rather than by a filter after the fact. Budget more index work than you expect.

Why does a stateless protocol like MCP change tenancy design? Because there is no protocol-level session to hang the tenant on. The MCP 2026-07-28 revision removed the handshake (SEP-2575) and the Mcp-Session-Id header (SEP-2567), so a request carries its own credentials and can land on any server instance. Per-request context isn't a nicety in that world; it's the only place the tenant boundary can live.

Can you export a single tenant out of a shared schema? Yes — that's the payoff we didn't plan for. Every Munin module exposes a matched export and import pair, all scoped by the same org_id, so an agent can walk six modules and move a whole tenant to another instance. With five schemas and five ideas of a contact, the same operation is a migration project.

If you're making the same call

  • One contacts table shared by every module beats five tables and a sync job — the customer is one person.
  • Treat RLS as the contract, not the safety net. If the application never writes org_id into a WHERE clause, it can never forget to.
  • Set the tenant context per request, not per session. Since the MCP 2026-07-28 revision dropped the handshake and the session header, there is no protocol session left to put it in.
  • Lead every index with the tenancy column. RLS predicates land in every query plan, so pay for them once in the index.
  • Adding the sixth module cost one table and one set of tools — no new schema, no sync job, no migration between systems.
  • The unplanned payoff is portability. One schema is what makes a whole-tenant export something an agent can do in an afternoon.

The policies, the indexes, and the export tools are all readable in the repository, and the docs list which table each module touches.

The customer is one person. The record should be too.