Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
---
id: 0004
title: Admission and governance of portable executable semantics
status: proposed
status: accepted
stage: none
decided: 2026-09-24
decider: Sindre Alstad Wilting
class: contract
reversibility: costly
applies-to:
Expand Down Expand Up @@ -82,3 +84,7 @@ Adding a construct costs more: vectors, an unsupported path and a record when a
## Related issues

Screenplay: [#128](https://github.com/Cratis/Screenplay/issues/128), [#135](https://github.com/Cratis/Screenplay/issues/135), [#136](https://github.com/Cratis/Screenplay/issues/136), [#139](https://github.com/Cratis/Screenplay/issues/139), [#167](https://github.com/Cratis/Screenplay/issues/167), [#218](https://github.com/Cratis/Screenplay/issues/218). Chronicle: [#4130](https://github.com/Cratis/Chronicle/issues/4130).

## Status notes

**2026-09-24 — accepted.** Accepted as written, including the two rules this record inferred from v2 and v3 rather than from #128: a construct that previously failed binding may join the highest existing ESM version when no model that bound before changes bytes, and each release that adds an ESM version opens a tracking issue in Stage, CLI, Studio and Generation. The decision text is unchanged. It stays at `stage: none` because its *Done when* is not met: ESM v3 has no source-backed `Cratis.Screenplay.CanonicalCorpus` vector yet.
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
---
id: 0005
title: "Policy predicates as an implementation attachment role, composed in authored order"
status: proposed
stage: none
status: accepted
stage: implemented
decided: 2026-09-24
decider: Sindre Alstad Wilting
class: contract
reversibility: costly
applies-to:
Expand Down Expand Up @@ -76,3 +78,7 @@ Models with code policies reach the ESM, and their unrelated specifications run.
## Related issues

Screenplay: [#139](https://github.com/Cratis/Screenplay/issues/139). Decisions: [0001](0001-chronicle-runtime-semantic-authority.md), [0002](0002-implementation-attachments-envelope-and-reducer-role.md), [0004](0004-admission-and-governance-of-portable-executable-semantics.md).

## Status notes

**2026-09-24 — accepted and implemented.** Accepted as written; the decision text is unchanged. The references above to what decision 0004 "proposes" now point at an accepted record. `feature/policy-predicate-role` merged as [#251](https://github.com/Cratis/Screenplay/pull/251) and shipped in v4.29.0, with the specs named under *Verification* in the tree. It is not yet `verified`.
73 changes: 73 additions & 0 deletions decisions/0006-reaction-triggers-declare-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
id: 0006
title: Reaction triggers declare the views they decide from with reads
status: accepted
stage: none
decided: 2026-09-24
decider: Sindre Alstad Wilting
class: contract
reversibility: costly
applies-to:
- Source/DotNET/Screenplay/Syntax/ReactionSyntax.cs
- Source/DotNET/Screenplay/Parsing/ReactionParser.cs
- Source/DotNET/Screenplay/Parsing/TriggerParser.cs
- Source/DotNET/Screenplay/Parsing/ReadsParser.cs
- Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs
- Source/DotNET/Screenplay/Semantics/SemanticModelBinder.SliceMembers.cs
- Documentation/screenplay/reactions.md
- Documentation/screenplay/grammar.md
- Source/Screenplay/Monaco/**
- Source/Screenplay/VSCodeExtension/**
---

## Context

An automation decides from state it consults: in Event Modeling, a process watches a to-do view and acts on each item. [#69](https://github.com/Cratis/Screenplay/issues/69) asks for that relationship. A reaction can already start from an event, a declared trigger or the clock, and can `produces` or `invokes`, but it cannot name the views behind its decision. `ReactionTriggerSyntax` has no reads member ([`ReactionSyntax.cs:44-52`](../Source/DotNET/Screenplay/Syntax/ReactionSyntax.cs)), so the inputs of an automation are invisible in the document.

Commands already have `reads <View> [as <alias>] [by <prop>]`. [Decision 0003](0003-decision-consistency-for-command-reads.md) makes a command's reads a protected decision dependency and rejects giving one keyword two meanings ([0003, options](0003-decision-consistency-for-command-reads.md#options-considered)). Reactions do not bind in the executable semantic model (ESM) today: every reaction fails with "requires portable occurrence and effect semantics" ([`SemanticModelBinder.SliceMembers.cs:93`](../Source/DotNET/Screenplay/Semantics/SemanticModelBinder.SliceMembers.cs)).

Today a line `reads X` under a trigger parses as a trigger value named `reads` of type `X`: the trigger body falls through to `TriggerParser.ParseData`, which accepts any property line ([`ReactionParser.cs:175-178`](../Source/DotNET/Screenplay/Parsing/ReactionParser.cs), [`TriggerParser.cs:87-89`](../Source/DotNET/Screenplay/Parsing/TriggerParser.cs)).

## Decision

A reaction trigger may declare `reads <View> [as <alias>] [by <trigger value>]`, with the single meaning `reads` has on commands: the state the behavior decides from.

1. **Syntax.** `reads` is a trigger body line, parsed by the existing `ReadsParser` into the existing `ReadsSyntax`. The alias rules are the command rules: a second read of the same view needs an alias, and aliases are unique per trigger.
2. **`by`.** On a reaction, `by` names a value the trigger takes. Clock triggers (`every`, `at`) take no values, so they can only read a whole view.
3. **Protection.** A reaction that `invokes` a command decides nothing itself; the command decides, and the command's own reads carry decision 0003's protection. A reaction that `produces` directly is held to decision 0003's rule once reactions bind: its reads are protected only where Chronicle can check them exactly, and otherwise binding fails with a diagnostic that names the reason.
4. **Reserved form.** `for each <View>` is reserved for a later view-driven trigger. It is not admitted by this record.
5. **Compatibility.** A trigger value named `reads` must now be written `@reads`, the escape the parser already applies to other directive names ([`ReactionParser.cs:172-174`](../Source/DotNET/Screenplay/Parsing/ReactionParser.cs)).

## Options considered

- **`reads` on the trigger with one meaning (taken).** It states the automation's inputs with the word commands already use for the same thing, and it does not foreclose a view-driven trigger, which would still want to name what it reads ([#69 sweep comment](https://github.com/Cratis/Screenplay/issues/69)).
- **Report-only `reads` on reactions.** Not taken: `reads` on commands becomes protected under decision 0003, so a report-only reaction `reads` would give one keyword two meanings.
- **A different word such as `uses`.** Not taken: `uses` already attaches UI behaviors to screens ([`ScreenParser.cs:73-76`](../Source/DotNET/Screenplay/Parsing/ScreenParser.cs)), and a second word for the same relationship is what decision 0003 avoids.
- **The view as the trigger (`when <View>`).** Not taken: `when` resolves against events, declared triggers and registered triggers ([`triggers.md`, "How a name resolves"](../Documentation/screenplay/triggers.md#how-a-name-resolves)), so views would be a fourth set with event/view name collisions. It also misstates the runtime: Chronicle reports a read-model change as `Added`, `Modified` or `Removed` ([`ReadModelChangeType.cs:9-25`](https://github.com/Cratis/Chronicle/blob/main/Source/Clients/DotNET/ReadModels/ReadModelChangeType.cs)), not as "the view has rows".
- **Wait for the view-driven trigger.** Not taken: the automation's inputs stay unwritable in the meantime, and nothing in the view-driven design depends on leaving them out.

## Default if unanswered

Reactions keep hiding their inputs. `reads X` under a trigger keeps parsing as a trigger value, so authors who write it get a silent misreading rather than a diagnostic. Studio and Stage cannot draw or realize the view-to-automation edge.

## Timeline and scope

Settle before any reaction grammar work under #69, and keep it until superseded. The grammar is additive apart from the `@reads` escape and can ship first. Protection for `produces` ships only when reactions bind in the ESM, under decision 0003's rule and decision [0004](0004-admission-and-governance-of-portable-executable-semantics.md)'s admission gates.

In scope: the trigger `reads` grammar in the parser, syntax tree, printer, walker, TextMate and Monaco grammars; the alias and unknown-view checks; the `by`-against-trigger-values check; the `@reads` compatibility note in `reactions.md`; the protection rule stated above.

Out of scope: a view-driven trigger (`for each <View>`); business due time (`due at`) and terminal-fact or cancellation semantics, which are deferred because neither Chronicle nor Arc has a scheduling primitive to mirror, Chronicle's read-model reactors are best-effort with no retries or ordering guarantee ([`reacting-to-changes.mdx:56-65`](https://github.com/Cratis/Chronicle/blob/main/Documentation/read-models/reacting-to-changes.mdx)), and the work waits for Stage to decide how view-driven automation is realized and for the logical clock in [#87](https://github.com/Cratis/Screenplay/issues/87); binding reactions in the ESM; `where` over read paths; Stage's rendering of trigger reads.

## Verification

**Done when:** `reads <View> as <alias> by <value>` under an event or named trigger parses into `ReactionTriggerSyntax` with a `ReadsSyntax` entry, and round-trips through the printer. Under a clock trigger, `reads <View>` parses and `reads <View> by <x>` is a diagnostic. A `by` that names no trigger value, an unknown view, a missing alias on a repeated view and a duplicate alias are diagnostics. `@reads X` still parses as a trigger value named `reads`. `reactions.md` and `grammar.md` describe the clause and the escape.

**Verify by:** Parser, validator and printer specs for each case above, and an editor-grammar check that `reads` is highlighted under a trigger. When reactions bind, binder specs show a `produces` reaction's read is either protected under decision 0003 or rejected with the reason.

## Consequences

An automation's inputs become visible to readers, Studio and renderers, using the same word and shape as commands. Protection for reactions comes for free through invoked commands and is never weaker than decision 0003 for direct `produces`. Documents that used a trigger value named `reads` need the `@reads` escape. Due time, terminal facts and a view-driven trigger remain open, and `for each` is kept free for them.

## Related issues

Screenplay: [#69](https://github.com/Cratis/Screenplay/issues/69), [#87](https://github.com/Cratis/Screenplay/issues/87), [#129](https://github.com/Cratis/Screenplay/issues/129). Decisions: [0003](0003-decision-consistency-for-command-reads.md), [0004](0004-admission-and-governance-of-portable-executable-semantics.md).
81 changes: 81 additions & 0 deletions decisions/0007-affected-read-model-instances.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
id: 0007
title: Which read-model instances an event affects follows Chronicle's keys and joins
status: accepted
stage: none
decided: 2026-09-24
decider: Sindre Alstad Wilting
class: contract
reversibility: costly
applies-to:
- Source/DotNET/Screenplay/Semantics/SemanticBehaviors.cs
- Source/DotNET/Screenplay/Semantics/ExecutableSemanticModel.cs
- Source/DotNET/Screenplay/Semantics/SemanticProjectionScopes.cs
- Source/DotNET/Screenplay/Semantics/SemanticModelBinder.Projections.cs
- Source/DotNET/Screenplay/Semantics/Execution/SemanticExecutionPlan.cs
- Source/DotNET/Screenplay/Semantics/Execution/SemanticScopedProjection.cs
- Source/DotNET/Screenplay/Semantics/Serialization/**
---

## Context

[#132](https://github.com/Cratis/Screenplay/issues/132) asks which read-model instance, or instances, an event affects, and suggests a key-list form such as `on CustomersShared keys customerIds`. The executable semantic model (ESM) already carries `SemanticAffectedInstance` with a cardinality of `One`, `ZeroOrOne` or `Many` ([`SemanticBehaviors.cs:328`](../Source/DotNET/Screenplay/Semantics/SemanticBehaviors.cs)). The validator accepts all three, reading `Many` as a collection-typed key and `ZeroOrOne` as an optional key ([`ExecutableSemanticModel.cs:640-645`](../Source/DotNET/Screenplay/Semantics/ExecutableSemanticModel.cs)); the binder only ever produces `One` ([`SemanticModelBinder.Projections.cs:107`](../Source/DotNET/Screenplay/Semantics/SemanticModelBinder.Projections.cs)); the evaluator rejects anything else ([`SemanticExecutionPlan.cs:169-172`](../Source/DotNET/Screenplay/Semantics/Execution/SemanticExecutionPlan.cs)). Both values are pinned in the golden vectors.

Under [decision 0001](0001-chronicle-runtime-semantic-authority.md), Chronicle defines the meaning:

- A key resolver returns a resolved key, a deferred key (retried once the parent exists) or an unresolvable key. There is no list-of-keys outcome ([`KeyResolverResult.cs:12-35`](https://github.com/Cratis/Chronicle/blob/main/Source/Kernel/Core/Projections/Engine/KeyResolverResult.cs)).
- "Many" exists only structurally. A root join updates every existing document whose join property equals the value, with `UpdateMany` and no upsert ([`ChangesetConverter.cs:354-401`](https://github.com/Cratis/Chronicle/blob/main/Source/Kernel/Storage.MongoDB/Sinks/ChangesetConverter.cs), [`Sink.cs:164-172`](https://github.com/Cratis/Chronicle/blob/main/Source/Kernel/Storage.MongoDB/Sinks/Sink.cs)). A child join matches children across parents. Remove-via-join pulls the child from every document ([`Sink.cs:662-674`](https://github.com/Cratis/Chronicle/blob/main/Source/Kernel/Storage.MongoDB/Sinks/Sink.cs)).

## Decision

Affected instances mirror Chronicle. No new syntax is added.

1. **`from` affects one instance.** A `from` transition affects exactly one instance, possibly deferred until its parent exists. A key never resolves to a list, so a key-list syntax is rejected.
2. **"Many" is structural.** It is expressed only by the scoped shapes the ESM already binds (`SemanticProjectionJoin`, `SemanticProjectionJoinRemoval` in [`SemanticProjectionScopes.cs`](../Source/DotNET/Screenplay/Semantics/SemanticProjectionScopes.cs)):
- a root join updates every existing instance whose join property equals the event source id, and never creates one;
- a child join updates matching children across parents;
- remove-via-join pulls the child from every document.
3. **Transition cardinality.** `ZeroOrOne` and `Many` on a projection transition have no Chronicle meaning. They get a deprecation diagnostic now, and are removed from canonical bytes in the next ESM version, following [decision 0004](0004-admission-and-governance-of-portable-executable-semantics.md). Query cardinality (`SemanticQueryCardinality`) is unaffected.
4. **Derived view for Studio.** Screenplay derives a read-only affected-instance view per (event, block) from the projection scope: one by key (`from`, removal); one per event source (`all`); many where a property equals the event source id (root join); many where the child identity equals the key, across parents (child join, remove-via-join). It is derived, not authored, and adds nothing to canonical bytes.
5. **Reducers.** Reducers are keyed by the event source only, as [decision 0002](0002-implementation-attachments-envelope-and-reducer-role.md) settled.

## Known differences to resolve

- **Chronicle, root join key.** A root join resolves its key as the event source id and ignores the join's key expression ([`ProjectionFactory.cs:969-981`](https://github.com/Cratis/Chronicle/blob/main/Source/Kernel/Core/Projections/Engine/ProjectionFactory.cs)), although the .NET client's variant support sets one. Tracked in [Cratis/Chronicle#4165](https://github.com/Cratis/Chronicle/issues/4165).
- **Chronicle, in-memory sink.** It applies a root join to the one document it resolved ([`InMemorySink.cs:430-432`](https://github.com/Cratis/Chronicle/blob/main/Source/Kernel/Storage.InMemory/Sinks/InMemorySink.cs)) instead of fanning out as the MongoDB and SQL sinks do. Tracked in Chronicle#4165.
- **Reference evaluator.** It matches Chronicle's join fan-out ([`SemanticScopedProjection.cs:144-163`](../Source/DotNET/Screenplay/Semantics/Execution/SemanticScopedProjection.cs)) with two exceptions: it honors the key of a variant join ([`SemanticModelBinder.Variants.cs:108-110`](../Source/DotNET/Screenplay/Semantics/SemanticModelBinder.Variants.cs)) where Chronicle uses the event source id, and it fails a child event with no parent ([`SemanticScopedProjection.cs:265-271`](../Source/DotNET/Screenplay/Semantics/Execution/SemanticScopedProjection.cs)) where Chronicle defers it.

Neither Chronicle difference was reproduced at runtime; both come from reading the code.

## Options considered

- **Mirror Chronicle; many is structural (taken).** Every construct has a runtime counterpart, and the ESM stops accepting states the evaluator and Chronicle cannot run.
- **Key-list syntax (`keys customerIds`).** Not taken: Chronicle has no construct that resolves one event to a list of keys, so it would be invented semantics, which decision 0001 rules out.
- **Keep `ZeroOrOne`/`Many` as reachable transition cardinalities.** Not taken: the validator accepts shapes nothing can execute. `ZeroOrOne` only corresponds to deferral, which is operational and not something a modeler writes.
- **Remove them from canonical bytes immediately.** Not taken: it changes existing bytes, which decision 0004 allows only in a new ESM version.

## Default if unanswered

The validator keeps accepting cardinalities the evaluator rejects, #132 keeps inviting a key-list syntax with no runtime, and Studio has no data to show which instances an event touches.

## Timeline and scope

Settle before any #132 syntax or Studio visualization work, and keep it until superseded. The diagnostic and the derived view ship first; byte removal ships with the next ESM version.

In scope: the deprecation diagnostic, removal in the next ESM version, the derived affected-instance view, the reducer keying statement, and specs for the known differences.

Out of scope: new projection syntax; Chronicle runtime changes (owned by Chronicle#4165); Studio's rendering of the view; reducers keyed other than by event source.

## Verification

**Done when:** A model whose transition carries `ZeroOrOne` or `Many` gets a deprecation diagnostic, and the next ESM version's golden vectors no longer contain them on transitions. The derived view returns the four shapes above for a model with `from`, `all`, a root join, a child join and a remove-via-join. A spec shows one child-join event updating children under two parents.

**Verify by:** Validator and binder specs for the diagnostic; specs for the derived view per shape; the child-join spec against `SemanticScopedProjection`; golden-vector diff on the version bump, with a `Decision: 0004` and `Decision: 0007` trailer.

## Consequences

The ESM says only what Chronicle can do, and "many" is visible through the derived view without new syntax. Authors who expect a key list must model a join. The byte removal costs a version bump. The known differences remain open until Chronicle#4165 and the evaluator's deferral gap are resolved.

## Related issues

Screenplay: [#132](https://github.com/Cratis/Screenplay/issues/132), [#128](https://github.com/Cratis/Screenplay/issues/128). Chronicle: [#4165](https://github.com/Cratis/Chronicle/issues/4165).
Loading
Loading