Skip to content

Run a Cratis Stage sandbox with Docker

Cratis Stage runs a Cratis Screenplay .play model as an application sandbox. Write the model, start a session with one command, and use the HTTP commands and queries it describes before implementing the application. You can use authored or default Scene screens, inspect read state, and follow recorded events in Cratis Chronicle. The cratis/stage container starts Cratis Arc and an in-memory Chronicle kernel beside the application, so you do not need a separate database or Chronicle server. Replacing the container loses session state. This executes the model; it does not render code or test a generated application.

You need Docker and a complete .play application, either one file or a folder of files. If you do not have a model, create a Screenplay model first. Include its referenced concepts and policies, commands with event productions, and read models with projections and queries. A read model is a view built from events; a projection describes how each event updates that view. A getting-started excerpt alone may lack the declarations needed to run. Screens are optional: Stage can compose default screens from admitted commands and read models. An empty folder exits before either process starts.

For a first session, save this standalone example as screenplays/application.play. It keeps the Home feature and its CustomerId concept from the CLI’s shell model: one command produces a fact, a projection builds a row, and a screen reads the query. After sending GreetCustomer, you will look for its text in AllWelcomes. The example declares no authentication or custom UI packages.

screenplays/application.play
concept CustomerId : Uuid
module Home
feature Welcome
slice StateView WelcomeList
readmodel WelcomeRow
welcomeId CustomerId
text String
query AllWelcomes => observable WelcomeRow[]
projection WelcomeList => WelcomeRow
from CustomerGreeted
welcomeId = $eventSourceId
text = text
screen Welcome
title "Welcome"
data WelcomeRow[] via query AllWelcomes
table WelcomeRow
column text label "Text"
slice StateChange GreetCustomer
command GreetCustomer
id CustomerId identifier
text String
produces CustomerGreeted
for id
text = text
event CustomerGreeted
text String

id selects the command’s event source. The projection takes that identity from $eventSourceId, so the row’s welcomeId connects the command to the read result; text comes from CustomerGreeted. The authored Welcome screen uses AllWelcomes. Once the session starts, send GreetCustomer through the interactive API reference, Scalar. This model has an authored screen, so Stage does not compose default command screens; use Scalar for the command.

With the Cratis CLI installed and your complete model in ./screenplays, run:

Terminal window
cratis run ./screenplays

The CLI mounts only the selected folder, read-only, chooses its compatible Stage image, and reports readiness or model refusals. First use may pull the image. For a model without declared authentication, it publishes the application on loopback port 9090 and Workbench on loopback port 35000. Keep these defaults: --allow-remote exposes the unauthenticated standalone sandbox without adding authentication. If your model declares authentication, follow the authenticated CLI flow instead; its entry address and configuration differ.

When the CLI reports readiness, continue with Use the application to submit GreetCustomer and find the resulting row. If you prefer to manage the container yourself, use the Docker command below instead of cratis run.

  1. Open a terminal in the folder containing your model. The mount below is read-only and searched recursively.

  2. Start the container:

    Terminal — start a model session
    docker run --rm -p 127.0.0.1:9090:9090 -p 127.0.0.1:35000:35000 -v "$PWD":/eventmodel:ro cratis/stage:latest
  3. After startup, open Scalar and check the model’s mapped commands and queries. The OpenAPI document lists the session’s model API. Check the session status for admission issues before treating a responding HTTP endpoint as a usable session. These checks show what the host exposes, not whether every modeled behavior is implemented.

The direct Docker command uses the moving latest tag. The CLI selects the Stage version it renders with; do not assume a published image has every capability described by current source.

  1. Open session status and semantic admission. Resolve refusals before treating the session as ready. A reachable host alone does not establish that your model can execute.
  2. Open the application UI. Stage serves authored screens or default per-slice Scene screens derived from admitted commands and read models. Use a supported command form, or open Scalar and submit the command there using its model-specific schema. Do not guess endpoint routes or payloads: the OpenAPI document describes the mapped operations. HTTP 501 or an unsupported result means that behavior did not execute. For the example above, use Scalar to select GreetCustomer and send id as 11111111-1111-4111-8111-111111111111 and text as Hello from Stage.
  3. Run the corresponding query in Scalar, or open its screen, to inspect the changed read state. For the example, call AllWelcomes or open Welcome: after the accepted command, look for a row with that welcomeId and text. For an admitted command with event production, Stage evaluates the decision, records its accepted facts through Chronicle, and makes supported projected state available to queries. The semantic read-model browser inspects the in-process views.
  4. Open Workbench through Stage to inspect the session’s event history. For the example, look for CustomerGreeted on the event source you supplied. Workbench shows Chronicle events; use the semantic browser for Stage’s in-process read models. Those are not Chronicle projection views. Storage is disposable, so this exploration does not establish persistence for a deployed application.
  5. Check declared expectations separately with the semantic specification runner. Read every reported outcome: failed and unsupported specifications are not passes. The small example above declares no specifications; add modeled expectations before expecting behavioral coverage. A ready host does not mean specifications have run, and model results do not establish correctness of a rendered application.

