Multi-Tenancy Architecture

Realm-based data isolation and sovereignty enforcement.

Architecture Overview

Autonomy is designed from the ground up as a multi-tenant system where every piece of data belongs to a realm.

This isn't permission checking. This is data isolation enforced by foreign keys, database constraints, and TypeScript types.

Core Principle: Sovereignty by Schema

Traditional multi-tenant systems rely on application-layer permission checks. Autonomy enforces sovereignty at the database schema level.

You can't accidentally leak data across realms because the database won't let you.

How Multi-Tenancy Works

1. Every Entity Has a realm_id

Signals, clusters, and synthesis all have a required realm_id foreign key.

model Signal {
  signal_id   String @id
  realm_id    String // REQUIRED - can't be null
  // ... other fields

  realm       Realm  @relation(fields: [realm_id], references: [realm_id])
}

2. Query Functions Filter by Realm

All query functions accept a userId parameter and automatically filter by realms the user has access to.

// Get user's accessible realms
async function getUserRealmIds(userId: string): Promise<string[]> {
  const realms = await prisma.realm.findMany({
    where: {
      OR: [
        { user_id: userId },              // Realms they own
        { members: {                       // Realms they're members of
            some: { user_id: userId }
          }
        },
      ],
    },
    select: { realm_id: true },
  })
  return realms.map(r => r.realm_id)
}

// Query signals - automatically filtered
export async function querySignals(
  params: QuerySignalsParams,
  userId: string
) {
  const userRealmIds = await getUserRealmIds(userId)

  return await prisma.signal.findMany({
    where: {
      realm_id: { in: userRealmIds },  // Only accessible realms
      // ... other filters
    }
  })
}

3. TypeScript Types Enforce Realm Requirements

Zod validation schemas require realm_id on creation. You can't create an entity without specifying which realm it belongs to.

export const createSignalSchema = z.object({
  realm_id: z.string().length(26),  // REQUIRED
  signal_type: z.enum([...]),
  signal_title: z.string(),
  // ... other fields
})

4. Cross-Realm Operations Are Blocked

You cannot add a signal from one realm to a cluster in another realm. The query layer enforces this.

// Verify signal and cluster share a realm
const signal = await prisma.signal.findUnique({
  where: { signal_id },
  select: { realm_id: true }
})

const cluster = await prisma.cluster.findUnique({
  where: { cluster_id },
  select: { realm_id: true }
})

if (signal.realm_id !== cluster.realm_id) {
  throw new Error('Cannot add signal to cluster in different realm')
}

Realm Access Control

A user has access to a realm if:

  1. They created itrealm.user_id matches their user_id
  2. They're a member — Entry exists in realms_users table (future feature for shared realms)

Access check implementation:

export async function userHasRealmAccess(
  userId: string,
  realmId: string
): Promise<boolean> {
  const realm = await prisma.realm.findFirst({
    where: {
      realm_id: realmId,
      OR: [
        { user_id: userId },
        { members: { some: { user_id: userId } } }
      ]
    }
  })

  return realm !== null
}

Data Flow with Realm Isolation

Example: Creating a Signal

  1. 1. User submits signal creation form
    Form includes realm_id (pre-selected to default realm)
  2. 2. Validation checks realm_id is present
    Zod schema requires the field
  3. 3. Backend verifies user has access to that realm
    Calls userHasRealmAccess(userId, realmId)
  4. 4. Signal created with realm foreign key
    Database enforces the relationship
  5. 5. Future queries automatically filter by realm
    User can only see signals from their accessible realms

Common Query Patterns

Pattern 1: List All Entities

// Get all signals user can access
const userRealmIds = await getUserRealmIds(userId)

const signals = await prisma.signal.findMany({
  where: {
    realm_id: { in: userRealmIds }
  }
})

Pattern 2: Get Single Entity

// Get specific signal (verify access)
const userRealmIds = await getUserRealmIds(userId)

const signal = await prisma.signal.findFirst({
  where: {
    signal_id: signalId,
    realm_id: { in: userRealmIds }  // Access check
  }
})

if (!signal) {
  throw new Error('Signal not found or access denied')
}

Pattern 3: Update Entity

// Only realm owner can update
const signal = await prisma.signal.findUnique({
  where: { signal_id },
  include: { realm: true }
})

if (signal.realm.user_id !== userId) {
  throw new Error('Only realm owner can update')
}

await prisma.signal.update({
  where: { signal_id },
  data: updateData
})

Pattern 4: Cross-Entity Validation

// Ensure signal and cluster are in same realm
const [signal, cluster] = await Promise.all([
  prisma.signal.findUnique({
    where: { signal_id },
    select: { realm_id: true }
  }),
  prisma.cluster.findUnique({
    where: { cluster_id },
    select: { realm_id: true }
  })
])

if (signal.realm_id !== cluster.realm_id) {
  throw new Error('Signal and cluster must be in same realm')
}

Benefits of Schema-Level Isolation

Security by Design

Data leakage is impossible at the schema level. No amount of application bugs can expose cross-realm data.

Type Safety

TypeScript enforces realm_id requirements at compile time. Forget to pass userId? Compiler error.

Clear Ownership

Every entity knows which realm it belongs to. No ambiguity about data ownership.

Scalable Architecture

One user or thousands — the isolation model is identical. Future sharding is straightforward.

Auditability

Every query is scoped to specific realms. Easy to audit who accessed what data.

Future-Proof

Built for shared realms from day one. Adding multi-user collaboration doesn't require schema changes.

Future: Shared Realms

The schema already supports shared realms through the realms_users table.

When implemented, shared realms will enable:

  • Multi-user collaboration — Multiple people contributing signals to the same realm
  • Role-based permissions — OWNER, CONTRIBUTOR, OBSERVER roles
  • Consent-based synthesis — AI analysis across member signals with explicit permission
  • The ansible network — Remnant instances communicating across shared realms

The architecture is ready. The feature just needs UI and workflow implementation.

Best Practices

Always pass userId to query functions

Never query without realm filtering. Every query function requires userId.

Verify realm access before mutations

Before update/delete, check user owns the realm or is a member.

Use transactions for cross-entity operations

When creating multiple related entities, wrap in Prisma transaction for atomicity.

Never expose realm_id in URLs

Use entity IDs in routes. realm_id is verified server-side during access checks.

Related Documentation