Skip to content

Folders

A .play file describes an application. That works beautifully right up until the application gets big - the invoicing sample in this repository is a single file of nine hundred lines, and it only has one module. Put five modules in it and nobody can find anything, two people cannot touch it at once, and a diff stops telling you what changed.

The structure you want is already in the language. Modules contain features, features contain slices - so let the file system hold that shape, one folder per level, and let the compiler put it back together. That is what this page is about: a folder of .play files is one application, in both directions.

IPlayFileWriter.Expand

IPlayFileCompiler.CompileFolder

ApplicationSyntax

application.play

Invoicing/Invoicing.play

Invoicing/Invoices/Invoices.play

Invoicing/Invoices/Register/Register.play

A folder says nothing about what the application is made of, and every slice file in it restates the module and feature it belongs to. Imports let a root file name the files of the application, and let a module or feature file place the files it imports - so a focused file holds only its own part of the story.

When viewing a folder application, both boards compile every .play file in the folder in alphabetical file-path order. When VS Code opens an importing document with no application.play above it, it instead compiles that document and what it imports. An application root’s import order supplies presentation ranks only. Unimported files remain visible after ranked siblings, in their previous relative order; merge diagnostics and event ownership do not change.

VS Code recognizes application.play as the folder root; the MCP App can also find a single importing root with another name when the folder has no application.play. Imports expand depth-first where they are written; declarations within a file keep text order, and glob matches stay alphabetical. Explicit container declarations in the root or an enclosing container’s own named file take precedence over restating slice files. With no importing root—including an import-less application.play written by IPlayFileWriter.Expand—the board keeps path order.

Use explicit imports in a composite file when the story needs a particular sequence. Expanding a file into folders does not by itself preserve its board order. Ordering affects presentation, not executable model bytes or identities.

Folder compilation also checks the merged application’s event flow on the timeline. It uses the sole root, an importing folder-root application.play, or the unique importing root, in that order; without an ordering root it skips the check. PLAY0516 identifies a slice using an event declared to its right, and PLAY0517 identifies a mutually dependent group. Both are information diagnostics and do not fail --warnaserror.

CompileFolder discovers every .play file beneath a folder, merges them into the one application they describe, and resolves the whole:

using Cratis.Screenplay.Files;
var compilation = new PlayFileCompiler().CompileFolder(root);
if (compilation.Result.Success)
{
var application = compilation.Result.Value!;
}

The merge happens before anything is resolved, and that is the entire point. An event declared in one file and produced in another resolves. A concept declared once at the root is available to every slice. A policy an authorize names is found wherever it lives.

Compare that with CompileIn, which compiles every file as a document in its own right. Given a folder where Register.play declares InvoiceRegistered and Submit.play produces it, CompileIn reports:

Submit.play(5,9): warning PLAY0165: Unknown type 'InvoiceId' on 'invoiceId' of command 'Submit' - declare it with 'concept InvoiceId : <Primitive>' or 'type InvoiceId'
Submit.play(6,9): warning PLAY0167: Unknown policy 'CanManageInvoice' - declare it with 'policy CanManageInvoice'
Submit.play(7,9): warning PLAY0166: Unknown event 'InvoiceRegistered' - declare it with 'event InvoiceRegistered'

None of those are real. CompileFolder reports nothing, because none of them are missing - they are just in another file. Both calls remain available: reach for CompileIn only when the files genuinely are separate documents that happen to share a folder.

Module and feature dependencies accumulate on the same owner in ordinal file-path order, regardless of import presentation order. Repeated resolved targets are kept once with PLAY0555 on each repeat; unresolved repeats compare by text. Target resolution uses the merged inventory, so a target may be declared in another file. Layout expansion writes dependencies only in the owner’s own file and strips them from restated ancestor headers, like description.

Module and feature documentation follows first-wins merge order, just like descriptions: identical copies are accepted, and a different copy warns with PLAY0559 while retaining the first text. Put reasoning in the owning container file. Layout expansion keeps it there and strips it from restated ancestor headers. See Descriptions and documentation.