You have now exercised the model’s supported command-to-state path without generating and building an application. To develop source you own, continue with Plan artifacts from Screenplay sources.

If startup refuses the model, read the diagnostics and compare the construct with the supported semantic subset. Missing references and compile diagnostics need model changes; runtime refusals identify behavior the selected engine cannot perform. Do not switch to the deprecated eventmodel engine just to hide semantic refusals.

Published applications. The sandbox above is for trying out a model. To run an application for real users, export a versioned package and start the image in published mode: persistent storage, sign-in by a trusted proxy, and none of the tooling surfaces. See Published applications and export.

The image is built on top of the Chronicle kernel image (cratis/chronicle), so the event store and the engine performing the model live side by side and talk over localhost:

cratis/stage container

gRPC · localhost:35000

▶️ Stage host
/stage · port 9090
Arc commands & queries · OpenAPI

📄 /eventmodel
*.play

📖 Chronicle kernel
/app · port 35000
event store · Workbench · API

Three things about that base image are worth knowing:

  • The kernel is pinned to the version the host’s Chronicle client is built against. The Dockerfile names an exact cratis/chronicle:<version>-development tag — the same version Directory.Packages.props pins for the Cratis.Chronicle client — rather than latest. The two talk gRPC over a contract that changes between Chronicle versions, so letting either float is how a client and a kernel end up disagreeing about a message shape and the host fails at boot.
  • Storage is fully in-memory. The MongoDB install the Chronicle image ships with is purged from the layer and the kernel is started with Cratis__Chronicle__Storage__Type=InMemory. The image carries no database, and every session starts empty.
  • The Stage host is a framework-dependent publish copied in, not compiled in the Docker build. The dotnet publish output is produced on the host (by dockerize.sh or the publish workflow) and only copied into the image, because portable IL does not need to be recompiled per architecture.

entrypoint-stage.sh runs both processes in order:

  1. Check the selected input, defaulting to /eventmodel. A missing path, a file without a .play extension, or a folder without .play files fails before the Chronicle kernel starts. File extensions are case-insensitive. This checks the input path, not the validity or executable capabilities of the model.
  2. Start the Chronicle kernel from /app with in-memory storage, and with its API and Workbench features turned on explicitly.
  3. Wait for the kernel — it polls port 35000 until the gRPC endpoint accepts connections, so the Stage never races ahead of the event store it needs.
  4. Start the Stage host with the selected path. It compiles just that file, or compiles every .play file beneath the folder as one application using Screenplay’s folder compiler. The host materializes the runtime constructs it currently supports. After the host starts, the default semantic path registers event contracts with Chronicle, seeds event sources that have no events yet, and reconstructs its in-process world from those facts. Its read models are held in Stage’s process, not registered as Chronicle projections.

Each session gets a generated, Docker-style event store name (brave-mendel, nifty-turing, …), which is what you will see in the log line the host prints and in the Workbench.

PortProtocolWhat it serves
9090HTTPThe model’s API — commands, queries, OpenAPI and the Scalar reference. See URLs of a running Stage.
35000HTTPSThe Chronicle kernel: gRPC (HTTP/2) for the Stage’s own client, plus the Chronicle Workbench, its API and OAuth (HTTP/1.1) on the same port.

Those two are all a caller needs. The remaining ports are internal to the container and can be ignored: the kernel’s Orleans clustering on 11111 and 30000, and the Stage’s own Orleans silo on 21111 and 31000.

The Stage hosts its own Orleans silo through Cratis.Choreography, next to the Chronicle kernel, so durable state machines and workflows have grains to run on. It uses the local configuration: localhost clustering and in-memory grain storage, so it starts without MongoDB and nothing it holds survives the container. Jobs, Chronicle-backed event-sourced state machines and the workflow engine are registered.

The kernel is itself an Orleans silo in the same container and binds the Orleans default ports, so the Stage silo listens on its own (21111 silo, 31000 gateway) and joins a distinct cluster (cratis-stage). Override the ports with Stage:Orleans:SiloPort and Stage:Orleans:GatewayPort in cratis-stage.json if something else in your container needs them.

The model is read from /eventmodel inside the container:

Terminal window
docker run --rm -p 127.0.0.1:9090:9090 -p 127.0.0.1:35000:35000 \
-v /path/to/screenplays:/eventmodel:ro \
cratis/stage:latest

The folder is searched recursively, so a model split across many .play files in nested folders works as-is. /eventmodel is a plain directory rather than a declared volume, which means a model can also be pushed in through the container archive API instead of a bind mount — that is how tooling that has no host folder to share (Studio, for one) supplies a model.

