Skip to content

Service Mode

Service mode is what you run when more than one person uses Strata through a network. It's the right mode when:

  • Multiple users are sharing one Strata instance with their own identities (not just "everyone is logged in as me").
  • The server is reachable beyond a loopback interface.
  • Multi-tenancy matters, separate caches, separate QoS, separate metrics per tenant.
  • You want the platform to mediate writes - either routing them through server-side transforms, or letting authenticated clients publish directly to a shared store with service_writes_enabled (the shared research store).

For a single developer running on a laptop, use personal mode. Personal-mode-behind-a-proxy also covers small-team sharing (~5–20 trusted users) see Sharing personal mode with a small group.

Switching from the default

STRATA_DEPLOYMENT_MODE defaults to personal. To run in service mode you set the mode explicitly and fill in the matching auth / artifact configuration:

STRATA_DEPLOYMENT_MODE=service
STRATA_AUTH_MODE=trusted_proxy
STRATA_PROXY_TOKEN=<shared-secret>
STRATA_ARTIFACT_DIR=/path/to/persistent/dir   # or a blob backend

A startup coherence check fires clear ValueErrors on boot if anything's missing, a sloppy service-mode deploy refuses to start rather than silently exposing write endpoints.

The trusted-proxy contract

Strata does not authenticate users itself. It trusts an upstream proxy that:

  1. Terminates auth: JWT, OIDC, mTLS, Cloudflare Access, SAML, whatever. Strata doesn't care which.
  2. Injects identity headers on every request:
Header Value Required
X-Strata-Principal Stable user identifier (email, sub claim, etc.) Yes
X-Tenant-ID Tenant the user belongs to, when multi-tenant is on. Header name is configurable via tenant_header. When multi_tenant_enabled=true
X-Strata-Scopes Space-separated capability set (e.g. notebook:read notebook:write artifacts:write admin:cache) For scope-gated endpoints
X-Strata-Proxy-Token Shared secret matching STRATA_PROXY_TOKEN Yes, proves the request came from the proxy, not a direct connection
  1. Is the only path to Strata. Strata is on a private network / VPC / Kubernetes namespace; the proxy is the only ingress. Without that, anything that can reach Strata directly can forge the headers above and impersonate any user.

The proxy-token check is a backstop, not the security boundary: the network-level isolation is. If an attacker can reach Strata's IP directly, they can read the token from any leaked config and forge headers. Treat the token as defense-in-depth.

Running the demo stack

The repo ships a complete service-mode demo: Strata + a notebook executor sidecar + an nginx proxy that injects two pre-baked identities for testing.

docker compose -f docker-compose.service.yml up --build

The proxy exposes Strata on two ports, same server, different synthesized callers:

URL What nginx injects Use for
http://localhost:8865 Principal: demo-user, scopes: notebook:read notebook:write notebook:execute Normal-user view
http://localhost:8866 Principal: demo-admin, scopes: admin:* notebook:* Admin-only operations

Both URLs route to the same Strata instance. Tenant header (X-Tenant-ID: demo-team) is injected on both. Open either in a browser to use the notebook UI; hit /v1/... endpoints with curl to exercise the REST surface.

The configuration is in .docker/service-mode/:

  • pyproject.toml, Strata's service-mode config (mounted into the container as /home/strata/pyproject.toml). Includes the proxy token, multi-tenancy on, tenant header name, and a sample worker-catalog entry pointing at the executor sidecar.
  • nginx.conf, the two server {} blocks that inject the demo identities.

To experiment with new identities, edit nginx.conf and restart the proxy container (docker compose -f docker-compose.service.yml restart proxy).

Production reference architecture

┌────────────────┐     ┌─────────────────┐     ┌──────────────┐
│  Your auth     │     │                 │     │              │
│  provider      │◄────┤  Auth proxy     │     │  Strata      │
│  (OIDC/SAML/   │     │  (nginx,        │────►│  (service    │
│   Cloudflare   │     │   Envoy,        │     │   mode)      │
│   Access, …)   │     │   Cloud Run     │     │              │
│                │     │   ingress, …)   │     │              │
└────────────────┘     │                 │     └──────┬───────┘
                       │  Adds headers:  │            │
                       │  Principal      │            ▼
                       │  Tenant         │     ┌──────────────┐
                       │  Scopes         │     │ Notebook     │
                       │  Proxy-Token    │     │ executors    │
                       └─────────────────┘     │ (sidecars or │
                                               │  remote)     │
                                               └──────┬───────┘
                                               ┌──────────────┐
                                               │ Artifact     │
                                               │ store (S3 /  │
                                               │  GCS / Azure │
                                               │  / local)    │
                                               └──────────────┘