Diagnostics know which file they came from

Section titled “Diagnostics know which file they came from”

A single document needs no file identity - there is one source text, and you handed it over. A folder does, so every SourceLocation in a folder compilation carries the relative path of the file it came from:

foreach (var diagnostic in compilation.Result.Diagnostics)
{
Console.WriteLine($"{diagnostic.Location.Path}({diagnostic.Location.Line},{diagnostic.Location.Column}): {diagnostic.Message}");
}

A location carries a path exactly when the compiler was told one. ScreenplayCompiler.Compile is never told one, so Path stays null there and nothing about compiling a single document changed. The compilation also hands back the source text of every file it read, in Sources, so a diagnostic can be rendered with its offending line by the formatter:

var sources = compilation.Sources.ToDictionary(source => source.File.RelativePath, source => source.Source);
var formatter = new DiagnosticFormatter();
foreach (var diagnostic in compilation.Result.Diagnostics)
{
var file = diagnostic.Location.Path!;
Console.WriteLine(formatter.Format(file, diagnostic, sources[file], useColors: true));
}

Consumers do not usually want the syntax tree - they want their own representation of it, which is what the visitors and the walker are for. Both the folder and the single file have a visitor overload, so a consumer takes one path regardless of how the application arrived:

var compiler = new PlayFileCompiler();
var fromFolder = compiler.CompileFolder(root, new MyApplicationVisitor());
var fromFile = compiler.CompileFile(path, new MyApplicationVisitor());

Both return an ApplicationCompilation<T> carrying the visitor’s result and the diagnostics. The visitor sees the merged application - one tree, whatever it was spread across - and runs only when the compilation had no errors.

screenplay <folder> verifies the folder as one application. screenplay <file> still verifies that one file on its own, which is what you want when a generator just produced it:

Terminal window
screenplay path/to/screenplays # the folder, as one application
screenplay path/to/invoicing.play # that file, on its own

A host that edits existing source should keep the exact documents—not only the merged syntax tree—as its authority. ScreenplayWorkspace admits strict UTF-8 documents, portable paths, stable document identities, and an identity catalog, then derives the current semantic compilation without rewriting the source bytes:

using Cratis.Screenplay.Semantics;
using Cratis.Screenplay.Workspaces;
var catalog = SemanticIdentityCatalog.Empty(ApplicationIdentity.Create("Invoicing"));
var document = WorkspaceDocument.Create(
"application",
PortablePlayPath.Parse("application.play"),
sourceBytes);
var workspace = ScreenplayWorkspace.Create("Invoicing", [document], catalog);

When the source compiles, workspace creation materializes the current document, semantic, and event-contract identities into the returned catalog. When it does not compile, the workspace still retains every exact authored byte and exposes the failed Compilation; opening imperfect source never destroys it.

An authoring system such as Studio can keep its durable keys authoritative without constructing semantic addresses for hashing or formatting canonical identity strings. Create semantic identities from the artifact kind and stable key, and scope event-contract identities to the stable application identity:

