Skip to content

Projections in the semantic model

A projection is written once, in the Projection Declaration Language, and read by two consumers. Cratis Chronicle compiles it with the Screenplay compiler and runs it. The Screenplay semantic binder turns it into the executable semantic model (ESM) that Stage, rendered applications and the reference evaluator share. This page explains how the two stay in agreement and where the semantic model deliberately stops.

The grammar belongs to Screenplay, and Chronicle uses the Screenplay parser as it is. What a projection means - which instance an event affects, what a join may create, what a removal removes - is decided by Chronicle’s lowering (ProjectionDefinitionSyntaxVisitor), its validation (ProjectionValidator) and its engine. The semantic binder mirrors those rather than defining meaning of its own. Where these pages and Chronicle disagree, Chronicle is right and the page is wrong.

In practice that means:

  • Every block is legal at every level. Chronicle lowers the projection body, a children body and a nested body with one recursive switch and no per-level restrictions, so join, every, all, remove with, remove via join and clear with bind inside children and nested as well as at the top.
  • The default key is the event source. A from block without a key, and one with key $eventSourceId, are the same declaration. The key is resolved from the event’s inline key, then the block’s key, then the event source - never from a key written directly on the projection.
  • from A, B is one transition per event, and all of them share the block’s mappings.
  • clear x and x = null are one operation, and count is increment.
  • Arithmetic is typed by the read model. add and subtract convert both operands to the target property’s type before the operation - a whole-number target rounds half to even - and a target with no value yet starts at zero. There are no arithmetic operators; add … by and subtract … by are the whole arithmetic surface.
  • A source with no value sets the target to null.
  • Nested paths create what is missing. quantity.amount = amount creates quantity if it is absent; basis = quantity.basis reads through a composite event property.

A projection the original vertical can express - one or more from blocks keyed by an event property, setting read-model properties directly - keeps its flat shape, so everything that already reads it is unaffected:

projection ProjectSummaryProjection => ProjectSummary
from ProjectRegistered key projectId
name = name

Anything more - children, nested objects, joins, removals, every and all, variants, event-source, literal and composite keys, and every mapping kind - binds to a scoped shape. A scope holds one level’s transitions, joins, child collections, nested objects, every mappings, removals and join removals, and a child collection or nested object carries a scope of its own:

projection OrderDetails => OrderView
every
lastSeen = $eventContext.occurred
exclude children
from OrderPlaced key orderId
label = label
join customer on customerId
with CustomerRegistered
customerName = name
children lines identified by lineNumber
from LineAdded key lineNumber
parent orderId
add subtotal by amount
count quantity
remove with LineRemoved key lineNumber
parent orderId
nested shipping
from OrderShipped
carrier = carrier
clear with ShippingCleared
remove with OrderCancelled key orderId

What a block means depends on the level it sits in. remove with deletes the instance at the top, removes one child inside children, and clears a nested object back to null inside nested. Keys inside children identify the child, and parent identifies the parent - the event source when it is left out. Inside nested, keys keep addressing the enclosing instance, because a nested object lives in the same document.

SemanticAffectedProjectionInstances.GetAffectedInstances(projection) (or its application-aware overload for unset child identities) derives read-only SemanticAffectedProjectionInstance records for Studio. Each record identifies the event contract (or null for the open all subscription), block kind, path from the read-model root, and matching shape. Flat and scoped from transitions and ordinary removals affect one by key, potentially deferred until a parent exists. Root all addresses one by event source. A root join updates existing instances whose Property equals the event source id (never creates); a child join and child remove via join match across parents on the child’s identity (not the join’s on property). Path identifies child collections and nested objects; Key, FlatKey, Property and ParentKey retain the operands needed to draw the relationship. A root remove via join is reported as Unverified rather than as a root deletion: Chronicle wires it as a child pull with no child collection. Joins, children and remove-via-join inside nested are omitted because Chronicle does not subscribe to them. The projection-only overload reports an unset child identity as Unverified rather than guessing; the application-aware overload resolves the element property named like the ESM read-model identifier. Chronicle instead falls back to the root schema’s id/Id key property. The Screenplay rule can differ only for hand-built or deserialized ESM, since the binder always supplies identified by. The view is derived, not authored, and never enters canonical ESM bytes. Reducers are keyed by event source only (decision 0002), not by a per-event list or an authored reducer key.