Strata sits on a private network, only the auth proxy can reach it. Artifacts persist to a blob backend (S3, GCS, Azure) rather than a local volume so it survives container churn and is shared across replicas. Notebook execution dispatches to executors; the demo stack runs one locally, production typically uses HTTP executors on dedicated nodes or remote backends like Modal / Fly Machines.

Minimum service-mode env vars

# Required
STRATA_DEPLOYMENT_MODE=service
STRATA_AUTH_MODE=trusted_proxy
STRATA_PROXY_TOKEN=<shared-secret-with-proxy>
STRATA_ARTIFACT_DIR=/path/to/persistent/dir  # or use STRATA_ARTIFACT_BLOB_BACKEND

# Multi-tenancy (optional but recommended for >1 team)
STRATA_MULTI_TENANT_ENABLED=true
STRATA_REQUIRE_TENANT_HEADER=true
STRATA_TENANT_HEADER=X-Tenant-ID  # match what your proxy injects

Run the server normally:

uv run python -m strata
# or
uv run strata-notebook

What service mode changes

Compared to personal mode:

  • No default artifact dir. You must set STRATA_ARTIFACT_DIR explicitly (or configure a blob backend via STRATA_ARTIFACT_BLOB_BACKEND=s3 etc.). Service mode refuses to start without a persistent target.
  • Reads work; direct writes are off by default. Clients can read results - scan/stream a table, fetch an artifact's data (GET /v1/artifacts/{id}/v/{n}/data), and resolve a dataset by name (GET /v1/names/{name}) - all tenant-scoped and ACL-gated. Direct write endpoints (put, set_name, …) are disabled at the surface by default; the platform decides what gets materialized via server-side transforms (transforms_config / [tool.strata.transforms]). To let authenticated clients publish directly - the shared-research-store pattern - opt in with service_writes_enabled (see below).
  • ACLs apply. acl_config deny / allow rules gate POST /v1/materialize, GET /v1/streams/{id}, and admin endpoints like POST /v1/cache/clear. Deny rules cannot be bypassed by allow rules, deny-first evaluation.
  • Per-tenant resources when multi-tenancy is on. Each tenant gets its own QoS limiter pool, its own metric labels, and its own cache keying, bulk queries from tenant A can't starve tenant B's dashboards.

Multi-tenancy

STRATA_MULTI_TENANT_ENABLED=true activates per-tenant isolation. With STRATA_REQUIRE_TENANT_HEADER=true, requests without a tenant header are rejected. The tenant ID is validated as 1–64 alphanumeric / _ / - characters and hashed into:

  • Cache keys: tenant A and tenant B can scan the same Iceberg table and never see each other's row-group cache entries.
  • Storage directories: per-tenant subdirs under the artifact store.
  • QoS limiters: interactive + bulk semaphores per tenant.
  • Metric labels: Prometheus output carries a tenant label so you can dashboard per-tenant usage.

A tenant registry tracks active tenants (LRU-bounded). Implementation details are in the source tree if you need to extend the tenant-scoping behavior.

ACLs

acl_config is a pyproject.toml block:

[tool.strata.acl_config]
# deny rules evaluate first; explicit denies cannot be bypassed.
[[tool.strata.acl_config.deny]]
principal = "guest@example.com"
resource = "tables/internal/*"

[[tool.strata.acl_config.allow]]
principal = "analyst@example.com"
resource = "tables/marketing/*"
scope = "notebook:write"

# default action when no rule matches (default: "deny")
default = "deny"

Evaluation: deny rules → allow rules → default. Wildcard, principal, and scope matching semantics are documented in source for anyone extending the ACL engine.

Scope-gated endpoints

A few operations require a specific scope under trusted-proxy auth (admin:* satisfies any of them):

Scope Gates
admin:cache POST /v1/cache/clear
admin:registry POST /v1/registry/pending/approve and .../reject - deciding protected-alias changes
artifacts:write Publishing in service mode (put / set_name / set_alias / tags) when service_writes_enabled=true. See below.

