How the modules talk, and how they split apart
Fourteen modules share one build and never import each other. They exchange integration events and call each other through versioned capability contracts. The same build runs as one process or as several services behind an edge, depending on configuration.
At a glance
Modules
Each module owns a PostgreSQL schema and connects with its own database role, which can reach nothing else. Other modules learn about its data only through events or a capability. Every module is built the same way (Module design); each one is described in Domains.
identityaccountauthzauditusagefilesbillingentitlementsnotificationsprivacyaichatrealtimeintegrationsWho talks to whom
Capabilities are synchronous calls from a caller to a provider, with a two-second deadline, and they fail closed. Events are asynchronous, carried by each module's outbox, and delivered at least once. Select a module to trace its links.
Selected relationships from the Go backend's capability clients and event consumers, including all fifteen modules. This is a reading map, not an exhaustive subscription or runtime route inventory. Privacy gathers supported personal-data sections through *.personal_data.v1 capabilities; see Domains for each module's reference.
Capability contracts
The table shows selected examples; the complete, versioned catalog is in contracts/capabilities/. A capability is named after the module that provides it. It is an external protocol, not shared code: the provider implements a handler, the caller implements a client behind its own port, and the composition root connects them. The call runs in-process when the provider is local and over HTTP when it is not. The JSON is the same either way. Over HTTP, each call carries a service assertion signed with the calling service's own Ed25519 key, and each capability lists the callers it accepts.
| Capability | Callers | Answers |
|---|---|---|
identity.session.v1 | Edge, Realtime | The active identity for a session token, or none. |
identity.session_authentication.v1 | Privacy | The session's identity and when it last signed in, for recent-sign-in checks. |
identity.email.v1 | Account | An identity's normalized sign-in email. |
identity.profiles.v1 | Account, Audit, Realtime, Integrations, Notifications | Display names and emails for up to 100 identities. |
account.shared_ownerships.v1 | Identity | Workspaces the identity owns that have other members. Self-deletion is refused while any exist. |
authorization.permission_check.v1 | Account, Identity, Audit, Entitlements, Files, Usage, AI, Realtime, Billing, Integrations, Notifications, Operations, Chat | Whether the principal holds a permission in a scope. Always false in a closed or purged workspace. |
entitlements.account.v1 | Account, Files, Usage, AI, Integrations | Feature grants and numeric limits. A missing limit is unconfigured, never unlimited. |
files.read.v1 | AI | A five-minute download link for one available file. |
files.describe.v1 | AI | Bounded file metadata and availability used by AI file selection and retrieval. |
usage.reserve.v1 · commit · release | AI | Hold, settle or return metered usage around a unit of work. |
ai.run_access.v1 | Realtime | Whether the principal may see one AI run. |
<module>.personal_data.v1 | Privacy | One page of the personal data a module holds about a principal, for an export. |
Fail closed
A slow or failing provider is a dependency failure, never an answer. An unreachable Identity is a 503, not an anonymous request, and a failed permission check is not a denial. See Errors. Only a successful negative answer may deny, hide or refuse.
Split deployment
BENTO_MODULES chooses which modules a process runs. This is the layout the split end-to-end suite runs for both backends: Account and Files in one service, the rest in another, and an edge in front. The suite also runs a Realtime-only service behind its own edge. The monolith is the same build with no split settings.
- The client sends its session cookie to the edge, the only public entry point.
- The edge resolves the session once, through
identity.session.v1. - It drops any client-supplied principal header and forwards a signed Ed25519 assertion, valid for 60 seconds.
- Service B calls capabilities in A over
/internal/capabilities, signing each call with its own service key. - Both services publish and consume events through NATS. Separately, the edge polls each service's
GET /internal/routesevery 30 seconds to learn which routes it serves.
| Setting | Monolith | Service in a split | Edge |
|---|---|---|---|
BENTO_ROLE | service | service | edge |
BENTO_MODULES | unset (all) | its modules, e.g. account,files | unused |
EVENT_TRANSPORT | memory | nats (required) | unused |
BROADCAST_TRANSPORT | memory | nats (required) | unused |
SERVICE_SIGNING_KEY | unset | this service's Ed25519 key | the edge's own key |
SERVICE_KEYS | unset | public keys of the services that call it | public keys of its upstreams |
CAPABILITY_PROVIDERS | unset | identity=http://a,… | identity=http://a |
PRINCIPAL_SOURCE | session | assertion + public keys, unless it runs Identity | session + signing key |
EDGE_UPSTREAMS | unset | unset | service base URLs |
A split refuses to start without NATS for events and broadcasts, without service keys, or without a provider, local or remote, for every capability its modules require.
One request through the edge
Accepting an invitation in the split above. The edge authenticates once. Account never sees the session and reaches other modules only through capabilities.
- Client→Edge
POST /v1/accounts/invitations/{id}/acceptwith the session cookie. - EdgeDrops any
X-Bento-Principalheader the client sent. - Edge→Identity
identity.session.v1returns the principal. - Edge→AccountForwards the request with a signed assertion, valid for 60 seconds.
- AccountVerifies the assertion with the edge's public key.
- Account→Identity
identity.email.v1confirms the invitee's email. - Account→Entitlements
entitlements.account.v1checks the member limit. - AccountAdds the membership and its outbox row in one transaction, then returns
200. - Account⇢NATSThe relay publishes
account.membership_added. - NATS⇢AuthorizationGrants the role the invitation carries. Its inbox drops a redelivered event.
Rules that keep it splittable
Own schema only
A module reads and writes its own schema. No cross-schema joins, views, triggers or foreign keys.
State and event commit together
The outbox row is written in the state transaction, and each consumer dedupes through its inbox.
Fail closed
Only a successful negative answer may deny, hide or refuse. Anything else is a dependency failure.
Contracts are language-neutral
The Go edge served the Nest services unchanged. Every protocol is specified in contracts/.
Enforced, not just agreed
Import checkers in both backends fail make check and CI on a cross-module import, including in tests. Each schema has its own migration history, starting from0000_<schema>_baseline.sql. See Module design.
In the repository
backend/SERVICE-BOUNDARIES.mdSchemas, roles, migrations and extraction.backend/EVENT-RULES.mdOutbox, inbox and delivery guarantees.backend/MODULE-AUTHORING.mdAdding a module that follows the rules.contracts/capabilities/README.mdEvery capability and its wire protocol.contracts/edge/README.mdAssertions, route listings and the edge.backend/ARCHITECTURE-ENFORCEMENT.mdHow the import checkers work.