To load a single file, mount only that file read-only and pass its container path after the image name:

Terminal window
docker run --rm -p 127.0.0.1:9090:9090 -p 127.0.0.1:35000:35000 \
-v /path/to/invoicing.play:/eventmodel/input.play:ro \
cratis/stage:latest /eventmodel/input.play

The entrypoint forwards absolute paths unchanged and resolves relative paths against its initial working directory before starting the host. A single-file input does not discover sibling .play files. Implementation file references remain symbolic: this loader does not open or execute them, and no mount of their parent folder is needed. Folder compilation resolves declarations across the discovered files and reports compilation errors with relative source paths. Compilation alone does not establish that Stage can execute every modeled construct.

The host separates hosting defaults from deployment configuration:

SourceCarries
appsettings.json / appsettings.Docker.jsonHosting defaults only — the Kestrel endpoint (http://0.0.0.0:9090) and the correlation-id (X-Correlation-Id) and tenant (X-Tenant-Id) HTTP headers.
cratis-stage.jsonDeployment configuration, loaded from the content root if present. Optional, and reloaded on change.
STAGE_CONFIGOverrides the path cratis-stage.json is read from — point it at a mounted file to configure a session without rebuilding the image.

Anything Chronicle-related can also be set with the kernel’s own environment variables (Cratis__Chronicle__…), which is how the entrypoint pins in-memory storage and the Workbench.

With Stage__Runtime__Engine=eventmodel, the legacy EventModel engine enforces this subset of command-level declarative validate rules:

RuleLegacy-engine behavior
not emptyRejects missing or null values, blank text and empty collections. Nested object paths such as customer.address.name are resolved through the payload.
min / max with a numeric literalInclusive value bounds on numbers (including decimals); inclusive UTF-16 length bounds on text.
> / >= / < / <= with a numeric literalCompares numeric values or text’s UTF-16 length against the threshold.
length with a non-negative integer literal up to 2147483647Requires that exact text length.
matches with a quoted literalApplies the literal as a .NET regular expression.

Except for not empty, these rules allow absent or null values. Authored messages are retained. Rules on paths that cross a collection are skipped, including not empty on lines.description; scalar rules cannot express element validation. Use lines not empty to require a nonempty collection. Numeric bounds and comparisons skip values that are neither numbers nor text (booleans, objects and collections).

The legacy engine drops, rather than partially enforces, all > and all >=, equality and inequality (== / !=), checksums, ordinal text rules (contains, not contains, starts with, ends with), named rules, inline code validation, and rules with non-literal operands (including property references, today and named match patterns such as email). It does not turn a collection quantifier into a scalar comparison. Dropped rules do not reject a command; compilation alone does not mean the legacy engine enforces all modeled validation. Use semantic execution for its admitted rule forms and inspect its admission report for unsupported constructs. An unknown rule kind refuses the model.

The host uses the semantic engine by default. To keep the legacy EventModel behavior, set Stage__Runtime__Engine=eventmodel or pass --engine=eventmodel to the Host executable. The EventModel engine is deprecated: it logs one warning at startup and will be removed in Stage v6. The SpecRunner retains structural as its default; this Host default does not change its report format. The legacy engine refuses named event sources, streams and event-source routes with STAGE-ESM-030, before registering anything with Chronicle. The host stays in refused mode: /stage/status reports state: "unsupported" and /api/** returns HTTP 501. The semantic sandbox reports public events and public-event projections (STAGE-ESM-032), foreign events and source events captures (STAGE-ESM-033), directed Translate slices and captures (STAGE-ESM-024) as renderedAround entries on /stage/semantic/admission, with a startup warning for each artifact. They do not block unrelated local commands or queries. Public events are published to the outbox: a local public event is registered as public and appended to the application’s own outbox, never to its event log, including supported outbound event projections. Another application receives it through its inbox when the two run against the same Chronicle kernel. Foreign events are registered with the store they come from, and the application’s store is subscribed to that store’s outbox: what arrives in the inbox joins the world, sets off its captures and reactions, and what they append reaches the event log. The origin store need not be running first: Chronicle creates it when the foreign event is registered and starts delivering once the application that owns it publishes, so nothing arrives from an origin until that application runs against the same kernel. Run the applications of a project together, pinning each one’s event store name (STAGE_EVENT_STORE) to the name its consumers write after from. Foreign events also still arrive through seeds, and captures do not poll external sources. A restart does not replay the inbox into the world, so start a fresh session after restarting a running host. Unsupported public-projection shapes still refuse the affected command without appending facts. Because the event log holds no publication, a restart over a log is rebuilt without the former World rebuild refused stop for local publications. The legacy engine continues to refuse these constructs. Both engines refuse reducer observer filters (STAGE-ESM-035). The legacy engine also refuses reaction execution identity (STAGE-ESM-034) and reaction observer filters (STAGE-ESM-035). Semantic rendering supports reaction filters; neither that support nor the renderer’s narrowed 034 refusal for claim-reading or opaque gates changes these legacy-engine refusals. The semantic engine runs per-production route overrides; it does not admit an override on an unkeyed stream under a keyed command route, or a literal override stream id of Default, yet; both fail with STAGE-ESM-036 until their generated routing is proven equivalent. The legacy engine refuses the stream syntax with STAGE-ESM-030. The semantic engine runs event-triggered reactions, including where, runs as, from, productions, invocations and cascades. It settles them with the initiating command and appends all facts in one tail-guarded batch. A rejected, denied or unsupported settlement refuses the whole command and appends nothing, retaining the previously committed world. With reaction batches atomic, each trigger occurrence’s direct productions and each selected refusal branch are atomic units during evaluation; invoked commands remain separate units. The live Host still rolls back the whole initiating command and its cascade if any unit fails, rather than keeping earlier successful units as the reference evaluator does. Models without the declaration retain legacy per-fact reaction evaluation and the same Host rollback boundary. This differs from rendered Chronicle reactors, which run eventually and retry failed partitions. Clock, application, startup and shutdown triggers and opaque reaction bodies still fail STAGE-ESM-024 naming the trigger kind; unsupported context mappings such as $context.causedBy.* fail STAGE-ESM-013. The independent specification executor also settles event reactions, including identity, filters and mixed effects, but runs only the slice that declares each specification: other slices’ reactions and publications do not run, and an invoked command another slice owns is decided without keeping its events. See The spec runner for this and its scenario limits. The semantic engine checks each language/semantic version pair against Stage’s declared ESM support. Audited pairs and newer pairs within its declared range proceed to member and capability checks; a version alone does not establish support for every construct. Pairs outside that policy and unknown ESM members refuse the model with STAGE-ESM-016 before registration or execution. Check /stage/semantic/admission for the loaded model’s capability refusals. A v22 model that uses reaction batches atomic executes atomic production units while retaining the Host’s whole-command rollback boundary. At v28 the semantic engine runs Uuid == and != in command require conditions and produces when guards, including through concepts and protected read paths such as require Grant.memberId == requestedBy. Uuids compare ordinally in their canonical lowercase hyphenated spelling, and the Host rejects a non-canonical Uuid input (uppercase, braced or unhyphenated) before any requirement runs. The renderer renders property, literal and protected read-path comparisons in require as Guid comparisons; a read path in produces when keeps STAGE-ESM-044. A rendered application binds Uuid input through Arc, which also accepts uppercase or braced spellings. The legacy engines refuse every Uuid comparison with STAGE-ESM-049. At v27 the semantic engine runs read models with several key parts: instances that differ in one part stay apart, parts projection keys create and remove only the instance whose parts all match, list queries filter on any part, and a single-instance query by parts takes one parameter per part, validated against its concept like a single key. Such a read model has no conventional Get<ReadModel>ById query, and the read-model browser shows each key as an object keyed by part name. The renderer emits such a read model with a synthetic Id key record holding the parts and a UsingCompositeKey projection, and refuses shapes it cannot express (reducers and flat projections, joins, children, every, optional, date-time, decimal (Chronicle#5427) or non-primitive parts, incomplete parts keys) with STAGE-ESM-048. It refuses a single-instance query by parts with STAGE-ESM-047 until Chronicle can look up an instance by a composite key (Chronicle#4265); the legacy engine refuses the read model with STAGE-ESM-048. At v26 the semantic engine executes Date/DateTime ordering and instant equality in command requirements, conditional productions and validation rules, including concept-backed properties. Participating DateTime inputs must carry an explicit offset or Z; validation rejects missing offsets after authorization and before other input validation, including reaction invocations. Date compares calendar days; DateTime compares UTC ticks. DateTime event and read-model assertions instead compare ordinally after both sides use Screenplay’s round-trip canonical text: zero offsets become UTC Z, while non-zero offsets remain distinct. Specification expectations remain ordinal. Opaque validate csharp is an explicit ordering difference: the specification executor refuses it at run admission, and the Host refuses it before authorization and input validation. The reference checks authorization, then offsets, then opaque validation, so an offset-less DateTime combined with opaque validation returns Validation in the reference but Unsupported in either Stage path. Unsupported never counts as a pass. The renderer renders these comparisons (see Stage rendering); the legacy engines refuse them with STAGE-ESM-046. The semantic engine executes command reads protected: after authorization and property/concept rules, each required view must exist at its declared key. Absence rejects validation with Current '<key>' does not exist. (using the view’s name), before requirements or generation. All requirements then run, including read-backed conditions; produced-event mappings can read the stored view values. /validate also reports required absence. Protected commands with non-Error requirement severities remain refused with STAGE-ESM-045 until Screenplay defines their severity semantics. This includes reaction-invoked commands: model admission and the runtime refuse before evaluating reads or requirements or appending facts. The Host decides against the session world, reads outside facts through the current log tail, then commits against that extended world. Only a later fact matching a protected read’s key and event contracts conflicts; the command returns a concurrency violation without faulting the session. Unrelated facts remain in the commit-time world, where constraints and projections run. Outside facts do not trigger reaction settlement again. The complete root and reaction batch is written atomically against the durable pre-batch tail, including a guarded empty write for a successful command with no facts. If that guard definitively rejects the write, the Host can retry the same decision, with its generated values unchanged, only if settlement invoked no commands. An invoked command prevents automatic retry; the operation returns a concurrency violation. Repeated tail movement also returns a conflict after eight attempts. An unknown write outcome is never retried or reported as success: the session faults until it can be reconstructed. Unreadable or unsupported outside history also faults the session. Legacy custom appenders without fact-reading or guarded-write capabilities retain their conservative safety checks. The Cratis renderer renders protected reads as Arc read protection (see Protected reads rendering), refusing only the shapes it cannot preserve with STAGE-ESM-044. The Contracts EventModelLoader admits the preamble and records it as EventModel.ProtectedCommandReads; the legacy syntax renderer, the eventmodel Host engine, the structural runner and StageCommandHandlerProvider refuse such a model with the same code. Without the preamble, command reads still fail binding with PLAY0271 and the repair diagnostic PLAY0786. ESM v11 preserves retained exact Double-mode numbers in command decisions, appended payloads and read-model state, including ±9007199254740991 and 0.10000000000000002. Source integers still use the Double safe range; numbers exact and arbitrary-precision arithmetic are not admitted. ESM v12 preserves explicit enum member numbers when rebuilding stored integer values; decisions still compare member names. ESM v13 admits concept compliance without changing command decisions or world reconstruction. ESM v14 equality query filters execute through the reference evaluator in the Host, including optional omission and required-filter rejection. The independent specification executor and Host specification admission support equality-filtered assertions over projected and seeded read models. Supplied scalar primitive, concept and enum filters combine with by using AND; omitted optional filters do not narrow. Collection expectations compare rows by read model and key, not order. Unrepresentable projection shapes still receive typed refusals. The executor supports complete scalar per-key seeds, replacing projected Given history for that key before folding subsequent facts; see Spec runner. The semantic Stage Host encrypts personal-data and secret concept values at rest through Chronicle, like rendered applications, and releases them on read. Personal-data erasure uses Chronicle’s key deletion keyed by the event source; explicit subject lineage remains Stage #28. Existing Play stores retain earlier plaintext events. The semantic Host and renderer admit text and non-text secret concepts (Int, Decimal, Bool, Date, DateTime, and Uuid), preserving their primitive types. pii secret uses PII only. The renderer refuses compliance on identity uses or enums with STAGE-ESM-040; see Concept compliance rendering. The semantic engine executes command routes, registers named sources and streams with Chronicle on every connect, and checks stored routes during world reconstruction. Routed specification runs are still refused with STAGE-ESM-030, as are routes in the structural SpecRunner and EventModel engine. The default engine uses the Screenplay executable semantic model (ESM) for command decisions and keyed snapshot queries. Stage__Runtime__Engine=semantic selects it explicitly:

Terminal window
docker run --rm -p 127.0.0.1:9090:9090 -p 127.0.0.1:35000:35000 \
-e Stage__Runtime__Engine=semantic \
-v /path/to/screenplays:/eventmodel:ro \
cratis/stage:latest

The semantic engine evaluates validation, requirements, authorization, conditional production and constraints before accepting a command. Accepted facts are appended to Chronicle as one batch; a failed append does not commit the in-process read-model state. GET /stage/semantic/admission lists the model’s admitted and unsupported artifacts, state machines and workflows included; GET /stage/lifecycles reports where state machines are and what workflows wait for (see State machines and workflows). GET /stage/status includes engine: "semantic". If Screenplay cannot compile an executable plan, the host returns state: "unsupported" with issues; /api/** responds with HTTP 501 instead of performing a partial model. Only Error-severity compiler diagnostics become blocking Plan issues. Information and Warning diagnostics are returned separately in the optional notes array on GET /stage/status, for both successful and refused loads. Each note preserves its code, message, root-relative source location and severity; these notes do not prevent execution. An exit-42 warm handoff restarts only Stage, retaining the Chronicle kernel and its in-memory event log. Other host exits stop the container; replacing the container loses that log. An external or persistent Chronicle store can retain history across container replacement, but that storage path is not verified by the sandbox. With a nonempty log, Stage reads the complete event history, checks its continuity, event schemas, origins and modeled constraints, then reconstructs the in-process world using Screenplay semantics. It refuses incomplete or inconsistent history rather than starting with partial state. /stage/status reports loading during rebuild; /api/** is unavailable then.

Commands retain their slice-qualified /api/… route and legacy alias. The semantic engine also exposes one query by its modeled name. Compatibility Get…ById and All… queries are available only for read models with at least one keyed query and no authorization on any keyed query targeting that read model. Otherwise, the compatibility routes are not mapped, so callers cannot bypass a modeled query policy. Unknown or revised events, schema mismatches and unverified historical constraint claims cause Unsupported(World) with a specific issue rather than a partial world. A logged event contract with a DateTime or DecimalNumber property also refuses because Chronicle storage does not preserve these values exactly. Null optional event values, non-text destination IDs, changed causedBy.name for the same subject, and duplicate tags can also fail the exact-history check. Origin verification compares modeled tags and mapped $context values; it does not independently verify literal mappings, production conditions, or that a destination matches a mapped event property. Do not treat a successful rebuild as proof that another client could not append a plausible event. After rebuild, an external append detected before a command faults the runtime instead of committing against stale state. The semantic engine does not register Chronicle projection mirrors: its read models are available through Stage’s in-process queries, not as projection views in the Chronicle Workbench. For admitted, unrouted models, the legacy EventModel engine continues to register its own Chronicle projections. Commands that require allocation of a new event-source identity return Unsupported(IdentityAllocation); explicitly name a produces … for destination to make them executable. Unsupported event revisions or duplicate event names put the running host in refused mode (state: "unsupported" and HTTP 501 for /api/**). When scene loading succeeded, /stage/scene, /stage/routes and /stage/locales remain available even in refused mode. The semantic engine runs modeled specifications through the shared executor on request, with fresh isolated state rather than the live world or Chronicle log (see below). Scene synthesis still uses the legacy presentation converter, but that converter is never registered as the semantic execution model. A screen can therefore omit a semantic command even though its API route exists. The in-process world holds the session’s fact history without a size cap; use this engine for short-lived play sessions, not durable workloads.

A denied command responds with HTTP 403. Failed validation and constraints respond with HTTP 400; a constraint result includes its name in reasonDetail. An unsupported command responds with HTTP 501 and Stage-Unsupported-Capability and Stage-Unsupported-Artifact headers. /validate evaluates the same model without appending facts. Semantic queries require a modeled snapshot lookup; the compatibility All/ById queries read the in-process world, not Chronicle projections. If an append’s outcome cannot be established, Stage faults the session and returns Unsupported(World) for subsequent commands and queries; /stage/status then reports unsupported. Restart rebuilds from the Chronicle log only when its history passes the same strict admission checks.

The Docker sandbox enables Stage:Sandbox:Identity so a browser can exercise role-gated models without a login screen. The setting is off when absent outside Docker. It supplies a server-selected persona for /api/** requests and the page identity at /.cratis/me. /stage/** endpoints and the Chronicle Workbench do not receive that persona. This changes only the Stage host, never rendered applications or their authentication.

The sandbox runs with ASPNETCORE_ENVIRONMENT=Docker, not Development. Arc’s discovery catalogs require authentication and answer 401 to anonymous sandbox requests by design. The persona is not applied to these endpoints, and Play does not use them.

Stage selects the default persona once at startup:

  1. Use the persona named by a Cratis.Blueprint.Default.userMenu component’s literal role property.
  2. Otherwise, use the persona whose policies satisfy the most module-level authorize requirements, in declaration order when tied.
  3. Otherwise, use the first declared persona. With no personas, leave the caller anonymous.

The same model selects the same persona and claims across fresh or warm sessions. The persona receives roles and literal claims synthesized from its declared policies, not every role in the model. Stage skips policies it cannot synthesize from roles and claims, including anonymous requirements or conflicts with previously accepted policies, and logs a warning. The audit subject is persona:<name>; the display name comes from the user menu’s literal name property, or the persona name. Startup logs identify the persona and its roles. A persona lacking a required role still receives 403 for that operation.

Arc’s forwarded-header trust remains off. In sandbox mode, Stage removes every incoming header whose name starts with x-ms-client-principal (any letter case) from every request, before authentication, static files and routing run. It also replaces any incoming principal with the server-selected persona, or an anonymous principal when the model has no personas. Sending headers or an existing authenticated identity cannot select another persona or add roles. To use another persona, change the modeled user menu’s role and restart the session.

To disable the persona middleware, set Stage__Sandbox__Identity=false in the host environment (for Docker, add -e Stage__Sandbox__Identity=false to your container command). You can also override the setting in the Stage configuration file:

{
"Stage": {
"Sandbox": {
"Identity": false
}
}
}

With the setting disabled, Stage neither injects a persona nor strips headers or replaces an existing principal. Arc’s default forwarded-header trust is still off. An anonymous semantic caller has empty audit identity fields (subject, name, userName); the legacy EventModel path omits those values entirely.

The semantic engine keeps read models in its live in-process world, not in Chronicle projections. To see what your commands produced, open http://localhost:9090/read-models (or <session-base>/read-models behind a proxy, for example /api/play/<session>/read-models in Studio). The browser lists every read model, including ones without queries or instances. Select a model to inspect its keys and property values; arrays and composite values appear as compact JSON. Refresh reads the current world again, including the selected model. The Chronicle Workbench still shows the event log, but does not show these semantic read models.

You can also read the same state over HTTP:

  • GET /stage/read-models returns the models in declaration order, their module/feature/slice location, property names and types, and current instance counts.
  • GET /stage/read-models/{id} returns one model’s property contract and current instances. Use the id from the list; an unknown or malformed identity returns HTTP 404.

For example, a list entry has this shape (the semantic identity is abbreviated for illustration):

{
"readModels": [{
"id": "sm:…",
"name": "ProjectSummary",
"module": "Projects",
"feature": "Registration",
"slice": "ProjectLookup",
"properties": [{ "name": "name", "type": "ProjectName" }],
"instances": 1
}]
}

Inspecting that model returns named JSON values, not semantic property identities:

{
"id": "sm:…",
"name": "ProjectSummary",
"properties": [{ "name": "name", "type": "ProjectName" }],
"instances": [{ "key": "project-one", "values": { "name": "Screenplay" } }]
}

Both endpoints return HTTP 503 with state: "loading" and Retry-After: 1 until the world has been rebuilt. A faulted session returns HTTP 503 with { "state": "faulted", "reason": "…" } and no rows, rather than presenting indeterminate state as current. Restart the session before inspecting it again. A refused model returns HTTP 501 with the same refusal headers and exceptionMessages as the specification endpoint. These diagnostic endpoints expose the whole session, independently of modeled query authorization; keep the session private behind the trusted proxy described above.

Under the eventmodel engine, both endpoints return HTTP 404 with engine: "eventmodel" and a reason explaining that read models are shown in the Chronicle Workbench. The browser displays that message instead of a read-model table.

Post to POST /stage/semantic/specifications/run with an empty object or an empty scopes array to run all specifications:

Terminal window
curl -X POST http://localhost:9090/stage/semantic/specifications/run \
-H 'Content-Type: application/json' -d '{"scopes":[]}'

To select a subset, supply semantic identities in scopes. Application, module, feature, slice and specification identities use the same selection rules as the semantic specification runner. The response uses its existing stage-spec-run/1 report schema. HTTP 200 means a report was returned, not that every specification passed: inspect each outcome (Passed, Failed, Unsupported or Cancelled). Refusals carry unsupported.capability, unsupported.construct and unsupported.details. An unknown scope produces an Unsupported(Specification) record rather than being ignored; a malformed identity returns HTTP 400 without running any specifications. Duplicate scopes are selected once; more than 1000 scopes returns HTTP 400. A model refused at initial compilation or runtime admission returns HTTP 501 with the host’s refusal headers and issues. If an admitted model’s live world rebuild or Chronicle registration fails later, specifications remain available: they depend only on the admitted model, not that live world or registration.

The host launches the semantic SpecRunner as a separate process. Each specification runs against a fresh in-memory Chronicle event log and Arc scenario. Runs never seed from, append to, or modify the session’s live event store or world. Testing and kernel assemblies are loaded only by the child, never by the Host. Runs are serialized process-wide. Request cancellation kills the child process tree; a disconnected caller receives no completed report. A timeout returns HTTP 504; launch failure, nonzero exit, or a missing/invalid report returns HTTP 502 problem details, never a passed report. The report must match the host’s admitted model revision; changing source files after startup requires restarting the host before they can be tested.

dotnet publish Source/Host/Host.csproj includes the runner in specrunner/. Configure Stage__Specifications__RunnerPath to override the default specrunner/Cratis.Stage.SpecRunner.dll beneath the host output, and Stage__Specifications__TimeoutSeconds for the run deadline (default 120, clamped to 1–600 seconds, including queued time). The same keys can be set in deployment configuration as Stage:Specifications:RunnerPath and Stage:Specifications:TimeoutSeconds.

Named C# rules and C# validation iterators on commands and their input concepts run in a separate body worker. The Host compiles admitted units once at startup and shares one execution worker across command commits for that loaded application. Specification runs compile once in the runner and share its execution worker across specifications. Each commit or specification gets a lightweight provider; releasing it does not kill the worker. Unloading the application or exiting the runner disposes the shared worker. Neither process loads the units into the Host. The Host forwards its worker settings to SpecRunner, so the same limits apply to both paths.

An admitted validation iterator yields zero messages to accept, or rejection messages in declaration order. Messages are limited to 100 per iterator and 1024 characters each; null messages, exceeded bounds and iterator failures report a worker failure, not a successful validation. Query-argument code validations, command handlers and reducer transitions remain unsupported.

Keep the Host, SpecRunner and BodyWorker from the same Stage build. Worker protocol version 2 rejects older workers during the initial handshake, before sending a request.

Environment variableDefaultRange or resolution
Stage__BodyWorker__EnabledtrueSet false to keep implementation bodies unsupported.
Stage__BodyWorker__WorkerPathbodyworker/Cratis.Stage.BodyWorker.dllRelative to the Host output directory.
Stage__BodyWorker__CallTimeoutMilliseconds200050–30000 ms per evaluation.
Stage__BodyWorker__CompileTimeoutSeconds1205–600 seconds.
Stage__BodyWorker__MemoryLimitMegabytes25632–4096 MB managed heap; working-set watchdog at twice this value.
Stage__BodyWorker__CacheDirectoryCratis/Stage/Bodies beneath your local application-data directoryRelative overrides resolve against the working directory. The cache must be owned by the current user and protected from other users.

The cache holds executable assemblies, so do not share it between operating-system users. On Unix, Stage creates new cache directories with mode 0700 and uses /usr/bin/stat (or /bin/stat) to check ownership. It refuses foreign-owned or group/world-writable caches, symbolic-link cache roots and replaceable parent directories; root-owned sticky temporary parents are allowed. Windows checks ownership and effective write/delete permissions, including inherited permissions on the cache itself. Inherit-only ACEs and CREATOR OWNER / CREATOR GROUP templates do not grant access to the directory being checked. An unsafe cache produces an explicit worker failure, not cached execution; Stage does not repair its permissions. If local application data is unavailable, the default is a private, user-suffixed directory beneath the system temporary directory.

macOS limitation: Unix validation checks ownership and POSIX mode bits, not extended ACL grants. Mode 0700 alone is not proof of isolation when a named ACL grants another user access. In a hostile multi-user environment, use a cache directory and ancestors with no effective extended ACL grants that let other users write, delete or replace the cache or its contents; Stage does not verify this condition.

Stage rechecks cache ownership, permissions and ancestors before compilation and every evaluation. On Unix, each check starts a metadata process, so short, frequently evaluated bodies incur that overhead. These checks are not memoized: permissions or ancestors can change after startup or compilation, and reusing an earlier verdict would weaken the execution-time check.

These keys also work as colon-separated deployment configuration, for example Stage:BodyWorker:Enabled. For a standalone semantic SpecRunner, supply --body-worker-path and optionally --body-worker-cache, --body-worker-dotnet, --body-worker-timeout-ms, --body-worker-compile-timeout-ms, --body-worker-heap-limit-mb, --body-worker-working-set-limit-mb, --body-worker-max-response-bytes and --body-worker-max-stderr-bytes. BodyWorkerOptions.FromArguments parses this same argument contract; without worker arguments it returns no configuration and body rules remain unsupported. A wall-time breach reports STAGE-IMPL-007, a memory breach STAGE-IMPL-008, and an unavailable or failed worker STAGE-IMPL-009. Each kills the worker; a later run can start a fresh one and the Host keeps serving. The managed heap limit plus working-set watchdog is not an OS memory cap or a hostile-code security boundary.

GET /stage/semantic/admission marks specifications admitted by that executor as supported, and reports the missing capability and details for those it refuses, without blocking the live model. The legacy EventModel engine does not expose this route. The /stage/status, /stage/scene and runner results.json schemas are unchanged.

A play session is usually not exposed on its own port but reached through whatever started it — Studio, for one, proxies https://<studio>/api/play/<session>/… to the container’s root. The host is built for that: it honors the standard X-Forwarded-Host, X-Forwarded-Proto and X-Forwarded-Prefix headers, and applies them before anything else in its pipeline. Send them from the proxy and everything that derives a URL from the request follows the public address — the OpenAPI document’s servers entry names https://<studio>/api/play/<session> instead of the internal service name, and Scalar’s request builder targets the same, so the reference works as-is under the prefix.

The headers are trusted from any caller: there is no list of known proxies to configure, because a session only ever sits behind the proxy of whoever started it and is thrown away with it. Keep port 9090 off the public network accordingly — the proxy is the boundary, not the host.

Both Dockerfiles expect a prebuilt dotnet publish output, so build them through the script rather than calling docker build directly:

Terminal window
./dockerize.sh

It publishes the host and the specification runner, then builds both images from the repository root. The host image is tagged cratis/studio-stage:latest by default; STAGE_IMAGE, STAGE_TAG, SPECRUNNER_IMAGE, SPECRUNNER_TAG, VERSION, COMMIT and PLATFORM override the defaults.