Registry approval additionally enforces separation of duty: the principal who requested a protected-alias move cannot approve it themselves unless they hold admin:*. The registry audit read (GET /v1/registry/audit) is tenant-scoped - a principal sees only its own tenant's history; admin:* sees the whole store.

Authenticated write-back: the shared research store

Preview

Authenticated write-back is a preview feature - it deliberately re-opens writes in service mode, which is security-sensitive. It's off by default, auth-required, scope-gated, tenant-scoped, and audited, but the surface is new and may change. Evaluate it before relying on it in production. The server logs a notice at startup when it's enabled.

By default service mode is read-only to clients, computation goes through server-side transforms. But a common deployment wants the inverse: a team of researchers, each driving their own notebook, who publish processed datasets to one central store so a dataset computed once is available to the whole team.

service_writes_enabled opts into that. It lets authenticated clients write directly - put, set_name, set_alias, tags - under a strict contract:

  • Opt-in and auth-required. Off by default; setting it requires auth_mode=trusted_proxy (enforced at startup), so every write is attributable.
  • Scope-gated. Publishing requires the artifacts:write scope in the proxy-issued token (admin:* also satisfies it). Members without it stay read-only.
  • Tenant-scoped (team = tenant). A write lands in the caller's tenant and can't target another, so teammates share a namespace and other teams are isolated. The publishing principal is recorded in the registry audit.
STRATA_DEPLOYMENT_MODE=service
STRATA_AUTH_MODE=trusted_proxy
STRATA_PROXY_TOKEN=<shared-secret-with-proxy>
STRATA_ARTIFACT_DIR=/path/to/persistent/dir   # or a blob backend
STRATA_MULTI_TENANT_ENABLED=true              # team = tenant
STRATA_SERVICE_WRITES_ENABLED=true            # the opt-in

The proxy injects X-Strata-Scopes: artifacts:write for principals allowed to publish. The publish → consume loop then looks like:

# Researcher A (team-a, artifacts:write) publishes a processed dataset:
strata.put(inputs=[], transform={"ref": "clean@v1"}, data=cleaned,
           name="team/cleaned-events")

# Any teammate (team-a) resolves the name to its current artifact and reads it:
info = strata.resolve_name("team/cleaned-events")   # {artifact_uri, version, …}

# Other-team principals (team-b) cannot resolve team-a's name - tenant isolation.

Connecting a notebook to the shared store

Each researcher runs their own notebook, which computes locally but points its ambient strata client at the central store via STRATA_NOTEBOOK_REMOTE_STORE_URL. The notebook's own cell outputs and provenance stay local; only what a cell explicitly publishes (strata.put(name=…)) goes to the shared store.

# On each researcher's notebook server:
STRATA_NOTEBOOK_REMOTE_STORE_URL=https://store.team.example
# Auth the remote store needs (set via env, not committed config):
STRATA_NOTEBOOK_REMOTE_STORE_HEADERS='{"X-Strata-Proxy-Token":"…","X-Strata-Principal":"alice@team","X-Tenant-ID":"team-a","X-Strata-Scopes":"artifacts:write"}'

In a fully proxy-fronted setup the notebook's requests instead flow through the same auth proxy, which injects identity, and notebook_remote_store_headers can be omitted.

The team cache: sharing results nobody named

Everything above is explicit publish - a researcher decides a dataset is worth sharing and names it. That leaves out the expensive middle of a pipeline, because nobody names their intermediate results, and that is exactly where the recomputation is.

STRATA_NOTEBOOK_TEAM_CACHE_ENABLED=true closes that gap. On a local cache miss the notebook asks the shared store whether anyone has already run this exact computation, and on a hit it fetches the result instead of running the cell. After a cell does run, its outputs are offered back to the store so the next teammate hits.

# On each researcher's notebook server, alongside the two settings above:
STRATA_NOTEBOOK_TEAM_CACHE_ENABLED=true

It works because a cell's provenance key - sha256(sorted_input_hashes + source_hash + env_hash) - contains no notebook id and no cell id. Two people running the same source over the same inputs in the same environment already arrive at the same hash; the store just had no way to be asked.

Lineage answers the follow-up. Opening a result's lineage (from the cell's artifact strip, or the registry panel) shows each step with who computed it, on what, and what it cost - alice@lab · cpython-3.14-linux-x86_64 · 38s. Each step also carries its environment identity - env:3d731494 (which package set) alongside the platform (on what). Those two together are what answers the question a shared cache generates on day one: "you got a hit and I didn't - why?" The answer is almost always that the identities differ, and until they were readable there was no way to see it.