using System.Text;
using Cratis.Screenplay.Semantics;
using Cratis.Screenplay.Workspaces;
var applicationIdentity = ApplicationIdentity.Create("studio-application-42");
var document = WorkspaceDocument.Create(
"registration",
PortablePlayPath.Parse("Projects/Registration.play"),
Encoding.UTF8.GetBytes(
"""
module Projects
feature Registration
slice StateChange RegisterProject
command RegisterProject
event ProjectRegistered
"""));
var sliceAddress = SemanticAddress.ForSlice(
applicationIdentity,
"Projects",
"Registration",
"RegisterProject");
var commandAddress = SemanticAddress.ForCommand(sliceAddress, "RegisterProject");
var eventAddress = SemanticAddress.ForEventContract(sliceAddress, "ProjectRegistered");
var commandIdentity = SemanticId.Create(SemanticKind.Command, "studio-command-node-42");
var eventSemanticIdentity = SemanticId.Create(SemanticKind.EventContract, "studio-event-node-42");
var eventContractIdentity = EventContractId.Create(applicationIdentity, "studio-event-contract-42");
var catalog = SemanticIdentityCatalog.Create(
applicationIdentity,
[],
[
new(commandAddress, commandIdentity, SemanticIdentityOrigin.Persisted),
new(eventAddress, eventSemanticIdentity, SemanticIdentityOrigin.Persisted)
],
[
new(
eventAddress,
eventContractIdentity,
EventContractRevision.Initial,
SemanticIdentityOrigin.Persisted)
]);
var workspace = ScreenplayWorkspace.Create(
applicationIdentity,
"Project portfolio",
[document],
catalog);

SemanticId.Create(SemanticKind, string) separates every semantic kind. EventContractId.Create(ApplicationIdentity, string) also separates applications. Both external-key factories normalize keys to Unicode NFC and reject empty keys, malformed Unicode, control characters, and path-like values. Treat their canonical results as opaque.

The explicit ScreenplayWorkspace.Create overload keeps ApplicationIdentity independent from the friendly ESM application name. A display-name change therefore changes the semantic and workspace revisions but not the catalog revision or assigned identities. Persisted assignments also survive document moves. Declaration renames preserve them only through the transaction’s explicit SemanticIdentityRename and EventContractIdentityRename mappings; Screenplay never guesses continuity from similar names or locations. The original three-argument overload and the address/name-derived identity factories remain the compatibility path for existing source.

Changes are pure proposals tied to both the workspace revision and identity-catalog revision:

var result = workspace.Propose(new WorkspaceTransactionRequest
{
ExpectedRevision = workspace.Revision,
ExpectedCatalogRevision = workspace.IdentityCatalog.Revision,
Operations =
[
new ReplaceWorkspaceDocument
{
Document = document.Id,
Bytes = updatedSourceBytes
}
]
});
if (result.Success)
{
var candidate = result.Workspace!;
foreach (var entry in result.WritePlan!.Entries)
{
Console.WriteLine($"{entry.Kind}: {entry.Before?.Path} -> {entry.After?.Path}");
}
}

A stale revision, portable path collision, duplicate owner, malformed operation, invalid identity migration, or failed compile rejects the complete proposal. The original workspace is untouched. The write plan contains exact before/after documents but performs no file-system operation; CLI and Studio decide whether and where to publish it. Document key renames preserve DocumentId explicitly. Removing declarations requires explicit semantic and event retirements, so deleting source cannot silently erase durable identity continuity.

This transaction layer deliberately operates on whole documents. Single-file↔folder restructuring builds on top of it rather than introducing a second mutation contract - and so does the first semantic patch below.

Patch a slice description by its stable semantic identity

Section titled “Patch a slice description by its stable semantic identity”

Most edits a host makes are whole-document replacements. One field is different: a slice’s single-line quoted description is small, frequent to edit, and addressed more naturally by the slice’s stable SemanticId than by document and byte range. UpdateSliceDescription is a WorkspaceOperation like any other - it goes through the exact same Operations array, the same revision gates, and the same parse/merge/bind/identity pipeline - it is not a second preview/apply method:

var slice = workspace.Compilation.Value!.Model.Application.Modules.Single().Features.Single().Slices.Single();
var result = workspace.Propose(new WorkspaceTransactionRequest
{
ExpectedRevision = workspace.Revision,
ExpectedCatalogRevision = workspace.IdentityCatalog.Revision,
Operations =
[
new UpdateSliceDescription
{
SemanticId = slice.Id,
ExpectedCurrentDescription = "Registers a new project",
NewDescription = "Registers a brand new project"
}
]
});

