Ett databasschema för fem appar, inte fem scheman.
Varför vi lade CRM, KB, CMS, utgående kommunikation, konversationer — och nu analys — bakom en enda Postgres, och hur row level security bär lasten. Rubriken säger fem för att analysen kom efter att den skrevs.

De flesta B2B-plattformar växer genom förvärv: varje modul kommer med sitt eget schema, sin egen autentiseringsmodell, sin egen föreställning om vad en «kontakt» är. Efter några år äter integrationslagret utvecklingsteamet.
Munin tog det motsatta vadet. CRM, KB, CMS, utgående kommunikation och konversationer delar alla en Postgres-databas, en identitetsmodell, en tenancy-kolumn och en uppsättning org_id-avgränsade RLS-policyer. Det här inlägget handlar om hur det faktiskt är att bygga på det, och var det biter.
Varför lägga fem appar bakom ett Postgres-schema?
För att kundposten är en post. En kontakt i CRM:et är samma kontakt som supportdesken talar med, och samma kontakt som en kampanj riktar sig mot. Munin kör varje modul på en Postgres-databas med exakt en contacts-tabell, en org_id-kolumn och en uppsättning row level security-policyer. Att dela upp det i fem rader i fem scheman är ingen arkitektur — det är ett synkroniseringsprojekt som väntar på att hända.
Det här är databashälften av ett argument jag lagt fram på annat håll: om apparna ska exponera sig som verktyg snarare än som sidor bör de vara överens om vad de beskriver. En agent som måste förena tre versioner av samma kund gör precis det integrationsarbete vi försökte ta bort.
Så det gör vi inte. Det finns exakt en contacts-tabell, exakt en org_id på den, exakt en uppsättning RLS-policyer. CRM-modulen läser den. Konversationsmodulen läser den. Modulen för utgående kommunikation läser den. De ser alla samma rad, med samma behörigheter. Det är också skälet till att CRM:et kan fylla i sig självt från supportkonversationer — agenten som läser en stängd tråd tittar på den rad en utgående kampanj kommer att rikta sig mot, inte på en kopia av den.
Hur bär row level security multi-tenancy?
RLS är kontraktet, inte skyddsnätet. Varje fråga passerar Postgres RLS-motor, så applikationskoden filtrerar aldrig på org_id — den kan inte, för frågorna innehåller det inte. Applikationens enda uppgift är att sätta hyresgästkontexten på sessionen. Databasens uppgift är att vägra göra något annat.
RLS har fått dåligt rykte för att de flesta team som provar det skruvar på det i efterhand. Behandlat som kontraktet från första migreringen tar det bort en hel kategori buggar: applikationskoden kan inte kringgå gränsen ens av misstag, eftersom databasen själv upprätthåller policyn.
-- hyresgränsen, en gång, i databasen
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);
-- och varje index leder med samma kolumn
CREATE INDEX contacts_org_email_idx ON contacts (org_id, email);
-- applikationens hela bidrag till tenancy:
-- SET LOCAL munin.org_id = '...'
-- därefter är en glömd WHERE-sats en bugg, inte ett intrång.Varför ändrar ett tillståndslöst protokoll var tenancy bor?
Uppdaterat augusti 2026: Ordet som gör jobbet i den kodsnutten är LOCAL. Hyresgästkontexten etableras per förfrågan, från den autentiserade anroparen, och rivs ned med transaktionen — den är aldrig något servern minns om dig mellan anrop. När vi gjorde det valet läste det som vanlig hygien. Det mognade till ett bärande beslut den 28 juli 2026, när MCP-specifikationen 2026-07-28 landade: SEP-2575 tog bort initialize/initialized-handskakningen och SEP-2567 tog bort Mcp-Session-Id-huvudet tillsammans med den protokollnivå-session det bar. Protokollversion, klientinfo och klientkapabiliteter reser nu i _meta på varje förfrågan i stället för att utbytas en gång vid anslutning — ändringsloggen är kortversionen.
Den praktiska konsekvensen är värd att säga rakt ut, för ett protokoll utan egen session för servrar mot att ge modellen explicita id:n att träda mellan anrop. Ett post-id som läcker ut ur en hyresgästs konversation och spelas upp mot en annan är en fråga som returnerar noll rader. Inte för att applikationen fångade det — för att databasen inte kan få frågan ställd. Ett id i Munin är ett namn, inte en nyckel.
Bröt den sjätte modulen vadet?
Nej, och det här är det tydligaste beviset jag har på att det lönade sig. Analysen landade efter att det här inlägget skrevs — polymorfa händelser för sidvisningar och sökfrågor — och den hamnade på samma databas, samma org_id, samma policyer, utan nytt schema och utan synkjobb. Att lägga till en modul kostade en tabell och en uppsättning verktyg, inte ett kvartal. Det är också det som gör en sidvisning och en kontakt sammanfogningsbara i en enda fråga, vilket är hela mekanismen bakom att fråga vilken artikel som stängde affären.
Vilket gör rubriken fel med en. Det är sex moduler nu, inte fem. Jag lämnar hellre kvittot kvar än numrerar om det i tysthet.
Vilka är nackdelarna med ett delat schema?
Tre verkliga, och ingen av dem är hypotetisk. Migreringar är globala, så en ändring i kontaktschemat fortplantar sig genom varje modul och måste samordnas. Felsökning av RLS-policyer är ett eget hantverk — när en fråga inte returnerar något, kontrollera sessionens GUC innan du kontrollerar något annat. Och RLS-predikat hamnar i varje frågeplan, så du betalar för org_id-ledda index.
Var och en har en form värd att känna innan du binder dig:
- Globala migreringar. Det finns inget sådant som att skeppa en kontaktschemaändring till en enda modul. Varje modul som läser tabellen måste vara redo i samma deploy, så samordningskostnaden skalar med antalet moduler snarare än med ändringens storlek.
- GUC-först-felsökning. RLS:s felmod är tystnad — en korrekt fråga som returnerar noll rader för att sessionskontexten aldrig sattes. Det ser ut som saknade data snarare än ett behörighetsfel, vilket skickar folk på jakt på fel ställe i en timme.
- Indexdisciplin. Ett index som inte leder med
org_idär ett index policypredikatet inte kan använda. Vi har skrivit om fler av dem än vi planerat, och regeln är nu tråkig: tenancy-kolumnen först, alltid.
Inget av detta är en deal-breaker, och var och en är en kostnad du betalar en gång i utbyte mot en kostnad du annars skulle betala kontinuerligt. Fem scheman betyder fem migreringar, fem deploy-ordningar, fem uppsättningar autentiseringsuppgifter och ett synkjobb mellan varje par som måste förbli korrekt för alltid. Ett schema betyder att en migrering är en händelse med en återställning, att en fråga som fogar en konversation till en kontakt till en kampanj bara är en fråga, och att lägga till en sjätte modul kostade en tabell och en uppsättning verktyg snarare än ett kvartal. Indexarbetet är verkligt, och det tar slut. Integrationsarbetet det ersätter skulle aldrig ha gjort det.
Skulle vi göra samma val igen?
Alltihop. Synkproblemen vi inte har är värda migreringssamordningen vi har.
Det finns en andra utdelning jag inte förutsåg när vi började, nämligen att ett schema med en tenancy-kolumn gör utgången billig. Varje modul får ett symmetriskt export-/importpar, och en agent kan gå igenom dem alla för att flytta en hel hyresgäst någon annanstans — vilket är den del av affärsmodellen jag skulle försvara hårdast. Det kan du inte erbjuda trovärdigt ovanpå fem scheman. Du kan knappt erbjuda det alls.
Vanliga frågor
Kan Postgres row level security hantera multi-tenancy på egen hand?
Ja, om det är kontraktet snarare än en eftertanke. En policy på varje hyresgästavgränsad tabell, kontrollerad med både USING och WITH CHECK, betyder att både läsningar och skrivningar begränsas av databasen. Applikationen sätter aldrig org_id i en WHERE-sats, så den kan aldrig glömma det.
Bör jag använda en databas per hyresgäst eller row level security? Databas per hyresgäst ger dig hårdast isolering och sämst migreringsberättelse — varje schemaändring är en flottoperation. RLS i en databas ger dig en migrering och lägger bördan på policykorrekthet och indexdesign. Vi valde RLS för att vi skeppar moduler ofta och ville att lägga till en skulle kosta en tabell, inte ett kvartal.
Hur sätter du hyresgästkontexten för RLS i Postgres?
SET LOCAL munin.org_id = '...' i början av transaktionen, härledd ur den autentiserade anroparen, nedriven när transaktionen slutar. LOCAL är det viktiga ordet: kontexten överlever aldrig förfrågan, så en anslutning som lämnas tillbaka till poolen bär ingenting med sig.
Gör row level security frågorna långsammare?
Det lägger till ett predikat i varje plan, vilket är verkligt men hanterbart. Lösningen är att låta varje index leda med tenancy-kolumnen — (org_id, email) snarare än (email) — så att predikatet uppfylls av indexet i stället för av ett filter i efterhand. Budgetera mer indexarbete än du tror.
Varför ändrar ett tillståndslöst protokoll som MCP tenancy-designen?
För att det inte finns någon session på protokollnivå att hänga hyresgästen på. MCP-revisionen 2026-07-28 tog bort handskakningen (SEP-2575) och Mcp-Session-Id-huvudet (SEP-2567), så en förfrågan bär sina egna autentiseringsuppgifter och kan landa på vilken serverinstans som helst. Kontext per förfrågan är ingen finess i den världen; det är den enda plats hyresgränsen kan bo.
Kan man exportera en enskild hyresgäst ur ett delat schema?
Ja — det är utdelningen vi inte planerade. Varje Munin-modul exponerar ett matchat export- och importpar, alla avgränsade av samma org_id, så en agent kan gå igenom sex moduler och flytta en hel hyresgäst till en annan instans. Med fem scheman och fem föreställningar om en kontakt är samma operation ett migreringsprojekt.
Om du gör samma val
- En
contacts-tabell delad av varje modul slår fem tabeller och ett synkjobb — kunden är en person. - Behandla RLS som kontraktet, inte skyddsnätet. Om applikationen aldrig skriver
org_idi en WHERE-sats kan den aldrig glömma det. - Sätt hyresgästkontexten per förfrågan, inte per session. Sedan MCP-revisionen
2026-07-28slopade handskakningen och sessionshuvudet finns ingen protokollsession kvar att lägga den i. - Låt varje index leda med tenancy-kolumnen. RLS-predikat hamnar i varje frågeplan, så betala för dem en gång i indexet.
- Att lägga till den sjätte modulen kostade en tabell och en uppsättning verktyg — inget nytt schema, inget synkjobb, ingen migrering mellan system.
- Den oplanerade utdelningen är portabilitet. Ett schema är det som gör en export av en hel hyresgäst till något en agent kan göra på en eftermiddag.
Policyerna, indexen och exportverktygen går alla att läsa i repot, och dokumentationen täcker verktygen varje modul exponerar.
Kunden är en person. Posten borde vara det också.