Local-dev dual-cluster harness
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at README.md, and llms.txt lists every page.A two-region Orleans.Lattice environment that runs entirely on your machine, with
no Azure, no published Orleans.Lattice packages, and no Entra. It mirrors
reference-architecture/local as closely as possible, but differs in
four deliberate ways:
- Project references, not packages. Every head (Silo, MCP, Explorer) builds
directly from
src/**by<ProjectReference>, so the whole stack always reflects your working tree - edit a library anddocker compose up --buildpicks it up, with no pack/publish step. - Two network-isolated regions. Region A and region B are symmetric. Docker networking enforces the isolation: each region has a private network, and the only links between them are two silo-only seams (see Topology): one carries the replication traffic, the other reaches the one shared backup sink.
- Network-isolated primary storage per region. Each region has its own Azurite primary storage, reachable only from within that region. The one deliberate exception is the backup sink: both regions share a single sink over a dedicated backup seam, because a coordinated restore of a replicated tree needs every cluster to read the same backup.
- Real deny-by-default enforcement with per-request identities. The authorization core runs deny-by-default, and a hand-crafted dev identity story (no Entra) lets an agent act as any of four differentiated identities by setting a bearer token.
This is a development harness, not a secure deployment template. It trusts a bearer token as an identity verbatim, permits plaintext replication, and opens the coarse transport gate. None of that is safe outside a throwaway local box. The secure posture (Entra, managed identity,
RequireAuthorization=true, TLS) is the deployed reference architecture underreference-architecture/hosts.
Topology
flowchart LR
subgraph RegionA["Region A (net-a)"]
siloA["silo-a"]
azA["azurite-a<br/>(primary)"]
mcpA["mcp-a"]
expA["explorer-a"]
promA["prometheus-a<br/>+ grafana-a"]
siloA --- azA
mcpA --> siloA
expA --> siloA
promA --> siloA
end
subgraph RegionB["Region B (net-b)"]
siloB["silo-b"]
azB["azurite-b<br/>(primary)"]
mcpB["mcp-b"]
expB["explorer-b"]
promB["prometheus-b<br/>+ grafana-b"]
siloB --- azB
mcpB --> siloB
expB --> siloB
promB --> siloB
end
bkp["azurite-backup-shared<br/>(one shared backup sink)"]
siloA <-->|"net-replication<br/>(cross-region replication link)"| siloB
siloA <-->|"net-backup"| bkp
siloB <-->|"net-backup"| bkp
Region A's storage, telemetry, and MCP head are unreachable from region B and vice
versa. Two dedicated cross-region seams carry the only inter-region traffic:
net-replication attaches only the two silos, so the replication shipper can dial
its peer; net-backup attaches only the two silos and the one shared backup sink,
so every cluster can read the same backup for a coordinated restore. Everything else
stays region-local. The one qualification is the Explorer: it has no network of its
own but runs in its silo's network namespace (network_mode: service:silo-a /
service:silo-b), so it shares that silo's attachments, and the peer silo can
reach the console's port over net-replication.
Telemetry is deliberately not one of those seams, and it flows both ways inside
a region. Each silo exports the whole orleans.lattice meter family at /metrics
for its own Prometheus to scrape (the write direction, feeding Grafana), and each
silo also reads that same Prometheus back through the read-only telemetry facade
(Telemetry__BackendAddress), which is what puts a working Telemetry area in
that region's Explorer. silo-a is wired to prometheus-a and silo-b to
prometheus-b; the sibling region's Prometheus is not resolvable across the network
boundary, so neither region can read the other's series.
Quickstart
Run every command from this directory (reference-architecture/local-dev). Each head
builds from src/** by project reference, so --build always reflects your working
tree - no pack or publish step. The first standup builds three images (silo, MCP,
explorer); the sibling region reuses them.
| Action | Command | Notes |
|---|---|---|
| Stand up (build + run) | docker compose up --build -d |
Builds the images and starts both regions detached; wait for both silos to finish starting (they carry no Docker healthcheck, so follow docker compose logs -f silo-a silo-b). Uses the tracked public nuget.config for the restore step. |
| Stand up with a private / offline NuGet feed | PowerShell: $env:NUGET_CONFIG_FILE = "$env:APPDATA\NuGet\NuGet.Config"; docker compose up --build -d bash: NUGET_CONFIG_FILE=/path/to/NuGet.Config docker compose up --build -d |
Points the build-time nugetcfg secret at your own NuGet.Config (a private, proxy, or offline feed) instead of ./nuget.config, for when public nuget.org is unreachable. The secret is never baked into an image layer. |
Rebuild after editing src/** |
docker compose up --build -d |
Project references pick up the change. Append service names (e.g. ... silo-a silo-b) to rebuild and recreate only those services. |
| Reseed the identity model | docker compose restart silo-a silo-b |
Applies edits to identities.json with no rebuild. |
| Stop (keep data) | docker compose stop |
Halts the containers; volumes and networks remain. docker compose start resumes. |
| Tear down (keep data) | docker compose down |
Removes containers and networks; the per-region Azurite volumes survive, so grain state, clustering, reminders, and the WAL persist to the next standup. |
| Tear down and wipe data | docker compose down -v |
Also deletes the Azurite volumes (each region's primary plus the one shared backup sink) for a clean slate. |
| Wipe data only, then restart | docker compose down -v; docker compose up --build -d |
Clean-slate restart: drops the storage volumes, then rebuilds and starts both regions. |
| Stand up with multi-tenancy enabled | PowerShell: $env:TENANCY_ENABLED = "true"; docker compose up --build -d bash: TENANCY_ENABLED=true docker compose up --build -d |
Turns on the opt-in multi-tenancy feature in both silos and MCP heads together. The silos register the tenant registry + admin API and seed the demo tenants from identities.json's tenants section; the heads dial the silo's tenant-admin facade and advertise the tenant self-awareness tools (lattice_tenant_current / _list / _get). Add $env:TENANCY_CONTROL = "true" (bash TENANCY_CONTROL=true) to also advertise the mutating tenant-administration tools. Off by default - with TENANCY_ENABLED unset the stack is byte-for-byte the single-tenant cluster. |
Ports
Only three surfaces per region are published to the host, all on fixed ports
>= 9000. Every other container - the silos, Azurite, and Prometheus - is internal
to its region's Docker network and has no host port.
| Surface | Region A | Region B |
|---|---|---|
| Explorer console | http://localhost:9080 | http://localhost:9081 |
| MCP endpoint | http://localhost:9090 | http://localhost:9091 |
| Grafana | http://localhost:9300 | http://localhost:9301 |
| Silo HTTP / gRPC | internal to net-a | internal to net-b |
| Prometheus | internal to net-a | internal to net-b |
| Azurite primary | internal to net-a | internal to net-b |
| Azurite backup | one shared sink on net-backup, reachable from both silos | (shared) |
Prometheus has no host port on purpose. Read its series through that region's Grafana, that region's Explorer Telemetry area, or that region's MCP telemetry tools - all three reach it from inside the region's own network.
The identity model
The identities, their groups, and each group's grants live in
identities.json, which is mounted read-only into both silos and
seeded at startup by LocalDevIdentitySeeder. That seeder does two things:
- writes the groups and memberships into the durable membership directory (the
sys-membership-*trees), so they are introspectable through the ordinary read / scan surface and the Explorer's Access area; and - authors each group's authorization grant into the policy store
(
sys-auth-policy), so under deny-by-default the identities have genuinely different power.
| Identity | Group | Can do |
|---|---|---|
platform-admin |
platform-admins |
Everything (bootstrap administrator; also seeded a cluster-wide full grant). |
region-operator |
operators |
Read/write every tree: read, write, delete, range read/delete, CRDT apply, atomic/cross-tree write, bulk load, routine tree admin. No backup, schema, telemetry, replication, or tree lifecycle. |
data-reader |
readers |
Read only: single-key read and range read on every tree. Nothing else. |
auditor |
auditors |
Telemetry only: read cluster telemetry. No tree data, no mutations. |
Any other bearer id authenticates as itself but, carrying no groups, is denied everything - deny-by-default in action.
Edit identities.json and docker compose restart silo-a silo-b to change the
model; no rebuild is needed.
Acting as an identity
There is no Entra tenant and no per-identity registration. You choose the identity - and, once tenancy is enabled, the tenant you act as - per call:
MCP / gRPC: set the bearer token to the identity id.
# Act as the read-only data-reader against region A's MCP head. curl -s http://localhost:9090/ \ -H "Authorization: Bearer data-reader" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'The MCP head authenticates the request as the subject named in the bearer token and forwards it to the silo; fail-closed tool discovery then advertises only the tool groups that identity's grants reach. Discovery is coarse, so a group's mutating tools are listed alongside its reads and refused at call time for a caller who lacks them, with two refinements: the data group lists its mutating tools only to a caller whose grants include a mutating data-plane operation (
Write,Delete,RangeDelete,CrdtApply,AtomicWrite, orBulkLoad), and the scopelessTelemetryandAppInstallcapabilities count only from a grant written cluster-wide, as every grant inidentities.jsonis.data-readersees the state tools and the data reads;auditorsees the telemetry tools;region-operatorsees the state, data, auth, and tree-administration tools;platform-adminsees everything; an unlisted id sees only thelattice_capabilitiesmeta-tool, and a call with no token sees nothing.Explorer: the console opens at its sign-in dialog. Enter any identity id as the username (the password is ignored) to browse as that identity - start with
platform-admin. Sign out and sign back in as another id to see that identity's view: adata-readersees only readable trees, anauditorsees no tree data at all, and the Access area reflects the seeded groups and grants.Sign-in here is deliberately manual rather than seeded. The web Explorer withholds the
LATTICE_EXPLORER_USERNAME/LATTICE_EXPLORER_PASSWORDcredential seed unless a host opts in - the store is per browser, so a seeded credential would sign every anonymous visitor in as the operator - and this harness does not opt in, so those two variables are not set on either console. That is not just caution: a web head signs out by clearing the credential cookie and reloading, which is exactly the empty-store condition the seed fires on, so an enabled seed would sign you straight back in and make sign-out - and with it the identity switching this whole harness exists to demonstrate - impossible. One dialog on first load buys a working four-identity demo.Acting as a tenant (tenancy stack only): identity and tenant are independent axes. The bearer token says who you are; the
lattice-active-tenantheader says which tenant you are acting as. Send both:# Act as platform-admin, asserting the acme tenant. curl -s http://localhost:9090/ \ -H "Authorization: Bearer platform-admin" \ -H "lattice-active-tenant: acme" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"lattice_tenant_current","arguments":{}}}'That answers
{"tenantId":"acme","status":"Active","isDefault":false}. Drop thelattice-active-tenantheader and the same call answers{"tenantId":"default","status":"Active","isDefault":true}- no header means the reserveddefaulttenant, not a failure. The head lifts the header onto the ambient active-tenant context for the tool call and the credential-forwarding interceptor re-emits it to the silo as gRPC metadata, so the assertion does cross the split-head process boundary.The tenant is validated, not trusted. This harness takes the bearer token as an identity verbatim, but it does not extend that trust to the tenant: the silo authenticates the identity from the token and then independently checks the asserted tenant against that identity's seeded
adminSubjects(thetenantssection ofidentities.json).data-readeradministersglobexbut notacme, so assertingglobexanswers{"tenantId":"globex","status":"Active","isDefault":false}while assertingacmeis refused fail-closed withPermissionDenied- "The operation was denied: no valid active tenant is present for the caller." - rather than being quietly downgraded todefault. Header lifting is on by default and this harness does not rename it, solattice-active-tenantis the value to send; withTENANCY_ENABLEDunset the header is inert.
Demo 1 - differentiated access (deny-by-default)
docker compose up --buildand wait for both silos to finish starting (see the Quickstart table).tools/liston region A's MCP (port 9090) asplatform-admin- full tool set.- Repeat as
data-reader- the state tools and the data reads (the data group's write tools are withheld, because its grants include no mutating data-plane operation). Asauditor- only telemetry. Asregion-operator- the state, data, auth, and tree-administration tools, but no backup, telemetry, or replication. As an unlisted id such asnobody- only thelattice_capabilitiesmeta-tool; with no bearer - zero tools. - Try a write as
data-reader(call a data write tool) - it is refused, because discovery never offered that tool to its session. To see the per-subject access gate refuse a call the transport let through, call a tree-lifecycle tool such aslattice_treeadmin_tree_deleteasregion-operator: its tree-administration tools are listed (it holdsAdmin), but the silo denies the verb, which needsTreeLifecycle.
Demo 2 - replication across isolation
- As
region-operator(orplatform-admin) on region A's MCP (9090), write a key into theorderstree (the demo replicated tree, declared inReplication__Trees). - Read the same key from region B's MCP (9091) as
data-reader. It converges into region B overnet-replication, even though region B cannot reach region A's storage, telemetry, or heads - only the silos' replication seam bridges the two regions.
Demo 3 - telemetry, region-local
The Explorer's Telemetry area is a read plane over that region's Prometheus.
The silo exposes it: Telemetry__BackendAddress names the region's own Prometheus,
and the telemetry gRPC binding rides the same silo endpoint the console already
uses for State, so the console needs no second address.
- Open region A's Explorer (http://localhost:9080) and sign in as
platform-admin(any password). The directory spine shows a Telemetry area; open it and its charts render series scraped fromsilo-a. - Sign out and back in as
data-readerorregion-operator, or browse signed out. None of them is entitled to telemetry, so the catalogue comes back empty: the Telemetry area still appears, but it renders no charts, only a "No metrics to show" notice. The facade makes "no backend here" and "nothing offered to you" deliberately indistinguishable, so a caller cannot probe its own entitlement. - The area is region-local: region B's Explorer (http://localhost:9081) shows
region B's series, read from
prometheus-b. Neither region can read the other's telemetry - only replication and the shared backup sink cross the boundary.
auditorreaches telemetry without holding any data grant.auditorholds the scopelessTelemetrycapability and nothing else: its MCP telemetry tools work and the Explorer's Telemetry area renders charts for it, while every tree read is still denied. The seeded grant is authored cluster-wide over the all-trees sentinel (LatticeScope.ClusterWide(), whose own documentation namesLatticeOperation.Telemetryas its intended use), and both the telemetry facade and the tree-administration facade authorize the capability over that same sentinel, so a grant authored the documented way is the grant that is honoured. MCP discovery applies the same rule: it counts a scopeless capability only from a cluster-wide grant, so the sameTelemetrybit on a tree-scoped rule would neither list the telemetry tools nor unlock them.platform-adminreaches the area by a different route: a bootstrap administrator bypasses the gate outright. Until #1795 the two facades asked about the reserved auth-policy tree instead, so this delegated grant was silently inert and only bootstrap administrators could see the area.
Unset Telemetry__BackendAddress on a silo and that region's telemetry surface
disappears entirely: the binding answers Unimplemented, the Explorer's gate reads
the surface as absent, and the Explorer hides the Telemetry area for everyone.
Demo 4 - tenant isolation (opt-in multi-tenancy)
This demo requires the stack be stood up with tenancy on:
TENANCY_ENABLED=true docker compose up --build -d (see the Quickstart table).
The silos seed two demo tenants from identities.json's
tenants section, each with its own set of admin subjects:
| Tenant | Admin subjects |
|---|---|
acme (Acme Corporation) |
platform-admin, region-operator |
globex (Globex Corporation) |
platform-admin, data-reader |
tools/liston region A's MCP (9090). The tenant tools are grant-gated per identity, exactly like every other tool group, so different identities see different subsets (with tenancy off they are absent for everyone):platform-adminandregion-operatorsee all eleven - the three self-awareness tools (lattice_tenant_current,lattice_tenant_list,lattice_tenant_get) plus the eight administration tools (lattice_tenant_create/_suspend/_resume/_delete/_set_quotas/_authorize_regions/_set_residency, all mutating, and the read-only_region_status), which are advertised only whenTENANCY_CONTROL=true.data-readersees only the three read/self-awareness tools - no administration tools.auditor(telemetry-only) sees no tenant tools at all.
Call
lattice_tenant_listasplatform-admin- it returns bothacmeandglobex(it administers both). Asregion-operator- onlyacme. Asdata-reader- onlyglobex. Each identity sees exactly the tenants its seeded admin-subject membership allows, resolved from its own bearer credential.lattice_tenant_getfor a tenant the caller administers returns its status report; for one it does not, the tenant reads back asNotFound- isolation does not even leak the tenant's existence to a non-administering subject, the same deny-by-default discipline as tree access.lattice_tenant_currentreflects the tenant the call asserts, which is a second axis independent of identity: send thelattice-active-tenantheader alongside the bearer token (see Acting as an identity). Three calls show the whole behaviour:- No header, as
platform-admin- the reserved default tenant, exactly as before:{"tenantId":"default","status":"Active","isDefault":true}. lattice-active-tenant: acme, asplatform-admin(anacmeadmin) -{"tenantId":"acme","status":"Active","isDefault":false}. The active tenant does cross the split-head process boundary: the head lifts the header into the ambient tenant context and the credential-forwarding interceptor re-emits it to the silo over gRPC.lattice-active-tenant: acme, asdata-reader(aglobexadmin, not anacmeone) - refused fail-closed withPermissionDenied, "The operation was denied: no valid active tenant is present for the caller." The same identity assertingglobexsucceeds, so the refusal is membership, not a broken header.
The assertion is therefore validated rather than trusted: a caller cannot act as a tenant it does not administer, and a bad assertion is refused rather than quietly downgraded to
default. Tenant scoping is demonstrable on both axes -_list/_getderive from the caller's credential, while the asserted tenant scopes the tenant-aware surfaces.- No header, as
lattice_list_regionsis scoped by that same assertion, so call it twice. Without thelattice-active-tenantheader the answer is the unscoped topology, byte-for-byte what a single-tenant cluster returns, and no entry carries atenantScopeproperty at all. Re-send it withlattice-active-tenant: acmeand each entry gains atenantScopeobject naming the tenant and its standing in that region (isAllowed,isResident,status). That property appearing and disappearing is the visible proof the assertion reached the region filter, and it is the check worth running: the filter engages only on an asserted tenant, so a run without the header exercises none of it and would pass regardless. Be clear about what this harness can show: each head serves exactly one region (the compose file sets neitherMcp:RegionIdnor anyMcp:Regionspeer, so the current region is the built-incurrentand there are no peers), and the region a caller is talking to is always advertised to it. You see the annotation here, not the pruning - a peer outside the tenant's allowed-or-resident set dropping out of the list needs a multi-region head, which is the deployed reference architecture's shape rather than this harness's.
Configuration knobs
Everything has a baked-in dev default, so no .env is required. Override via the
environment or a .env file next to this compose file:
| Variable | Default | Purpose |
|---|---|---|
LATTICE_REPLICATION_SECRET |
local-dev-only-not-a-real-secret |
Shared key that authenticates inbound replication RPCs. Must be identical in both regions. |
STORAGE_CONNECTION_STRING_A / _B |
Azurite emulator string | Per-region primary storage. |
BACKUP_BLOB_CONNECTION_STRING |
Azurite emulator string | The one shared backup Blob sink, identical in both regions (region A is backup-primary and owns the scheduler; region B is DR standby). |
TENANCY_ENABLED |
false |
Opt-in multi-tenancy. When true, both silos register the tenant registry + tenant-admin API and seed the demo tenants from identities.json, and both MCP heads dial the tenant-admin facade and advertise the tenant self-awareness tools. Off leaves the stack byte-for-byte single-tenant. |
TENANCY_CONTROL |
false |
When true (and TENANCY_ENABLED=true), the MCP heads also advertise the mutating tenant-administration tools. Ignored when tenancy is off. |
NUGET_CONFIG_FILE |
./nuget.config |
The NuGet.Config the image builds restore with (the nugetcfg build secret). Point it at your own for a private or offline feed; it never lands in an image layer. |
Two per-region values are deliberately not .env knobs, because pointing either
across the region boundary would make the harness lie about its isolation: each
silo's Telemetry__BackendAddress and each MCP head's Mcp__Telemetry__BackendAddress
name that region's own Prometheus (prometheus-a / prometheus-b) directly in the
compose file. Clear a silo's value to switch that region's telemetry surface off
entirely - the Explorer then renders no Telemetry area there.
How the dev identity seams fit together
| Concern | Component | What it does |
|---|---|---|
| Resolve a forwarded bearer to a subject + groups (silo) | DevIdentityCredentialAuthenticator |
Trusts any bearer id as the subject and attaches the groups identities.json declares for it. Registered only when Entra is off. |
| Seed the model into the cluster (silo) | LocalDevIdentitySeeder |
Writes groups + memberships to the membership directory and each group's grant to the policy store. Idempotent, retried until the silo is active. |
| Seed the demo tenants (silo, tenancy only) | TenantSeeder |
When TENANCY_ENABLED=true, writes each tenants entry from identities.json into the tenant registry with its admin subjects. Idempotent LWW upserts, retried until the silo is active. Not registered when tenancy is off. |
| Per-request identity at the edge (MCP) | DevBypassAuthenticationHandler |
Authenticates each request as the subject in its own Authorization: Bearer <id> header; no bearer means anonymous (zero tools). |
| Per-identity sign-in (Explorer) | DevBypassExplorerAuthMethod |
Forwards the username entered at the sign-in dialog as the caller's bearer token, so the console is served as that identity. Sign-in is manual: the environment credential seed is deliberately left off (see Acting as an identity). |
All five exist only in this harness and never ship in a real deployment host (the
single-region hosts/ heads have separate, Entra-off-only dev bypasses, two of which
share these names).
Why the MCP head carries an administrator token
Tool discovery is fail-closed: for each caller the MCP head asks the silo's auth
control plane which facade groups that caller's grants allow, and advertises only
those tools. That introspection is administrator-gated. When the MCP server is
co-hosted in the silo it uses an in-process system-origin bypass to run the read on
the caller's behalf - but this harness runs the MCP head as a separate process
that reaches the silo over gRPC, and the in-process bypass does not cross the wire.
So each head is configured with Mcp__AdministratorToken: platform-admin (a
bootstrap administrator): the discovery introspection is forwarded to the silo under
that administrator service credential, which lets every caller's own grants light up
their tools. Enforcement is unaffected - the caller's own bearer token authorizes
every actual tool call, so a region-operator still sees the tree-lifecycle tools
advertised but is denied them at call time by deny-by-default. Without this token only an administrator
caller could enumerate a full tool set remotely; every non-admin identity would fall
back to the lattice_capabilities meta-tool alone.