---
title: "Local smoke-test harness"
url: "https://nsta1.github.io/Orleans.Lattice/reference-architecture/local/README.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/reference-architecture/local/README.md"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
---
# Local smoke-test harness

Part of [Orleans.Lattice reference architecture: deploy and configuration guide](../README.md).

A `docker compose` stack that brings up the whole Orleans.Lattice reference
architecture on your machine, **without Azure**. It is a smoke test and a
learning aid, not a secure deployment template.

## What comes up

| Service | Image / build | Purpose | Host URL |
|---|---|---|---|
| `azurite` | `mcr.microsoft.com/azure-storage/azurite` | Azure Storage emulator: Table (clustering, grain state, reminders, WAL) | Table `:10002`, Blob `:10000` |
| `azurite-backup-sink` | `mcr.microsoft.com/azure-storage/azurite` | Dedicated Azure Storage emulator (own mounted volume) for the Blob backup sink, isolated from the primary cluster storage | Blob `:11000` |
| `silo` | builds `hosts/Silo/Dockerfile` | The always-on Orleans + Lattice silo cluster | health/metrics `http://localhost:18080`, gRPC `:18081` |
| `mcp` | builds `hosts/Mcp/Dockerfile` | Stateless remote MCP head (gRPC client to the silo) | `http://localhost:8090` |
| `explorer` | builds `hosts/Explorer/Dockerfile` | Standalone Explorer web console (gRPC-web client to the silo) | `http://localhost:8080` |
| `prometheus` | `prom/prometheus` | Scrapes the silo `/metrics` endpoint | `http://localhost:9090` |
| `grafana` | `grafana/grafana-oss` | Bundled Orleans.Lattice dashboards over Prometheus | `http://localhost:3000` |

The silo reaches Azurite by connection string. The **MCP** head reaches the silo
by its compose service name (`http://silo:8081`). The **Explorer** head shares the
silo's network namespace (`network_mode: service:silo`) and reaches the silo over
loopback (`http://localhost:8081`) - its transport-security policy only permits the
anonymous plaintext dev path to a *loopback* host, and a cross-container service
name (`silo:8081`) is not loopback. Prometheus scrapes `silo:8080/metrics`; Grafana
provisions the Prometheus data source and the dashboards shipped by
`Orleans.Lattice.Dashboards` (mounted read-only from `src/lattice.dashboards`).

## Endpoints and ports

| Head | Browse / call at | Container port (host:container) | Notes |
|---|---|---|---|
| Explorer web console | <http://localhost:8080> | `8080:8082` (declared on `silo`) | Blazor Server; auto-connects on first load, then you sign in as `local-dev-admin` (see below). Kestrel binds `8082` because `8080`/`8081` are the silo's in the shared namespace. |
| MCP endpoint | <http://localhost:8090> | `8090:8080` | Streamable-HTTP MCP transport root; liveness at `/health`. Advertises the state, data, backup, auth, telemetry, replication, and tree-administration tool groups (the mutating auth-administration verbs stay off). |
| Silo health / metrics | <http://localhost:18080> | `18080:8080` | `/health`, scaling signal, Prometheus `/metrics`. |
| Silo gRPC (state / auth / replication) | `localhost:18081` | `18081:8081` | Exposed for host-side tooling; the heads dial it in-cluster (`silo:8081`, or loopback for the Explorer). |
| Prometheus | <http://localhost:9090> | `9090:9090` | |
| Grafana | <http://localhost:3000> | `3000:3000` | Anonymous viewer enabled; admin `admin`/`admin`. |

## Prerequisites

- **Docker Desktop** (or a Docker Engine with the Compose v2 plugin) running. No
  .NET SDK, Azure subscription, or Azure CLI is needed - the heads build inside
  containers and all storage is emulated by Azurite.
- Free local ports: `8080`, `8090`, `18080`, `18081`, `10000-10002`, `11000`,
  `9090`, `3000`. Stop anything already bound to them (or edit the `ports:` mappings).

## Setup and run (step by step)

1. **Change into the harness directory** (all commands below run from here):

   ```bash
   cd reference-architecture/local
   ```

2. *(Optional)* **Override a default.** Every value has a baked-in local dev
   default, so no `.env` file is required. Create one only to override something
   (for example to point the storage connection string somewhere other than
   Azurite):

   ```bash
   cp .env.example .env            # PowerShell: Copy-Item .env.example .env
   ```

3. *(Optional)* **Validate the compose file** without starting anything:

   ```bash
   docker compose config
   ```

4. **Build and start the stack.** The first run builds the three .NET head images
   in-container, so it takes a few minutes; later runs are cached and start in
   seconds:

   ```bash
   docker compose up --build          # add -d to run detached (in the background)
   ```

5. **Wait for the silo to come up.** Azurite is health-gated, so the silo waits
   for the Table endpoint before it starts. Watch the logs until the silo reports
   it is listening:

   ```bash
   docker compose logs -f silo        # Ctrl+C to stop following
   ```

