Skip to content

Syntax tree compatibility

The syntax tree is a public API. Every consumer that compiles Screenplay to something - a code generator, a designer, a documentation tool - is written against these types, so what the tree promises across versions decides how much work a Screenplay upgrade costs them.

This page states those promises. It is deliberately narrow: a guarantee you cannot rely on is worse than one that was never made.

Every node is a positional record deriving from SyntaxNode, which carries a SourceLocation. Nodes are immutable, compare by value, and support with expressions. A construct with several forms - an expression, a policy condition, a projection block, a screen directive, a projection mapping, a projection key, a validation block, a constraint - is an abstract base record with a concrete record per form.

That shape is stable. Nodes will stay records, stay immutable, and keep deriving from SyntaxNode.

A node kind can be added. New constructs arrive as new record types. If the new type derives from an existing abstract base, it starts appearing in collections you already read - see the caveat below. If you consume the tree through ScreenplaySyntaxWalker, a new kind arrives as a new Visit method with a default implementation that walks its children, so your subclass compiles unchanged and keeps walking. That is the property the walker exists to give you.

An unknown form never throws. The walker dispatches abstract bases with a fallback: a concrete form it does not recognize is passed to VisitNode and then left alone. A tool compiled against an older Screenplay does not fault on a document using a newer construct - it silently does not act on it, which is the recoverable failure.

Enum members keep their numeric values. SliceType, ValidationRuleKind, ComparisonOperator, LogicalOperator, AutoMapMode and CaptureWhenKind gain members at the end and never renumber existing ones. This one matters more than it looks: the C# compiler inlines an enum constant into the consuming assembly, so renumbering a member changes behavior in an already-compiled consumer with no error anywhere - the value it was built with silently means something else. New members are therefore always appended.

public const values never change. The diagnostic codes are public const string, and a const inlines into consumers exactly the way an enum member does. A code’s value is fixed once published; a diagnostic that changes meaning gets a new code rather than a new value on the old one. The same rule applies to every other published constant, such as QuerySyntax.ObservableModifier and PropertySyntax.IdentifierModifier.

A node grows by init-only property, not by a new parameter. A node gaining an optional value gains it as an init property on the record body, so the primary constructor and Deconstruct keep the exact signature they were published with. Positional construction you already wrote keeps compiling, with expressions reach the new value like any other, and it participates in equality and ToString the same way a parameter would.

Appending a trailing optional parameter instead looks equivalent and is not. It is source compatible and binary breaking: the compiler emits a different primary constructor and a different Deconstruct, so an assembly built against the previous version calls a member that no longer exists and fails at run time with a missing method - with no error at compile time anywhere. It has shipped once, Cratis.Arc.Screenplay built against 1.5.2 breaking against 1.7.0 exactly this way. Package validation now fails the build that would repeat it, so the guarantee is enforced rather than intended.

Authoring metadata follows this rule: SpecificationSyntax.Description and the Documentation members on modules, features, slices, commands, read models and reactions are nullable init-only properties. They appear in the syntax transport schema, not in the executable semantic model. Preserve them when rewriting a declaration.

SpecificationSyntax.GivenCallerPersona is an additive init-only reference with a name and location, distinct from GivenCaller. It is omitted from syntax JSON when absent, preserving untouched documents’ bytes. A reader that predates the persona member must reject the new member rather than silently interpret an unauthenticated caller. Effective syntax expands the reference and carries SpecificationValueOrigin.Persona with persona and policy provenance; it adds no ESM member or version.

ConceptSyntax.ValueNumbers is a nullable init-only collection of int32, not a positional constructor parameter. Absent numbers stay null and the syntax JSON omits valueNumbers, keeping existing documents’ bytes. Populated numbers parallel values in authored order; cardinality must match, numbers must be unique, and the concept must be an enum. Preserve the distinction between absent and populated numbers when rewriting. Positional pins retain the original executable bytes; other numbers select ESM v12 and become the optional canonical numbers member, parallel to ESM values. Consumers must explicitly admit that contract.

PropertySyntax.IsKey, QuerySyntax.ByParts, ReadsSyntax.ByParts, ProducesSyntax.Stream, ReactionSyntax.From and ReducerSyntax.From are additive init-only properties. Constructors and Deconstruct signatures stay unchanged. Missing members default to false, empty collections or null. Writers omit those defaults. Preserve authored key-part and mapping order when rewriting nodes.