A successful proposal changes only the description’s literal body bytes - every other byte of the owning document, and every other document in the workspace, is untouched - and produces exactly one Replaced write entry, going through the same WritePlan shape as a whole-document edit. The operation is admitted only for an existing single-line quoted slice description; it fails typed rather than falling back to raw text editing:

  • SemanticIdNotFound - the semantic identity does not exist in the workspace’s identity catalog.
  • UnsupportedSemanticField - the identity exists but does not address a slice (a module, feature, or command identity), or the slice has no single-line quoted description to patch (missing entirely, or written as a fenced multi-line block).
  • SemanticFieldValueDrift - ExpectedCurrentDescription does not match the decoded current text exactly.
  • MultiOwnerSemanticEdit - another operation in the same transaction, semantic or whole-document, already claims the owning document; a transaction may touch one document once regardless of how many of its operations reach it.

The stale-revision and stale-catalog-revision gates run before any operation is even inspected, exactly as they do for whole-document operations. This is the first semantic patch; broadening it to other fields or to multi-line descriptions is future work, not something this operation attempts today.

To change which command property supplies an existing event property, use UpdateProducedEventMappingSource. You address declarations, not text occurrences: even when a comment or another command repeats the same mapping, only the selected right-hand source changes. Start with a compiled workspace containing one explicit mapping such as name = name, and a compatible displayName property on that command:

var slice = workspace.Compilation.Value!.Model.Application.Modules.Single().Features.Single().Slices.Single();
var command = slice.Commands.Single(value => value.Name == "RegisterProject");
var producedEvent = slice.Events.Single(value => value.Name == "ProjectRegistered");
var result = workspace.Propose(new WorkspaceTransactionRequest
{
ExpectedRevision = workspace.Revision,
ExpectedCatalogRevision = workspace.IdentityCatalog.Revision,
Operations =
[
new UpdateProducedEventMappingSource
{
Command = command.Id,
ProducedEvent = producedEvent.Id,
TargetProperty = producedEvent.Properties.Single(value => value.Name == "name").Id,
ExpectedSourceCommandProperty = command.Properties.Single(value => value.Name == "name").Id,
NewSourceCommandProperty = command.Properties.Single(value => value.Name == "displayName").Id
}
]
});

ProducedEvent is the event declaration’s SemanticId, not its persisted EventContractId. Both source properties must belong to the addressed command; the target must belong to the addressed event. Their resolved value types and collection shapes must match the target, and an optional source cannot fill a required target. Exactly one production of that event must exist on the command, and it must be unconditional. The mapping must already be explicit and use a direct property source. Literals, computed or nested expressions, inferred mappings, and repeated productions are not editable through this operation.

Submit this operation alone, without document operations, other semantic patches, or identity migrations. Stale workspace and catalog checks still run first. Unknown identities reject with SemanticIdNotFound, wrong owners, incompatible types, unsupported grammar, ambiguous source ownership, or unproven equivalence with UnsupportedSemanticField, and a changed expected source with SemanticFieldValueDrift. Mixed operations or identity migrations reject with InvalidOperation; an unbindable workspace rejects with CompilationFailed. Every rejection returns neither a candidate workspace nor a write plan.

A successful proposal preserves the UTF-8 BOM, comments, whitespace, line endings, Unicode, and every byte outside the parser-owned source range. It passes the normal compile/migrate/recompile pipeline, compares the complete candidate semantics against an independent one-mapping graph edit, and proves canonical print/recompile semantic equivalence without printing over authored source. Identities, destinations, conditions, and specification expectations remain unchanged. Selecting the current source is an admitted no-op with no write entries.

This changes real declarative behavior, not code attachments, and Screenplay does not rewrite specification expectations to make them pass. A then value that the new source can no longer produce from the stated when values is a compile error (PLAY0285), so such a proposal rejects with CompilationFailed and its diagnostics. Run your semantic specifications on an accepted candidate for everything the static check cannot decide. This bounded operation does not complete general declarative patching or provide a file-system publisher; use the write plan to review exact changes before your host publishes them.