The author column fills in only for steps that came from the store - a cell you ran yourself has no authenticated identity to attribute, so on a solo notebook it stays blank throughout. The platform and environment identity are recorded on every run, so what a shared store changes is not that they appear but that they stop being the same value on every row. A graph where one step ran on someone else's machine, in a different environment, is the case these columns exist to make visible.

All of it is best-effort and recent: artifacts produced before these fields existed show blanks, and re-running the cell is what fills them in.

The notebook's profiling panel splits the savings once a team hit happens: "Cache savings ~12m" alongside "From your team ~8m (4 hits, alice, bob)". The team figure is priced by what the publisher's run actually cost, carried on the artifact - whoever gets the hit never ran the cell, so their own history holds no comparable number and a locally-derived estimate would credit zero for exactly the case the shared store exists to create.

What to know before switching it on:

  • It is a separate opt-in from the URL, deliberately. Wanting a shared store to publish to is not the same as wanting one to silently source results from. Enabling it without STRATA_NOTEBOOK_REMOTE_STORE_URL is rejected at startup rather than left silently inert.
  • Publishing needs artifacts:write. Members without the scope still get team cache hits; they just do not contribute. That is a supported configuration, and the notebook logs a warning when a publish is refused so it does not look like the cache is simply empty.
  • It never fails a cell. A store that is unreachable, refusing, or missing one of a cell's outputs ends in "run it locally".
  • A notebook whose environment does not match its lockfile will not publish. A failed uv sync keeps the previous venv and stays usable on purpose, so a transient network failure does not lock you out of your own notebook - but provenance is computed from uv.lock, which has moved on, so anything produced afterwards would be stamped with an environment it was not built in. Locally that is your own problem; published it is permanent and everyone's, because the store keeps the first writer's result. The cell still runs and its result is still cached locally; only the publish is refused, with a warning naming the cell. Re-run the environment sync to resume publishing.
  • Point it at an authenticated store. Without auth there is no tenant, so every team shares one flat namespace, and no principal, so every result arrives authored by nobody. The server warns at startup if the team cache is on with no notebook_remote_store_headers.
  • The environment key does not include the platform, but the artifact records it. uv.lock resolves to different wheels on macOS-arm64 and Linux-x86_64, and the env hash covers the lockfile only. That is deliberate, since hashing the platform would drop cross-machine hit rate to roughly zero and delete the feature in order to protect it. So a hit can legitimately cross machines, and every artifact carries the interpreter and hardware that produced it (cpython-3.14-linux-x86_64), reported by the process that actually ran the cell: the notebook venv locally, or the worker's interpreter for a remote cell. A team hit says which platform it came from rather than crossing one silently. In practice a shared cache handing the whole team one number is the more reproducible outcome, but if your work depends on hardware-identical results, leave the team cache off for now.

Migrating from personal mode

If you've been running personal mode and want to grow into service:

  1. Decide on the auth boundary. Anything from "Cloudflare Access in front of a Fly app" to "OIDC behind an enterprise ingress": the only requirement is that the proxy can inject the four headers above and that Strata is otherwise unreachable.

  2. Pick an artifact backend. Local-disk artifacts don't survive container restarts cleanly in multi-replica setups. Configure one of STRATA_ARTIFACT_BLOB_BACKEND=s3|gcs|azure and the matching credentials. See Artifact Storage.

  3. Flip the mode in env or pyproject.toml:

    STRATA_DEPLOYMENT_MODE=service
    STRATA_AUTH_MODE=trusted_proxy
    STRATA_PROXY_TOKEN=<your-shared-secret>
    
    Boot will fail with a clear error if anything's missing, that's the fail-closed property at work.

  4. (Optional) Add multi-tenancy. Once multiple teams are using the same instance and you want isolation, flip STRATA_MULTI_TENANT_ENABLED=true and start injecting X-Tenant-ID from the proxy.

  5. (Optional) Add server-side transforms. Configure the transforms_config block in pyproject.toml to expose the computations you want the platform to run on the client's behalf. The notebook executor in the demo stack is one example.

The demo compose stack is a working starting point you can fork: swap nginx.conf for your real auth proxy config, point the artifact dir at S3, and you have most of what production needs.