ObserverFilterSyntax carries an event source name, optional stream name and location. Routes and filters select ESM v10. Explicit single read-model keys preserve ESM bytes; composite read-model keys remain refused with PLAY0268 (#599). See AST authoring.

ReactionSyntax.RunsAs is a nullable init-only ReactionIdentitySyntax, not a new constructor or Deconstruct parameter. ReactionIdentitySyntax(Kind, Roles, Location) is an additive node; its CLR Kind uses syntaxKind in typed syntax JSON to avoid the node discriminator. The walker gains VisitReactionIdentity, and generated transport and collection schemas describe the roles. Preserve RunsAs when rewriting a reaction. Old syntax JSON may omit the member; its default is null. A declared identity binds to the reaction’s runsAs member and selects ESM v10; omission preserves existing ESM bytes and revisions.

EventSyntax and ImportSyntax gain init-only Visibility (EventVisibility.Private by default) and nullable Origin. An origin-bearing declaration/import must have Public visibility. SliceSyntax gains nullable init-only Direction; its computed EffectiveDirection interprets an omitted Translate direction as inbound and is not serialized. Constructors and Deconstruct signatures are unchanged. Missing old JSON members default to Private/null/null, and writers omit these defaults, preserving legacy syntax bytes. Explicit Public, origin and direction values participate in structural equality and survive typed JSON and canonical printing.

C# SyntaxSchema owns the new optional fields; the generated transport/TypeScript mirrors are regenerated through the owning schema specification, not edited by hand, and the TypeScript parser reads and writes the same members. Any nondefault public-event metadata or explicit direction refuses semantic compilation with PLAY0268 (#481/#480); source authoring acceptance must not be confused with executable readiness. Preserve these fields when rewriting nodes.

Event-target projections and reducers (projection X => PublicEvent, #482) and source events captures (#483) add no AST member. A projection’s or reducer’s ReadModel text and a capture source’s Kind (events) and from settings carry them: whether => names an event or a read model is decided by resolution against the assembled model, never by the parser. CaptureEventsSource (C#) and consumedEvents (TypeScript) read the consumed events. Both refuse semantic compilation with PLAY0268.

ConceptAttributeSyntax.Name keeps the wire values pii and sensitive, even though source uses canonical pii/secret and accepts the personal alias. Scope, SpecialCategory and Criminal are additive init-only properties. Old JSON omissions default to null/null/false; writers omit those defaults to preserve existing syntax bytes. Preserve explicitly declared values when rewriting a concept. Compliance markers select ESM v13 and bind to optional SemanticConcept.Compliance; reasons remain report-only. Personal takes precedence over secret, and no secret scope is stored next to personal. Identity and enum compliance, and protected events with an explicit subject mark, are refused; see concepts.

EventStreamIdPartSyntax(Name, Type, Location) is additive. EventStreamSyntax.StreamIdParts contains declarations in identity-bearing declaration order. CommandStreamSyntax.StreamIdParts and SpecificationStreamSyntax.StreamIdParts contain PropertyMappingSyntax in authored mapping order. These init-only collections default to empty, including old JSON omissions; existing positional constructors and scalar members are unchanged. A nonempty parts list is mutually exclusive with StreamId. Part names are not workspace references; part types are ordinary type references. The walker visits parts, their types and route mappings. Composite stream ids are admitted by ESM v8. Models using them select that version; models without sources or routes retain their existing ESM version and bytes.

IdentityExpressionSyntax(Path, Location) is an additive expression kind for $identity.<path>. Path contains only the text after $identity.. VisitIdentityExpression is an additive virtual walker method. $context.identity.<path> keeps its existing ContextExpressionSyntax kind and path; the printer preserves both spellings. The two forms bind to the same executable expressions and add no ESM member or version. Syntax transport consumers must support the new kind or reject it explicitly.

ApplicationSyntax.Identity is a nullable init-only property, never a new positional parameter. IdentitySyntax carries typed IdentityDetailSyntax entries with claim, query, inline code or file source nodes. Absent identity metadata is omitted from syntax JSON, preserving existing documents’ bytes. The walker visits all source children; syntax schema and TypeScript transport mirrors include the new kinds. Preserve the property during rewrites. The block adds no ESM member or version; executable reads of declared details refuse with PLAY0268 until admitted (#600).

The positional parameter list. Inserting a parameter, removing one, promoting an optional one to required, or appending a new one is a breaking change. The first three break source and binary; appending breaks binary only, which is the worse of the two to discover. Any of them is allowed in a major release and is enumerated in the release notes when it happens. If you construct nodes yourself, prefer named arguments and object initializers; if you consume them, prefer property access over deconstruction.

Exhaustive switch over an abstract base. A switch on ExpressionSyntax or ProjectionBlockSyntax with an arm per known form compiles today and still compiles tomorrow, but a form added later falls through. C# cannot check exhaustiveness over a type hierarchy, so this is a silent behavior change, not a compile error. Always write a default arm, or override the walker’s base-kind method (VisitExpression, VisitProjectionBlock, …) so the fallback is handled for you.

A collection’s element types. IEnumerable<ProjectionBlockSyntax> can start yielding a form your code has never seen. Code that casts elements to a concrete type rather than pattern-matching them will fault.

Hand-written traversal. Nothing protects a consumer that walks the tree with its own loops. A node gaining a child collection is invisible to it - the tree grew and the consumer silently stopped covering the document. This is the concrete reason to prefer the walker: a hand-written walk has no place for the language to tell you it changed.

  1. Derive from ScreenplaySyntaxWalker rather than writing the loops.
  2. Override the narrowest methods that answer your question, and call base unless you mean to prune.
  3. Pattern-match node forms; never cast, and always have a fallback.
  4. Treat an unrecognized form as “not my concern”, not as an error.
  5. Read diagnostics by Code, never by message text.

A consumer that does these five things is affected by a Screenplay release only when a construct it actively handles changes shape.