The inverse lives next to it. Expand turns an application into the files of a folder structure without touching the file system, and WriteTo puts them on disk:

using Cratis.Screenplay.Files;
var writer = new PlayFileWriter();
foreach (var file in writer.Expand(application))
{
Console.WriteLine(file.RelativePath); // and file.Content
}
writer.WriteTo(application, root);

Expand hands the files back rather than writing them, so the same structure can go to disk, into an archive, or straight down a wire without the expansion knowing which. WriteTo creates every folder it needs and overwrites the files it names - it leaves anything else beneath the root alone, so a removed slice leaves its file behind. Clear the folder first when the structure must be exactly what the application says.

Every level of the language gets a folder, and the file inside a folder carries that level’s own content:

application.play domain, imports, concepts, types,
policies, personas, authentication, identity, seed
Invoicing/
Invoicing.play module Invoicing - description and templates
Invoices/
Invoices.play feature Invoices - description
Register/
Register.play slice StateChange Register
Submit/
Submit.play slice StateChange Submit
Archiving/
Archiving.play nested feature Archiving - description
Archive/
Archive.play slice StateChange Archive
FileHolds
application.playEverything that belongs to the application as a whole rather than to any one module: domain, import, concept, type, policy, persona, authentication, identity and seed. There is one, always, at the root.
<Module>/<Module>.playThe module’s own description, authorize, screen template, dialog template, form, and contribute declarations - not its features.
<Module>/…/<Feature>/<Feature>.playThe feature’s own description, authorize and contribute declarations - not its slices or sub features.
<Module>/…/<Feature>/<Slice>/<Slice>.playOne slice, whole.

Every one of those is a complete .play document. A slice file restates the module and feature it belongs to, because that is what the language needs in order to place a slice:

module Invoicing
feature Invoices
slice StateChange Register
event InvoiceRegistered
invoiceId InvoiceId

Nothing is written twice. The restated module Invoicing in a slice file carries no description, authorization, templates, forms, contributions, behavior attachments, or module-header comments - those live in the module’s own file. Restated features likewise carry no description, authorization, contributions, behavior attachments or header comments, including when they are ancestors of a nested feature. Comments inside a slice stay in its slice file.

Merging follows a single rule: the documents of a folder are one document. From that everything else follows.

DeclarationWhat the merge does
module, featureCombined by name. Every file naming module Invoicing is talking about the same module. This is what lets a slice live in its own file and still belong to its feature. Header comments from every file survive, including identical text in different files. The owner file’s trailing header comment stays inline; printing places the other header comments above the combined header in file-path order, then source order within each file.
slice, screen template, dialog template, formAccumulated. A second file declaring one that already exists in the same owner is an error.
contribute on a module or featureAccumulated under that owner. Several contributions may target the same contribution point.
authorize on a module or featureDistinct gates in different files accumulate with AND in file-path order; no file can silently override another’s gate. An identical gate repeated in another file is kept once with a PLAY0394 warning. Expansion writes the gate only in its owner’s file and strips it from restated headers. Command and query authorization is additionally AND-composed with every enclosing feature and module gate.
on on a module or featureAccumulated under that owner, in file-path order. Inline behaviors are additive, so distinct on blocks from different files all run. A block identical to one another file already attaches is ignored with a PLAY0340 warning, so it runs once. Expansion writes them only in the owner’s own file.
uses on a module or featureAccumulated under that owner, in file-path order. Attaching the same behavior with the same arguments from a second file is ignored with a PLAY0340 warning, so it runs once; the same behavior with different arguments is two distinct attachments, and both are kept. Expansion writes them only in the owner’s own file.
concept, type, policy, personaAccumulated. Concepts and types share one namespace, so a type cannot take a concept’s name. A second file declaring one that already exists is an error.
domain, authentication, identityAt most one for the whole folder. A second file declaring one is an error.
importMerged and de-duplicated. An import declared anywhere applies to the whole application, exactly as it does within a single document.
seedAccumulated, the same way multiple seed blocks accumulate within one document.
description on a module or featureThe first one given wins. A second, different one is a warning - only the file that owns the folder is expected to describe it.

