Here's a question that sounds trivial until you build a multi-tenant product: when suman@karyospace.com asks the assistant something, which organisation's data should it search? The obvious answer is "his company's data". The obvious implementation is to read the bit after the @ and treat that domain as the organisation. That works right up until you meet a real enterprise, and then it quietly falls apart.
Big companies don't live on one domain. A group might run acme.com for the parent, acme.co.uk for the UK subsidiary, and acme-labs.com for the research arm, and every one of those people belongs to the same organisation and expects to see the same shared knowledge. If you infer the org by parsing the email domain, those three groups become three separate silos who can't see each other's work. That's the opposite of what a knowledge platform is for.
This is the story of how we fixed that in KaryoSpace with a domain-to-organisation registry, why we treat the organisation as a security boundary rather than a string to parse, and the bugs we hit on the way. It doubles as a build guide, so if you're putting together your own multi-tenant system, you can lift the shape of this directly.
We didn't invent anything exotic here. Google Workspace, Microsoft 365, Slack and Okta all solve this the same way, and it's worth naming because it's the pattern worth copying. An organisation owns a set of verified domains. An admin registers each domain the company uses, the platform verifies ownership, and from then on any user whose email is on one of those domains resolves to that organisation. One org, many domains, and a hard rule that a domain belongs to exactly one org so there's never any ambiguity about who a person is.
When we arrived at the same design independently, that was the signal we were on the right track. If you're building for enterprises, this is table stakes, not a nice-to-have. So we built it into KaryoSpace as an admin-managed registry.
An organisation is a tenancy boundary, the thing that keeps Company A's data away from Company B. A boundary that important should never be inferred by string-parsing an email. It should come from an explicit, admin-curated list you can audit.
Reading the domain off an email address feels authoritative, but it's a guess dressed up as a fact. It breaks in more ways than you'd expect. Subsidiaries and acquisitions mean one tenant spans several domains. A rebrand changes the company's domain and shouldn't silently fork the organisation in two. And the nastiest failure is a collision, where two customers happen to share a domain suffix and the inference quietly hands one tenant's data to the other. Because it's inferred, nobody sees it happen.
The authoritative fact already exists. It's on the user's record, established when they were onboarded, the same thing single sign-on hands you in its claims. The retrieval layer should trust that, not re-derive it every request from a parseable attribute. So the real design question isn't "domain or org id", it's making the organisation id the source of truth and using the domain only as the thing that maps to it.
The whole feature is a small collection and a leaf-level cache. The collection, org_domains, holds one document per organisation with the list of domains that resolve to it.
{
org_id: "Acme", // stable id, the real key
org_name: "Acme", // display label
domains: ["acme.com", "acme.co.uk", "acme-labs.com"]
}
There's a unique index on domains, which is what enforces the one-domain-one-org rule at the database level. If an admin tries to add a domain another org already claims, the write is rejected with a clear message rather than silently creating an ambiguous mapping. That single constraint is what removes the collision risk that plagues domain-parsing.
Retrieval happens on the hot path, so it can't hit the database on every query. We load the mapping into a dependency-free in-memory package (we call it orgres) at startup, and reload it whenever an admin edits the registry. Resolving a user is then a map lookup.
func Resolve(userID string) string {
d := domainOf(userID) // "x@acme.co.uk" -> "acme.co.uk"
if org, ok := domainToOrg[d]; ok {
return org // registered -> the org's canonical id
}
return d // unmapped -> the raw domain (legacy)
}
The fallback line is the quiet hero of the design. When a domain isn't in the registry, resolution returns the raw domain, which is exactly the old behaviour. That means single-domain companies, which is most companies, need to configure nothing at all. They work on day one, and the registry only matters the moment a company has more than one domain. Backwards compatibility for free, because the common case and the fallback are the same value.
We set the organisation id to default to the company's domain at onboarding. So for a normal single-domain customer, org id and domain are identical by construction, and everything behaves as if the registry didn't exist. Multi-domain is the exception that opts in, not a tax everyone pays.
Wiring this in should have been a change to one function. It wasn't, and the reason is instructive. The codebase resolved the organisation in three different places, one for retrieval, one for incidents, and a third inside messaging that the assistant used to scope knowledge and chat. I wired the first two through the registry, tested, and it looked done.
Then a live test on production came back with knowledge returning zero results while incidents and email worked fine. The third resolver, the one in messaging, was still parsing the raw domain. So a user on aimedemo.test had their incidents scoped correctly to the consolidated org but their knowledge scoped to aimedemo.test, where nothing lived. Knowledge came back empty.
The lesson isn't "I missed one". It's that if a security boundary is computed in more than one place, it will drift, and the drift shows up as either an empty result if you're lucky or a leak if you're not. The fix was to route all three resolvers through the same registry so they can't disagree. If you take one thing from this post for your own build, make it this: resolve tenancy once, in one place, and have everything call it.
An admin adds the company's domains in the Org Domains tab. The unique index guarantees no domain is claimed twice.
When a user signs in, the registry takes precedence: if their domain is registered, the session carries the org's canonical id, so anything they create is stored under it.
Every retrieval path resolves the user's domain through the same cache, so reads land on exactly what writes stamped. Content and query agree because they share one resolver.
Registering domains fixes new activity, but an enterprise that's already been using the product under separate domains has content stamped with the old organisation ids. Point the registry at a new consolidated org and, until you migrate, everyone retrieves nothing, because the content still carries the old labels.
So consolidation is a one-time data migration. When we merged three domains into a single org on our own instance, we relabelled every document carrying an organisation id, across roughly 30 collections, from the old ids to the new canonical one. Knowledge, incidents, email, chat, projects, calendar, the knowledge graph, people, the lot. We surveyed first, applied second, and verified there were zero documents left on the old ids. A user on one of the merged domains went from retrieving nothing to pulling the whole shared pool, around six thousand items, in one query.
A brand-new customer never hits this. They start empty, everything gets stamped with the right org from their first action, and there's nothing to migrate. The migration is purely the price of consolidating history.
A tenancy change is the highest-stakes thing you can touch, because a mistake here isn't an empty result, it's Company A reading Company B's data. So the gate on this work is an isolation test that runs on every build. It registers two organisations, one owning two domains and one owning a third, seeds content for each, and then asserts that users on either of the first org's domains see their shared content and never see the second org's. If a cross-tenant document ever appears, the build fails.
That test is non-negotiable. Features can wait for review, but the property that one customer cannot see another is verified automatically, every time, before anything ships.
Handling multiple domains per organisation isn't a cosmetic feature. It's the difference between a product a small team can use and one a real enterprise can adopt, because real enterprises come with subsidiaries, acquisitions and rebrands built in. Building it the way Microsoft and Google build it is a deliberate statement about the size of company KaryoSpace is meant to serve.
Keep the resolver in one leaf package with no dependencies so every layer can call it without import cycles, and so it's trivially testable. Cache the mapping in memory and reload on edit, because it's read on every request. Make the organisation id a stable key and let the display name change freely, so a rename never forces a re-migration. Default the id to the domain at onboarding so the single-domain case needs no configuration. And enforce domain uniqueness in the database, not just the UI, so the collision that would leak data is impossible rather than merely discouraged.
None of this is clever. It's just the boring, correct shape for multi-tenant identity, and getting it boring and correct is the whole point. Tenancy is the one boundary you don't get to be creative about.
KaryoSpace is live at karyospace.com. Questions: sumanakkisetty@gmail.com
Thanks for reaching out. We'll get back to you shortly.