6. **Use the cluster:**

   - **Explorer** - open <http://localhost:8080> and browse the live cluster. It
     **auto-connects** on first load (endpoint seeded from
     `LATTICE_EXPLORER_ENDPOINT`); then sign in with the username
     `local-dev-admin`, the bootstrap administrator (the password is ignored).
     Until you sign in the console connects anonymously, which the silo's
     state-visibility filter fail-closes to an empty tree catalog and a denied
     Access area; the dev sign-in forwards a trusted bootstrap-admin bearer token
     (the same mechanism MCP uses) so the catalog and Access area are fully
     populated. See the dev-auth note below.
   - **MCP** - reachable at <http://localhost:8090> (Streamable-HTTP MCP transport
     root; liveness at `/health`). It advertises the state, data, backup, auth,
     telemetry, replication, and tree-administration tool groups (the mutating
     auth-administration verbs stay off) - see the dev-auth note below for why.
   - **Grafana** - <http://localhost:3000> (anonymous viewer enabled; admin login
     `admin` / `admin`). The Orleans.Lattice dashboards appear under the
     `Orleans.Lattice` folder and populate as the silo emits metrics.

## Add trees and data (for a demo)

**A fresh cluster has no trees.** Nothing is pre-seeded, so on first start the
Explorer catalog is empty and the metrics dashboards are flat. A tree is not
declared up front - it is **materialized by its first write** and then appears in
the Explorer catalog automatically. Some convenient ways to put data in:

- **Ask an LLM to drive the MCP head (most convenient).** Point any MCP-capable
  assistant (Copilot, Claude Desktop, or your own client) at the Streamable-HTTP
  MCP endpoint <http://localhost:8090> and ask it, in plain English, to create a
  tree and write some entries. The head advertises the full write tool set, so the
  model can call `lattice_data_set` / `lattice_data_set_many_atomic` to seed trees,
  then `lattice_state_list_trees` and `lattice_state_scan_entries` to read them
  back - a fast, no-code way to populate a demo and explore the state tools. The
  local head is open (dev bypass), so no token is needed.

- **Call the MCP head from a small script.** Any Model Context Protocol client
  works. For example, with the TypeScript SDK, connect a
  `StreamableHTTPClientTransport` to <http://localhost:8090> and call the
  `lattice_data_set` tool (arguments: `treeId`, `key`, and a base64 `value`); the
  named tree springs into existence on the first write.

- **Write through the Data API directly.** The silo's read-write Data API gRPC
  facade is enabled and open in this harness (`localhost:18081`); a Lattice client
  or the Data API binding can write to it without going through MCP.

After any of these, refresh the Explorer at <http://localhost:8080> - the new tree
appears in the catalog, and you can browse its shards, entries, and history, and
watch the Grafana dashboards react.

## Teardown

Azurite persists its Table + Blob data to named Docker volumes, so the cluster's
state **survives a restart and recreate**. The primary emulator (`azurite-data`)
holds the durable WAL, grain state, clustering, and reminders; the dedicated
backup sink (`azurite-backup-data`) holds the Blob backups. Choose a teardown
based on whether you want to keep that data:

- **Stop, keep data** - stop and remove the containers but keep the volume, so the
  next `docker compose up` resumes with the same trees and state:

  ```bash
  docker compose down
  ```

- **Stop and wipe data** - also drop the `azurite-data` and `azurite-backup-data`
  volumes for a clean slate (the next start comes up with an empty cluster):

  ```bash
  docker compose down -v
  ```

- **Pause without removing** - just stop the containers, keeping everything in
  place to resume with `docker compose start`:

  ```bash
  docker compose stop
  ```

## Security posture: this is a documented dev bypass

This harness deliberately runs a **fully-open, unauthenticated** cluster so it
comes up with no Azure and no identity provider. The relevant toggles, and what
they become in a real deployment (see
[`../hosts/README.md`](../hosts/README.md)), are:

| Toggle (compose) | Local value | Azure value |
|---|---|---|
| `Entra__Enabled` (all heads) | `false` (no sign-in) | `true` (Entra JWT on every facade) |
| `StateApi__RequireAuthorization` / `Mcp__RequireAuthorization` | `false` | `true` |
| `Auth__DefaultEffect` (silo) | `Allow` | `Deny` (deny-by-default) |
| `Mcp__DevAuthenticateAll` (mcp) | `true` (synthetic subject) | `false` (real Entra subject) |
| `Auth__DevAuthenticateForwardedSubject` (silo) | `true` (trusts a forwarded bootstrap-admin bearer id) | unset (Entra authenticates every caller) |
| `LATTICE_EXPLORER_USERNAME` / `LATTICE_EXPLORER_PASSWORD` (explorer) | `local-dev-admin` / `local-dev-unused`, but inert: the web Explorer withholds this credential seed, so you sign in as `local-dev-admin` at the dialog (dev bearer sign-in) | unset (interactive Entra OIDC sign-in) |
| `Replication__AllowPlaintext` (silo) | `true` (h2c) | `false` (server TLS via the region FQDN) |
| Storage identity | Azurite connection string | managed identity (`DefaultAzureCredential`) |

