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.
Start a disposable session
Section titled “Start a disposable session”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.
Prepare a small model
Section titled “Prepare a small model”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.
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 Stringid 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.
Start with the CLI
Section titled “Start with the CLI”With the Cratis CLI installed and your complete model in ./screenplays, run:
cratis run ./screenplaysThe 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.
Start directly with Docker
Section titled “Start directly with Docker”-
Open a terminal in the folder containing your model. The mount below is read-only and searched recursively.
-
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 -
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.
Use the application
Section titled “Use the application”- 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.
- 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
GreetCustomerand sendidas11111111-1111-4111-8111-111111111111andtextasHello from Stage. - Run the corresponding query in Scalar, or open its screen, to inspect the changed read state. For the example,
call
AllWelcomesor open Welcome: after the accepted command, look for a row with thatwelcomeIdand 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. - Open Workbench through Stage to inspect the session’s event history.
For the example, look for
CustomerGreetedon 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. - 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.
What is inside
Section titled “What is inside”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:
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>-developmenttag — the same versionDirectory.Packages.propspins for theCratis.Chronicleclient — rather thanlatest. 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 publishoutput is produced on the host (bydockerize.shor the publish workflow) and only copied into the image, because portable IL does not need to be recompiled per architecture.
How it boots
Section titled “How it boots”entrypoint-stage.sh runs both processes in order:
- Check the selected input, defaulting to
/eventmodel. A missing path, a file without a.playextension, or a folder without.playfiles 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. - Start the Chronicle kernel from
/appwith in-memory storage, and with its API and Workbench features turned on explicitly. - Wait for the kernel — it polls port
35000until the gRPC endpoint accepts connections, so the Stage never races ahead of the event store it needs. - Start the Stage host with the selected path. It compiles just that file, or compiles every
.playfile 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.
| Port | Protocol | What it serves |
|---|---|---|
9090 | HTTP | The model’s API — commands, queries, OpenAPI and the Scalar reference. See URLs of a running Stage. |
35000 | HTTPS | The 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 silo
Section titled “The Stage silo”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.
Mounting the model
Section titled “Mounting the model”The model is read from /eventmodel inside the container:
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:latestThe 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:
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.playThe 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.
Configuration
Section titled “Configuration”The host separates hosting defaults from deployment configuration:
| Source | Carries |
|---|---|
appsettings.json / appsettings.Docker.json | Hosting 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.json | Deployment configuration, loaded from the content root if present. Optional, and reloaded on change. |
STAGE_CONFIG | Overrides 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.
Legacy-engine command validation
Section titled “Legacy-engine command validation”With Stage__Runtime__Engine=eventmodel, the legacy EventModel engine enforces this subset of
command-level declarative validate rules:
| Rule | Legacy-engine behavior |
|---|---|
not empty | Rejects 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 literal | Inclusive value bounds on numbers (including decimals); inclusive UTF-16 length bounds on text. |
> / >= / < / <= with a numeric literal | Compares numeric values or text’s UTF-16 length against the threshold. |
length with a non-negative integer literal up to 2147483647 | Requires that exact text length. |
matches with a quoted literal | Applies 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.
Opt in to semantic execution
Section titled “Opt in to semantic execution”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:
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:latestThe 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.
Sandbox identity
Section titled “Sandbox identity”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:
- Use the persona named by a
Cratis.Blueprint.Default.userMenucomponent’s literalroleproperty. - Otherwise, use the persona whose policies satisfy the most module-level
authorizerequirements, in declaration order when tied. - 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.
Browse read models in a semantic session
Section titled “Browse read models in a semantic session”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-modelsreturns 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 theidfrom 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.
Run specifications in a semantic session
Section titled “Run specifications in a semantic session”Post to POST /stage/semantic/specifications/run with an empty object or an empty scopes array to run all specifications:
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 variable | Default | Range or resolution |
|---|---|---|
Stage__BodyWorker__Enabled | true | Set false to keep implementation bodies unsupported. |
Stage__BodyWorker__WorkerPath | bodyworker/Cratis.Stage.BodyWorker.dll | Relative to the Host output directory. |
Stage__BodyWorker__CallTimeoutMilliseconds | 2000 | 50–30000 ms per evaluation. |
Stage__BodyWorker__CompileTimeoutSeconds | 120 | 5–600 seconds. |
Stage__BodyWorker__MemoryLimitMegabytes | 256 | 32–4096 MB managed heap; working-set watchdog at twice this value. |
Stage__BodyWorker__CacheDirectory | Cratis/Stage/Bodies beneath your local application-data directory | Relative 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.
Behind a reverse proxy
Section titled “Behind a reverse proxy”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.
Building the images locally
Section titled “Building the images locally”Both Dockerfiles expect a prebuilt dotnet publish output, so build them through the script rather than calling
docker build directly:
./dockerize.shIt 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.