Data architecture¶
Three planes and one derived store. The separation is the library's oldest decision and the one everything else leans on: a request cannot reach control-plane state, and a control-plane operation cannot read tenant data.
flowchart LR
subgraph control["control plane (one schema, shared)"]
tenants["tenants<br/>registry + lifecycle"]
placements["placements<br/>where a tenant lives"]
paudit["platform audit"]
end
subgraph tenantA["tenant plane: acme"]
rowsA["tenant tables"]
rlsA["row-level security<br/>app role"]
end
subgraph tenantB["tenant plane: beta"]
rowsB["tenant tables"]
rlsB["row-level security<br/>app role"]
end
subgraph derived["derived plane"]
neo[("Neo4j<br/>tenant-owned labels")]
end
registry["jdlib.control.registry"] --> tenants
registry --> placements
provision["jdlib tenant provision"] --> tenantA
provision --> tenantB
uow["jdlib.persistence.uow<br/>tenant-bound"] --> rowsA
uow --> rowsB
rowsA --- rlsA
rowsB --- rlsB
graph["jdlib.graph.repository"] --> neo
tenants -.->|lifecycle gate| uow
tenants -.->|lifecycle gate| graph
The control plane¶
jdlib.control holds the registry (registry), the lifecycle (enums, base), the models
(models), the platform audit (audit) and purge (purge). Migrations are
jdlib.migrations.runner, run by jdlib db upgrade-control — an operator step, deliberately not
part of process start, because a process that migrates while it starts does so on every worker.
A tenant's lifecycle state is the gate the request path consults: ContextFactory refuses a request
whose tenant is not active, and the graph plane's jdlib.graph.tenant applies the same rule to a
graph session. One state, two enforcers, no second source.
The placement and isolation strategies¶
jdlib.persistence.strategies is where "where does this tenant's data live" is answered:
| Module | What it does |
|---|---|
base |
the strategy interface every placement implements |
shared |
shared schema: one set of tables, every row carrying its tenant, enforced by RLS |
schema |
schema per tenant: a schema created, migrated and owned per tenant |
rls |
the row-level-security policy itself, applied to the application role |
database |
a placement per database, for the deployments that need the harder boundary |
deprovision |
taking a placement away without taking the tenant's neighbours with it |
The choice is per tenant and recorded in the placement, which is why a deployment can move one tenant to its own schema without changing a line of application code.
The data boundary¶
Two mechanisms, deliberately redundant:
- The tenant-bound unit of work —
jdlib.persistence.uow.UnitOfWorkandjdlib.persistence.repository.TenantRepositorycarry the context's tenant, so a query is scoped by construction. - Row-level security —
jdlib.persistence.session.TenantSessionsets the session's tenant and the database's policy constrains the rows. The application role (JDLIB_RLS__APP_ROLE) is the role the policy applies to, and the owner role is not the role the application connects as.
The redundancy is the point: the first is a programming discipline, the second is enforced by the
database when the first is forgotten. A cross-tenant read is measured in the enterprise example as a
409 INVALID_REFERENCE — a refusal, not an empty result — because an empty result would tell the
caller that the identifier exists somewhere else.
The derived plane¶
jdlib.graph is a derived store: it holds relationships the relational plane cannot express
efficiently, and it is rebuilt from the relational plane rather than being its source of truth. Its
tenant rule is enforced twice — jdlib.graph.tenant binds the session, and the identity constraint
in jdlib.graph.query is applied only to labels the example owns, so the library never rewrites a
query about a model it did not create.
Storage and cache¶
jdlib.storage (keys, metadata, the S3 backend) and jdlib.caching (the protocol, the policy, the
in-process and Redis providers) are both outside the tenant boundary by construction: an object key
carries the tenant in its prefix (jdlib.storage.keys) and a cache key carries it in the namespace
(jdlib.caching.keys), so a shared store cannot become a cross-tenant read. The cache has one
additional rule — it refuses to hold a credential object at all, including inside a container.