### Why the MCP head authenticates a synthetic subject

MCP tool **discovery** is fail-closed: the head advertises a tool group only
when the caller holds an **authored** Allow rule covering one of the group's
operations. With no identity provider (Entra off) an anonymous caller resolves
to no subject and is offered **zero tools** - even though `Auth__DefaultEffect=
Allow` would permit the calls. Two coordinated dev toggles bridge that gap:

- The MCP head (`Mcp__DevAuthenticateAll=true`) authenticates **every** request
  as one fixed synthetic subject, `Mcp__DevSubjectId` (default `local-dev-admin`).
  This branch is honoured only because Entra is disabled; it is forced inert in
  any Entra deployment, so it can never weaken a real estate.
- The silo (`Auth__BootstrapAdministrators=local-dev-admin`) seeds that same
  subject a cluster-wide full-access grant at startup, so discovery advertises
  the complete tool set to it.

The two ids **must match**. This is the same seeding mechanism a deployed estate
uses for its designated security administrator; here it targets a throwaway
synthetic subject instead of a real Entra `oid`.

### Why the Explorer console signs in as the bootstrap administrator

The Explorer's read-only surfaces (the tree catalog, per-tree structure, and the
Access area) flow through the **same** fail-closed state-visibility filter: an
anonymous caller sees an empty catalog and a denied Access area, so the console
would look broken next to MCP, which sees everything. With Entra off the console
has no sign-in provider to authenticate against, so the reference host registers a
dev-only sign-in method (`DevBypassExplorerAuthMethod`, wired **only** when
`Entra__Enabled=false`) that forwards `authorization: Bearer <username>` to the
silo - the exact credential the silo's `DevBypassCredentialAuthenticator` trusts
when the id is a configured bootstrap administrator. You apply it at the console's
sign-in dialog. The compose file does set `LATTICE_EXPLORER_USERNAME` /
`LATTICE_EXPLORER_PASSWORD` on the `explorer` service, but they are inert: the web
Explorer withholds that environment credential seed unless the host opts in with
`LatticeExplorerWebOptions.AllowEnvironmentCredentialSeed`, and this host does not.
As with the MCP head this is inert under Entra (the real OIDC sign-in provider is
used instead), so it can never weaken a real estate. The username you enter **must
match** the silo's bootstrap admin (and the MCP head's `Mcp__DevSubjectId`); the
password is ignored.

### The Access area is display-only here

The Explorer's **Access** area (membership and access-control administration) opens
and renders, but managing grants or policies through it has **no practical effect
in this harness**, because the cluster is not actually enforcing authorization:

- `Auth__DefaultEffect=Allow` makes the model **allow-by-default**, so a call is
  permitted whether or not any grant covers it. (A real estate runs `Deny`, where
  grants are load-bearing.)
- `StateApi__RequireAuthorization` / `Mcp__RequireAuthorization` are `false`, so the
  coarse transport gates enforce nothing (the silo's gRPC bindings skip their
  authorizer entirely) and never consult a policy.
- With Entra off there is **no real identity population** to administer - the only
  subject is the single synthetic bootstrap admin (`local-dev-admin`), which already
  holds a cluster-wide grant.

So the area is useful for **seeing the UI and the seeded admin grant**, not for
demonstrating enforcement. To exercise real authorization (deny-by-default, authored
grants that actually gate reads and writes, per-user visibility), run against a
deployed, Entra-enabled estate - see [`../hosts/README.md`](../hosts/README.md).

The single "secret", the per-cluster replication key, is a **dev placeholder**
with a baked-in default (`LATTICE_REPLICATION_SECRET`), overridable via `.env`.
No real secret is ever baked into an image, this compose file, or source. The
Azurite account key is the fixed, publicly-documented emulator credential - it
is not a secret and only ever addresses the local emulator.

## Notes

- The reference host images are **chiseled, shell-less, non-root** ASP.NET
  runtime images, so there is no in-container `curl`/`nc` for a Docker
  healthcheck on the .NET heads. The MCP and Explorer heads therefore start as
  soon as the silo container starts and connect lazily on first use (gRPC
  channels connect on demand), rather than being health-gated. Azurite (which
  has a shell) is health-gated so the silo waits for the Table endpoint.
- `InvariantGlobalization=true` (ICU dropped) is enabled on all three heads. The
  ordinal-only audit backing that decision is recorded in
  [`../hosts/README.md`](../hosts/README.md).
- Backups: this lone silo is the backup **primary** (`Backup__Primary=true`), so
  the scheduler is on. The default full/incremental intervals (24h / 60m) mean a
  scheduled backup will not fire during a short smoke run, but the Blob sink is
  fully wired against the Azurite blob endpoint.