ZeroOrOne and Many on legacy flat projection transitions have no Chronicle routing meaning. A constructed or deserialized ESM exposes warning PLAY0445 through ExecutableSemanticModel.DeprecationDiagnostics; the binder only creates One. These cardinalities remain in canonical ESM until the next ESM version. SemanticQueryCardinality is unchanged. A variant root join currently carries a key that the reference evaluator honours but Chronicle ignores in favour of the event source id; see Cratis/Chronicle#4165.

A variant group binds as one independent scoped projection per variant read model. Only the variant’s enters on events can create an instance. Shared projection-level handlers merge before reclassification: all other from handlers become update-only joins correlated on the variant’s identifier using the handler’s event key (or event source identity). Sibling entering events remove the variant’s instance by event source identity. The projection’s ESM name/address is length-prefixed (8:WorkItem:BacklogItem) to keep each variant’s semantic identity distinct; the read model is simply BacklogItem. This mirrors Chronicle’s client SDK reclassifier. Chronicle’s textual declaration-language visitor does not yet lower variants (Cratis/Chronicle#4109).

A join never creates an instance. At the root, when a joined event arrives, every existing instance whose on property equals the joined event’s source is updated. Inside children, the child’s identity is compared to the join key instead of on. At the root, when a from event sets the on property (or the root key), the latest joined event is read again, so a customer registered before the order still names it. Inside children, that backfill happens only when on equals the child’s identity. The identifier written directly after join is not part of the definition - Chronicle’s lowering discards it.

every mappings run with each from and join event of their level. all does the same and additionally reaches event types no block names, keyed by the event source. Only a projection’s own level can subscribe to every event type: Chronicle drops that flag below it, so all inside children or nested binds as every and reports warning PLAY0380. The same warning reports an automap or no automap written on a joined event, which Chronicle replaces with the auto-map of the level the join sits in.

new SemanticEvaluator().EstablishWorld(plan, facts) takes a capability-admitted SemanticExecutionPlan and an ordered ImmutableArray<SemanticFact>. It validates the entire fact history against the plan’s event contracts before projecting it, then returns a SemanticAccepted whose World.Facts preserves occurrence order and whose World.ReadModels contains derived keyed instances in deterministic semantic-identity/key order. The accepted result carries the supplied facts, no query results and no command response: world establishment executes no command. Accepted command execution may carry an ESM v7 response; only acceptance carries it. An empty array establishes an empty world. A default array, unknown event, malformed occurrence or payload returns SemanticRejected with category Contract and an unchanged empty world. ESM v2/v3 facts require a nonempty typed event-source identity matching the event’s unambiguous declared producer destination type; ESM v1 facts may omit occurrence context; when a v1 fact supplies one, it must match the declared producer destination type too. Every tag must be nonempty text. A projection failure returns SemanticUnsupported with capability Projection. The public operation also returns Unsupported when a supplied event affects a reducer-backed read model: reducer transitions are opaque and cannot be replayed by the reference evaluator. Authored given events in specification runs share the validation and projection core, but retain two compatibility rules: a given event without an event source keeps its legacy null destination even in v2/v3, and observing an event alone does not make a specification unsupported unless it asserts or queries reducer-backed state. Supplied event sources still undergo declared-type validation. Explicit given read-model overrides are applied after establishment.

A child event whose parent does not yet exist stays pending in the reference world rather than failing the specification. After a later event projects, the evaluator retries pending children in arrival order until no more parents can be found. Resolving one child can make its grandchildren resolvable in the same pass. Mappings use the original event values and event-source context, including ancestor every mappings unless they exclude children. Pending children survive from given into when and across accepted commands. A parent that never arrives leaves its child absent; an ambiguous parent still fails closed.

When a flat root and child both handle the same event type, the evaluator uses the root handler’s resolved key as the child’s parent, matching Chronicle’s current-event fallback. It does not implement the earlier-child-creation branch of FindParentEvent (Source/Kernel/Core/Projections/Engine/KeyResolvers.cs:389-415 in Chronicle). For a shared event that updates a child created by a different event type, Chronicle can resolve that child’s existing location through TryResolveViaCreationEventSource; the evaluator still uses the current parent handler’s key. Do not use the evaluator as a conformance oracle for that self-referential shared-event shape.

This is reference semantics, not Chronicle replay. Chronicle’s lowering, validation and engine decide runtime meaning (decision 0001); use Chronicle when rebuilding runtime state. This operation does not append facts to an existing world or execute reactions.

Executable semantic model v8 admits application-owned sources and streams, command routes, and specification routes. A routed SemanticFact carries Route with the stored sourceKind, streamKind, and the canonical encoded streamId when keyed. Stored names use the declaration’s id pin when present, otherwise its name. Unrouted facts carry no route; the model never fills in Chronicle’s Default, All, "Default" triple. Exactly Default is reserved as a stored source name.

World establishment resolves each supplied route to one declared source and its owned stream, strictly decodes its scalar or composite key, and validates the event-source identity against the source’s declared identifier type. If the source has no identifier type, exactly one unambiguous producer destination type supplies the fallback. Routed history needs no producer when its source declares an identifier. Unknown, foreign, noncanonical or mistyped routes are contract rejections before projections observe them. A route classifies facts; it does not filter projections or change their keys, constraints, reducers, reactions or queries.

Command routes resolve once after declarative validation and requirements, before generation and productions. Formatting-only failures reject the failing command as Contract without allocation, append or response; authorization and validation retain precedence. Non-NFC or ill-formed direct inputs already fail request type validation. Arc computes stream ids before authorization, so its native ordering is not equivalent: a renderer must preserve this portable precedence. A later cascade failure keeps already accepted facts. See event sources and streams for mapping and formatting rules.

Catalog identities survive source/stream renames and file relocation. A source rename migrates its owned streams atomically, but an unpinned rename changes its stored name. Pin id before renaming a declaration with stored events. Models without event routes retain their existing versions, bytes and outcomes; consumers must explicitly admit the ESM v8 event routes contract rather than ignore its members.

Each of these is reported with a precise message rather than bound and ignored:

ConstructWhy
$causedByChronicle compiles it but cannot run it. Write $eventContext.causedBy.subject, .name or .userName instead.
$eventContext.<path> for a path Chronicle’s event context does not haveChronicle would throw when it builds the projection. The binder admits a small set of scalar paths until a shared event-context catalog replaces it.
String templates and raw expressions in mappings and keysThe semantic model has no portable string-template expression.
A number or Boolean literal keyChronicle reads any key literal other than text as a property path. Write key literal "…".
parent outside childrenChronicle never reads it there.
A second every or all on one levelChronicle keeps only the last at the top and merges them below; the semantic model admits one.
sequenceWhich event sequence a projection observes is a realization concern, not portable behavior.
Dynamic dictionary keys such as counts.$eventContext.eventType.idThe semantic model has no dictionary-keyed target.

Everything that binds is either executed by the reference evaluator or blocks its execution plan - it is never bound and then skipped. The evaluator does not execute a remove via join on a projection’s own level (Chronicle’s engine wires it as a child removal although these pages promise a delete), a join, child collection or remove via join inside a nested object (Chronicle’s engine does not wire them), all beside removals or child levels, or event-context values other than the event source, which ESM v1 facts carry no occurrence context for. Specification events carry no event source in ESM v1 either, so a specification that builds an event-source-keyed projection from given events fails instead of guessing an identity.