Sub-domains, isolation, and the caller

A model is divided into sub-domains — the bounded contexts your domain splits into — and each aggregate declares how its rows are isolated. Isolation is where single-tenant and multi-tenant architectures diverge, and the whole difference is expressed in two places: an entity's isolation declaration, and the caller the platform resolves at the edge of every request.

Sub-domains — the bounded contexts

Every non-manifest file declares exactly one subdomain. It names the bounded context, its owner, and (once) its datastore:

sub-domains/ordering/entities/order.vishwakarma
subdomain ordering {
  summary "Ordering — the Order aggregate."
  owner   "chowk-ordering-team"
}

Chowk is split into catalog, ordering, inventory, and fulfillment (plus a shared enterprise_kernel). The sub-domain name becomes the package every construct in that context lowers into, so Order is ordering.v1.Order. Sub-domains are how a model stays navigable as it grows — each is an independent context with its own aggregates, events, and rules.

The caller — identity resolved at the edge

A Vishwakarma model never authenticates inside a command. By the time any authored logic runs, the request already carries a resolved, trustworthy identity — the caller — that guards and policies read as caller.<axis>. What a caller carries is itself declared, in caller_axes:

sub-domains/enterprise_kernel/caller_axes.vishwakarma
caller_axes {
  doc "Caller identity axes populated beyond the framework floor."

  axis tenant_id  uuid   "Tenant scope the caller is operating in. Empty for single-tenant or system callers."
  axis session_id string "Opaque session identifier. Empty for anonymous / system callers."
  axis client_id  string "Stable client/device fingerprint. Empty for anonymous / system callers."
  axis role       string "The caller's coarse role (CUSTOMER | OPERATOR). Empty for anonymous callers."
}

These axes are caller-sourced — the platform injects them from the authenticated request before your code runs. You never declare tenant_id or role on a command input; the client does not send them, the server resolves them. A guard like caller.role == 'OPERATOR' reads the value the platform put there.

The one place a raw request becomes a caller is a small authentication adapter — the caller provider seam. It verifies the credential the client sent (a bearer token, a session cookie) and populates the caller's axes. It is the only component that knows how a credential is checked; the whole model downstream depends only on the resolved caller.

Isolation — where single- and multi-tenant diverge

An entity declares how its rows are scoped. Chowk's aggregates are global — one shared dataset, no per-tenant partitioning:

entity Product aggregate_root global {
  // … one catalog, shared by everyone
}

That is the single-tenant shape: identity still matters (who is calling, what their role is), but there is no tenant dimension — the caller's tenant_id is simply empty, and no row-level filtering is applied.

A multi-tenant aggregate is the same entity with its isolation named explicitly. It drops global and declares isolation_axes [tenant_id]:

entity Product aggregate_root {
  isolation_axes [tenant_id]
  tenant_id uuid immutable filterable[eq]
  // … one catalog PER tenant
}

Now the platform emits row-level-security policies that filter every read and write by caller.tenant_id. A caller can never see or mutate another tenant's rows — and there is no isolation code in any command to get wrong, because the isolation is derived from the declaration.

The caller is the tenancy seam

Put those two pieces together and the tenancy model of the whole application is expressed in exactly two decisions:

Single-tenantMulti-tenant
Entitiesglobalisolation_axes [tenant_id]
The caller's tenant_idemptyresolved from the credential by the caller provider
Row isolationnoneRLS filters every row by caller.tenant_id
Domain logicidenticalidentical

The domain model — the aggregates, value objects, commands, invariants, events — is the same either way. To make an application multi-tenant you opt the tenant-owned aggregates into isolation_axes [tenant_id] and have the caller provider populate the caller's tenant_id from the verified credential; the framework's generated RLS does the rest. The caller is the single seam where authentication meets the domain's isolation boundary, which is why the tenancy decision lives there and nowhere else.