A duplicate is reported only when the same name is declared in more than one file, and it always names both ends - the file the name was already claimed in, and the location of the file that tried to claim it again:

second.play(1,1): error PLAY0173: Duplicate declaration of 'InvoiceId' - already declared in 'first.play'
second.play(3,5): error PLAY0173: Duplicate slice 'Register' in feature 'Invoices' - already declared in 'first.play'
second.play(1,1): error PLAY0172: The folder already declares a domain in 'first.play' - a folder compiles to one application, which can have at most one

Duplicates within one file are left to the single document compiler, which already has its own rules for them. Compiling one document behaves exactly as it always has. Ownership is attached to the file declaring a member, never to a module or feature header restated to place a child; descriptions are not declarations and keep their warning behavior. A workspace transaction that encounters a cross-file declaration error returns ConflictingOwner, with Path for the second claimant and OtherPath for the first; it offers no write plan. Other parse, binding and validation failures remain CompilationFailed. An external disk edit since proposal is rejected before the first write; reopen the workspace rather than applying a stale plan.

import names something that comes from outside the application - another bounded context, another team’s contract. It does not resolve against another file of the same folder, and it does not need to: the files of a folder are one document, so a name declared in one of them is simply in scope in all of them. Adding an import for a name your own application declares would say the opposite of what is true, so the compiler reports it as a PLAY0290 warning. Such an import has no effect: every reference and every consistency check resolves the name to your own declaration, exactly as it would without the import.

application.play(2,1): warning PLAY0290: Import 'Catalog.ItemView' names 'ItemView', which this application declares - the import has no effect

PlayFileWriter.Expand uses the same folder-layout projection as workspace layout changes. WriteTo still creates directories and overwrites files directly for existing callers; unlike a workspace write plan it has no prior revision, hashes or managed-file inventory to check. PlayFileCompiler.CompileFolder shares the folder merge used by workspace semantic compilation, but retains its syntax result, source strings, caller-supplied compiler/file discovery, and diagnostics even on invalid input. It cannot open an exact-byte workspace without changing legacy path/encoding admission and failure behavior. Use workspace proposals and the destination’s drift checks when a save must reject stale files.

Writing a folder and compiling it back gives an equivalent application. The invoicing sample exercises the broad round-trip: it expands to twenty-one files, compiles back with no diagnostics, and expanding the result again produces exactly the same twenty-one files, byte for byte. A separate fixture covers module forms, behavior attachments on modules and features, and contributions on modules, features, and nested features, including when the owner’s file sorts after its descendants.

Declaration order depends on the layout. With the import-less PlayFileWriter.Expand layout, modules, features and slices come back sorted by name rather than in the order they were authored. MCP expand-layout writes imports that preserve sibling order and a child’s position among its parent’s other members, so collapsing that layout retains interleaved contributions and templates too. Everything within a slice - including the order of its events, commands, constraints and specifications - comes back in authored order, because it never left its file. Without import-position evidence, members of a module or feature that span different files have no shared source order; canonical printing uses kind order for those members. Leading and trailing comments follow the declarations they annotate when the layout changes. Blank lines are canonical rather than copied from the original source.

  • A small model. One file you can read top to bottom beats eleven folders. Reach for a folder when the single file stops being navigable, not before.
  • Something generated end to end. If a tool produces the document and nothing hand-edits it, the structure buys you nothing - print it with the printer and write one file.
  • Several unrelated applications in one folder. CompileFolder will merge them, because it has no way to know they are not one application. Give each its own folder.
  • Names a file system cannot tell apart. Two declarations at the same level whose names differ only in casing have nowhere separate to live, and expansion throws AmbiguousPlayFilePath rather than silently losing one.