Render plans
A Screenplay application is deliberately target-agnostic. A screen names components and slots; it never names
a package, a theme or a shell. That is what makes the same screen work on the web and on a phone — and it means
something has to decide, per target, which component vocabulary is active, which shell is in force, which theme
applies, and how everything lays out. A render plan is that decision, made once per target, before anything
is emitted.
A plan is produced from a ui profile. Each one names its platforms, its packages, and the shell and theme it
selects:
ui profile Admin target platform web, ios layout AppShell theme Aurora
packages PrimeReactWhat a plan is resolved against
Section titled “What a plan is resolved against”Every resolution rule a plan applies comes from Cratis.Scene.Engine — the same engine a running Scene renderer
uses. Stage sequences the calls and reports what they return; it implements no breakpoint, priority or variant
rule of its own, so a plan and a running application can never disagree about what a screen resolves to.
The catalog is the one input a .play file cannot supply. A profile lists package names; the declarations
behind those names — what each package contains, depends on and ships — live outside the document, so the caller
passes them in as ScenePackage records.
Producing a plan
Section titled “Producing a plan”EventModelLoader compiles the model and resolves it in one call:
using Cratis.Scene.Model.Packages;using Cratis.Stage.Contracts;using Cratis.Stage.Contracts.Scene;
IReadOnlyList<ScenePackage> catalog =[ new ScenePackage("core", "1.0.0", PackageKind.ComponentLibrary, [], ["core:title", "core:table"], [], [], [], []), new ScenePackage("Tailwind", "3.0.0", PackageKind.Styling, [], [], [], [], [], []), new ScenePackage("PrimeReact", "1.2.0", PackageKind.ComponentLibrary, [new PackageDependency("Tailwind")], ["core:table"], [], [], [], ["Aurora"]),];
var plan = await EventModelLoader.LoadRenderPlanFromDirectoryAsync("./model", catalog);
foreach (var target in plan.Targets){ Console.WriteLine($"{target.Profile.Name} ({target.Profile.TargetPlatform}): {string.Join(", ", target.Profile.Packages)}");
foreach (var finding in target.Findings) { Console.WriteLine($" {finding.Kind}: {finding.Message}"); }}
if (!plan.IsComplete){ Console.Error.WriteLine("Some targets did not fully resolve.");}When you already hold a translated application — because you are also inspecting the screens, or planning the same model against more than one catalog — plan it directly:
var application = await EventModelLoader.LoadSceneApplicationFromDirectoryAsync("./model");var plan = RenderPlanner.Plan(application, catalog);Both throw InvalidEventModel when the source does not compile. Nothing that happens after that point
throws — see Findings for why.
What a plan contains
Section titled “What a plan contains”ApplicationRenderPlan carries one RenderPlan per target in Targets, plus Findings for anything wrong
with the application as a whole, and IsComplete when nothing anywhere was left unresolved.
Each RenderPlan carries:
| Member | What it holds |
|---|---|
Profile | The target, with Packages replaced by the resolved transitive closure in ascending override-priority order — the list every other member was resolved against. |
Packages | The PackageSelection behind that list: what was Added on the target’s behalf, and any Missing, VersionConflicts and Cycles. |
LayoutName | The name of the shell this target renders inside, or null when nothing in scope declares the one it selects. |
Layout | The shell’s structure, when the application declares it. null with a LayoutName set means an active package provides the shell — a catalog carries names, never structures. |
Theme | The theme this target applies, or null when it selects none. |
ThemePackages | The active packages the theme’s tokens actually apply to, so a renderer scopes tokens instead of applying them globally. |
SizeClass | The size class the arrangements below were evaluated at — the target’s target size, or the class Scene’s own calculator yields at its default breakpoints when the target declares none. |
Components | One ComponentResolution per component name the application references, naming the package it resolved to and every package that also declared the name but was shadowed. |
ScreenTemplates | The ScreenTemplateResolution: where each screen template nests inside Layout, and anything Unplaced or in a cycle. |
Arrangements | One ArrangementSelection per slot-bearing structure: the flow tree, slot variant or element variant that applies at SizeClass. |
Findings | Everything this target could not fully resolve. |
IsComplete | Whether Findings is empty. |
The separate Cratis ArtifactRenderPlan carries generated files and diagnostics rather than
Scene resolution findings. Its Outcome is Complete, Partial, or Refused. With explicit
render-around opt-in, Refusals lists each omitted specification’s semantic identity, kind,
and diagnostic codes in ordinal identity order. Original errors remain, so a partial plan’s
Success is false. No planned file realizes a refused identity through its Sources.
The output digest includes refusals-v1 only for nonempty refusals; empty refusals preserve
existing digests. CratisPlanResult exposes the same outcome and refusals.
See Render around refused specifications
for the exact opt-in and refusal boundary.
Degraded owner omissions
Section titled “Degraded owner omissions”When Stage renders around an unsupported event member in an audited ESM version,
Outcome is Degraded, not Complete. Refusals identifies the root event (STAGE-DEGRADE-001) and every omitted
dependent (STAGE-DEGRADE-005). No emitted artifact’s Sources includes an omitted owner.
Selected omitted constructs appear in the semantic inventory as GraphProvenOmittedLeaf
placeholders, with dependent entries linked to their root events. Report format version 1
is unchanged. These placeholders describe missing code; they are not generated stubs.
Strict mode retains the original whole-render refusal and emits no artifacts. See Rendering around unsupported event members for eligibility and remedies.
Specification case tables
Section titled “Specification case tables”The Cratis artifact render plan emits one specification class per named case in a Screenplay
specification table. Each effective specification is named <Spec>_<Case> and renders as
when_<spec>_<case>.cs. Case parameter values replace case.<parameter> references before rendering.
If two derived names normalize to the same C# identifier, the plan refuses them with
STAGE-CRATIS-004 rather than overwriting a specification file.
Findings
Section titled “Findings”A finding is reported, never thrown. Resolution keeps going and the plan comes back complete with everything that did resolve, so one pass shows every problem across every target rather than stopping at the first — and so your build decides which findings are fatal.
This is deliberately a different mechanism from a compile error. Screenplay’s compiler answers “is this source
valid?”, and Stage throws InvalidEventModel when the answer is no. A finding answers a question the source
alone cannot: “is this valid source enough for this particular target?”. It belongs to a target, not to a span
of text, which is why it lives on the plan.
| Kind | What it means |
|---|---|
NoTargetDeclared | The application declares no ui profile, so there is no deployment target to render for. |
PackageNotInCatalog | A package the target activates has no declaration in the catalog, so nothing it contributes can be resolved. Includes core, which is always active as the final fallback. |
PackageDependencyMissing | A package the target activates depends on a package nothing in the catalog satisfies. |
PackageVersionConflict | A dependency resolved by name, but to a version its declared range does not accept. |
PackageDependencyCycle | Packages depend on each other, so no override priority exists between them and name collisions have no defined winner. |
LayoutNotFound | The target selects a shell neither the application nor any package it activates declares. |
ScreenNotOnSelectedLayout | A screen resolved against a different shell than this target selects. A screen carries one layout name, so a target selecting another shell would render it somewhere it was never resolved for. |
ThemeNotFound | The target selects a theme the application does not declare, so no tokens apply. |
ThemeIncompatible | The theme is not declared compatible with a package the target activates — that package’s components render unthemed or wrongly themed. |
ComponentNotResolved | A component name the application references resolves against no active package: a hole in the screen where that component should be. |
ScreenTemplateUnplaced | A screen template’s fits slot names no single container — nothing declares that slot, or several do and the name is ambiguous. |
ScreenTemplateCycle | Screen templates nest inside each other, so no tree can be built from them. |
StructureInvalid | Scene’s structure index could not index the application’s modules, features and slices, for example because two siblings share an id. Navigation targets and toolbar buttons resolve against that index, so they are not trustworthy until it is. |
NavigationInvalid | Scene’s navigation diagnostics found a problem in a navigation region: a target that does not exist or is out of a module’s scope, a slot no layout declares, a duplicate item id, or a module with no feature to go to. The message carries Scene’s code, such as NavTargetMissing. |
ToolbarButtonInvalid | Scene’s toolbar validation found a problem in a button: a slice that does not exist, a slice that is not a State Change slice, or a button that names no slice. The message carries Scene’s code, such as ToolbarButtonSliceNotStateChange. |
SampleDataInvalid | Scene’s sample data validation found a problem with a pack or a profile’s selection: two pack names that derive the same id, an invalid locale, a repeated shape, or an active, mapped or bound pack that is not available. Design and preview time only; a production build is unaffected. |
SizeClassVariantMissing | A freeform arrangement declares no variant for the size class this target renders at. Freeform has no fallback variant by design, so those slots have nowhere to go. |
One invocation, one plan per target
Section titled “One invocation, one plan per target”An application ships several targets — a web app and a companion mobile app are two ui profiles, and a single
profile naming two platforms is two targets as well. Stage plans all of them from one invocation, and never
runs a build per target.
The targets come out of a single compile of a single .play source set. Planning them together is the only way
they are guaranteed to describe the same application; a build per target compiles the same source once per
target and can only compare the outcomes afterwards, if at all. It is also what makes “this template is
unplaceable on the phone but fine on the web” a question the build can answer at all, and what lets a build fail
once, coherently, with every target’s findings in hand.
Planning together does not mean emitting together. Targets is a list and every entry is independently
emittable, so shipping the web bundle now and the mobile package later is a filter over the plan rather than a
second build:
var mobile = plan.Targets.Where(target => target.Profile.TargetPlatform is "ios" or "android");The split belongs at emission, where the artifacts genuinely differ — not back at compilation, where they do not.
Artifact manifest for code rendering
Section titled “Artifact manifest for code rendering”ArtifactRenderPlan is the separate, destination-independent code-rendering contract (not the Scene RenderPlan above). Its Artifacts are ordered by normalized relative path. Each PlannedArtifact carries exact Bytes, their lowercase Sha256, and Sources: sorted, distinct SemanticIds of the model elements that file realizes. Scaffold files have empty Sources because they realize no semantic element; a Scene composition carried as a profile input is the exception and names the application identity. The plan carries the model’s SemanticRevision; SHA-256 hashes only artifact bytes, not sources or plan metadata. Use path, sources, and hash together when recording a mapping from model elements to generated files.
Self-reducing model ownership
Section titled “Self-reducing model ownership”With the explicit self reducer convention, the existing slice file contains the
read-model record, queries, public static Reduce overloads, and non-public typed-context
transition helpers. No separate reducer class or file is planned. The combined file’s
Sources includes its declaring slice, models, queries, projections, and the observed
event contracts that previously belonged to the separate reducer file. The generated-type
inventory likewise contains no nonexistent reducer class.
The default classic layout retains its existing bytes, paths, and ownership. A convention input alone does not change an output-only digest when it affects no selected artifact. Migrating an existing classic read model plans the removal of its reducer file. See Reducer rendering convention and Required removals.
Required removals
Section titled “Required removals”ArtifactRenderPlan.RequiredRemovals lists files the publisher must delete together with
writing the plan’s artifacts. Each ArtifactRemoval has a portable RelativePath, the
lowercase SHA-256 the file must still have (ExpectedSha256), and the Model whose
artifact replaces it. Removals are normalized like artifact paths and sorted ordinally.
A plan refuses removals that collide with an artifact path or with each other ignoring
case, removals whose model has no planned artifact, and removals on a plan with errors.
An empty list leaves the plan digest unchanged; a nonempty list is part of the digest.
A publisher must delete each removal atomically with its writes, refuse when the file’s
hash differs, and treat an already absent file as removed so recovering an interrupted
publication stays idempotent. CratisRendering.CheckPublication returns
CratisPublicationCheck.UnsafeRemovals (STAGE-REDUCER-005) for a changed file and for a
migrated read model’s previous reducer file that the plan would leave behind.
Source ownership inventory
Section titled “Source ownership inventory”The Cratis renderer records the following identities in Sources. The plan normalizes every list by removing duplicates and sorting identities ordinally; list order does not imply ownership priority.
| Artifact | Sources |
|---|---|
| Combined StateChange slice file | Declaring slice, its commands and event declarations. |
| Combined StateView slice file | Declaring slice, its read models, projections and queries. |
| Combined Automation slice file | Declaring slice, its commands, events, read models, projections and queries. |
| Separate reducer | Target read model, observed event declarations and declaring slice. An input event’s declaring slice is not added. |
| Generated specification files | Specification and its declaring slice, for command, read-model, query and reaction scenarios, including case-table artifacts. |
| Separate reactor | Reaction identity; its declaring slice is not explicitly included. |
| Common concept or composite type | Declaration identity. |
| Constraint | Declaring slice and target event declarations. |
| Event-source registration | Event-source identity and its stream identities. |
| Typed-context wrapper | Operation identity, when present; otherwise empty. |
| Operation policy and policy-context wrapper/body | Protected command or query identity. |
| Shared policy registrations and policy-context runtime/bodies | Application identity. |
<slice-folder>/Screens.scene.json | Declaring slice identity only, when the profile carries screen partitions. Bound commands or queries do not change the owner. |
scene.json, src/bindings.ts, src/stage-scene.json, src/stage.ts | Application identity only, for both authored and default Scene composition; emitted at application scope, and by a scoped plan whose profile carries the user interface input. |
| Scaffold | Empty, except application locale inputs, which name the application. |
Sources preserves provenance, not exclusive folder ownership. A reducer observes event declarations from other slices without becoming those slices’ reducer. Consumers selecting slice-owned business files should use the explicit declaring slice where provided and model mapping for artifacts such as reactors. App-scope Scene files remain application-sourced: select the declaring slice’s Screens.scene.json for its screen code instead of presenting the whole application UI.
Screenplay ESM v1 gives constraints and named policies no individual semantic identities. Cratis maps constraint files to their owning slice and target event identities, and generated policy registrations to the protected command/query identities. Source-only enrichment changes the plan digest and manifest metadata, but not artifact bytes or their Sha256 hashes.
Slice-owned screen fragments
Section titled “Slice-owned screen fragments”Call CratisRendering.WithScreenPartitions(profile, compilation) with the compilation that owns the profile’s translated Scene before planning. When no screens are authored, the input carries an empty partition without requiring a Scene payload. The optional cratis-authoring:screen-partitions input is versioned and hashed. It retains the compilation’s sources and identity catalog because the executable model contains no screen declarations. Planning recompiles that authority in memory to verify each declaring slice and the canonical translated screen payload. The input records real slice identities, screen records and app-scoped inherited contributions; it invents no screen or component semantic identities. Legacy profiles without this input retain their existing artifacts and receive no inferred ownership.
Each declaring slice gets <slice-folder>/Screens.scene.json containing a screens array. Screen keys, component IDs, forms and bindings remain in the records. Templates, shared shell data and inherited module/feature contributions remain app-scoped; fragments are Scene code, not standalone applications or React components. The application Scene is assembled from those same partitioned records, with inherited contributions restored, and retains its canonical bytes.
CratisRendering.PlanFrom(loaded, selection, options) emits the selected slices’ fragments with the same bytes as an application-scope plan. A feature selection produces the union, including nested features. By default, scoped plans omit all four Scene aggregates rather than publish a partial application; STAGE-SCREEN-006 explains that application scope, or the user interface option, is required to publish shared UI. A scoped plan whose profile carries the cratis-render:user-interface input (set through CratisPlanOptions.UserInterface) emits the four aggregates composed over the slices that input names plus its own selection: only those slices’ screens, commands and queries are composed, bound and imported, so the bindings never import a proxy whose slice has not been generated. With every slice present, the aggregates are byte-identical to the application-scope ones. A profile that also carries the cratis-render:user-interface-domains input (set through CratisPlanOptions.UserInterfaceDomains) adds the slices generated under the application’s other domains and imports each of their proxies from that domain’s folder; the input is admitted only alongside cratis-render:user-interface. An input naming an identity that is not a slice of the model is refused with STAGE-SCREEN-007. A slice without an authored screen receives STAGE-SCREEN-005 when partitions are carried: Stage emits no slice-owned default fragment and never attributes synthetic application UI to that slice.
Admission refuses unsupported-version, stale, malformed, duplicate or unresolved partitions, missing screen coverage and disagreements with either the carried Scene or the compilation’s translated screen records, without partial artifacts. Naming another existing slice as owner is refused even when it belongs to the same feature. Screen partitions add managed paths and therefore change artifact lists and output digests; rename/removal uses the normal publication ownership and drift checks.
Authored code documentation
Section titled “Authored code documentation”Render hosts that hold a SemanticCompilation can preserve authored descriptions and command, event, and read-model documentation by passing their resolved profile and the same compilation to CratisRendering.WithAuthoringMetadata before creating the ArtifactRenderRequest for CratisArtifactRenderPlanner.Plan. This preserves any Scene or strings inputs already in the profile. The model-only CratisRendering.Plan overloads cannot recover syntax-only metadata. The cratis CLI calls its metadata adapter when constructing the render profile, preserving authored documentation through WithAuthoringMetadata. If the compilation’s paths or workspace documents do not meet the metadata reader’s requirements, the CLI renders without those comments and reports warning CLI-RENDER-006.
Descriptions become XML-escaped <summary> comments on generated commands, events, read models, query methods, composite types, reactions, and specification classes. A specification description is repeated on each generated scenario class realizing that specification. Command, event, and read-model Markdown documentation becomes escaped text in <remarks>; it is not converted to HTML. Reaction descriptions and documentation become the generated reactor class’s summary and remarks. The retained syntax renderers also preserve trigger descriptions; those have no separate generated symbol in the semantic path. Line endings, including Unicode next-line, line-separator and paragraph-separator characters, and trailing whitespace are normalized. With no authored metadata, existing generated summaries and artifact bytes are unchanged.
Metadata travels as a versioned, hashed profile input keyed by semantic identity, not as a generated sidecar file. A documentation-only edit changes the affected artifact hashes without changing the executable model revision. Planning refuses malformed, stale, duplicate, or unsupported-version metadata. Declarations without a corresponding generated symbol do not gain an artificial documentation artifact; the generated frontend scaffold is not a declaration-documentation surface.
What a plan does not decide
Section titled “What a plan does not decide”- It emits nothing. A plan is the resolved input an emitter consumes; producing a static web bundle or a native app package is a separate step.
- It does not invent a catalog. Pass one in. With no catalog, every package is unknown and every component name is unresolved, which the findings will say plainly.
- It does not resolve a shell a package only names. A catalog carries package names and the names of what they contribute, never structures — so a shell provided by a blueprint is named in the plan, and its arrangements and template placements are not part of it.
- It does not push findings back into Screenplay’s diagnostics. Screenplay diagnostics describe source text and have no target to attribute a per-target outcome to.