Skip to content
Open
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
23 changes: 23 additions & 0 deletions Documentation/screenplay/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ validate
dueDate > today message "Due date must be in the future"
```

### Comparing against another property

An operand does not have to be a constant. A bare path resolves against the **sibling properties of the artifact being validated** - the command's own properties for a command rule - so the rule that matters most in a date range says exactly what it means:

```screenplay
validate
endDate >= startDate message "The end date cannot be before the start date"
```

`today` is a keyword, not a path: it evaluates to the current date when the rule runs. A property genuinely called `today` is written `@today`, using the same [keyword escape](grammar.md#keyword-escape) as everywhere else.

### Rules that only apply sometimes

A rule that holds only under a condition takes a `when` clause, using the same condition grammar as `produces when`:

```screenplay
validate
newEndDate > endDate when isExtension == true message "An extension has to move the end date out"
reason not empty when status == "cancelled" message "Cancelling requires a reason"
```

The condition comes after the rule and before the `message`, and it may combine comparisons with `and` and `or`. An unconditional rule is the same as it always was.

Cross-field or complex rules drop into C#:

````screenplay
Expand Down
16 changes: 14 additions & 2 deletions Documentation/screenplay/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Concepts are formalized value types that wrap a primitive. They give every domai
## Syntax

```screenplay
concept <Name> : <PrimitiveType> [<attributes>]
concept <Name> : <PrimitiveType> [@<attribute>[("<reason>")]]*
[validate ...]*

concept <Name> : Enum
Expand All @@ -24,11 +24,23 @@ concept <Name> : Enum
| `@pii` | The value is personally identifiable information. Chronicle manages it and can erase it for GDPR compliance. |
| `@sensitive` | The value is sensitive and handled under Chronicle's sensitivity rules. |

Any `@word` is accepted, so a consumer can introduce an attribute the compiler does not know about without a grammar change.

### Carry the reason, not just the flag

A compliance reader wants to know *why* a value is personal data - the purpose, the lawful basis, whose subject it lives under. Give the attribute a quoted argument and the document carries that reasoning instead of losing it:

```screenplay
concept BankAccountNumber : String @pii("Partner payout bank account - financial data. Remits self-billing payments; lawful basis: contract performance / legal obligation.")
```

The argument is optional on every attribute - a bare `@pii` stays exactly as valid as it has always been.

## Examples

```screenplay
concept InvoiceId : Uuid
concept EmailAddress : String @pii
concept EmailAddress : String @pii("Contact address for invoice delivery. Lawful basis: contract performance.")
concept NationalIdNumber : String @pii @sensitive
concept DateOfBirth : Date @pii
```
Expand Down
21 changes: 14 additions & 7 deletions Documentation/screenplay/grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ ConceptRule = RuleOp, [ "message", LocalizableString ], NL ;
PrimitiveType = "Uuid" | "String" | "Int" | "Decimal" | "Bool"
| "Date" | "DateTime" ;

Attribute = "@pii" | "@sensitive" ;
Attribute = "@", Ident, [ "(", StringLiteral, ")" ] ;

(* -------------------------------------------------------------- *)
(* Policies *)
Expand Down Expand Up @@ -183,7 +183,7 @@ ValidateDecl = "validate", NL,
INDENT, { ValidationRule }, DEDENT
| "validate", "csharp", NL, InlineBlock ;

ValidationRule = Ident, RuleOp, [ "message", LocalizableString ], NL ;
ValidationRule = Ident, RuleOp, [ "when", Condition ], [ "message", LocalizableString ], NL ;

RuleOp = "not empty"
| "max", Number
Expand All @@ -198,7 +198,9 @@ RuleOp = "not empty"
| "all", ">", Value
| "all", ">=", Value ;

Value = Number | StringLiteral | "today" | "true" | "false" ;
Value = Number | StringLiteral | "today" | "true" | "false" | PropertyPath ;

PropertyPath = [ "@" ], Ident, { ".", Ident } ;

(* -------------------------------------------------------------- *)
(* Produces *)
Expand Down Expand Up @@ -247,6 +249,7 @@ HandlerDecl = "handler", NL,

QueryDecl = "query", Ident, "=>", TypeRef, NL,
[ INDENT,
[ "observable", NL ],
[ ByClause ],
{ FilterClause },
[ AuthorizeDecl ],
Expand Down Expand Up @@ -335,10 +338,14 @@ ConstraintBody = "unique", Ident, "on", Ident, NL (* unique property *)
(* -------------------------------------------------------------- *)

ReactorDecl = "reactor", Ident, NL,
INDENT,
"on", Ident, NL,
( FileDirective | InlineBlock ),
DEDENT ;
INDENT, { ReactorTrigger }, DEDENT ;

ReactorTrigger = "on", Ident, NL,
[ INDENT,
{ "produces", Ident, NL },
{ "executes", Ident, NL },
[ FileDirective | InlineBlock ],
DEDENT ] ;

(* -------------------------------------------------------------- *)
(* Screens *)
Expand Down
16 changes: 15 additions & 1 deletion Documentation/screenplay/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,31 @@ Queries are read-side entry points. A query maps to a return type — a read mod

```screenplay
query <Name> => <ReturnType>[[]?]
[observable]
[by <paramName> <Type>]
[filter <paramName> <Type>?]
[authorize <PolicyName> [or <PolicyName>]*]
```

| Clause | Meaning |
| --- | --- |
| `by` | The identifying parameter — the query returns the instance it identifies. |
| `observable` | The query is live — a subscription that pushes updates rather than a one-shot read. |
| `by` | The identifying parameter — the query returns the instance it identifies. A query declares at most one. |
| `filter` | An optional parameter narrowing the result set. Filter types are typically optional (`?`). |
| `authorize` | The [policies](policies.md) that must pass. |

## Live or one-shot

Whether a query pushes updates changes what the reader has to build: a live query holds a subscription and re-renders as events land, a one-shot query answers once. Say which it is:

```screenplay
query ListInvoices => InvoiceListReadModel[]
observable
filter status InvoiceStatus?
```

An unmarked query is one-shot, so every document written before this marker existed keeps its meaning.

## Examples

A single-instance query identified by a parameter:
Expand Down
41 changes: 40 additions & 1 deletion Documentation/screenplay/reactors.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,53 @@ Reactors are event reaction rules — the "if this then that" of a Screenplay. T
```screenplay
reactor <Name>
on <EventType>
[produces <EventType>]
[executes <Command>]
[file <Path>]
[csharp
```
<C# returning event side effects>
```]
```

Each `on` clause names the event that triggers the reaction. The reaction body is either a `file` reference to an external C# implementation, or an inline `csharp` block. Inside the block, `@event` is the triggering event; returned events are appended as side effects.
Each `on` clause names the event that triggers the reaction. Everything under it is optional: what the reaction produces, and where its implementation lives - a `file` reference to an external C# implementation, or an inline `csharp` block. Inside the block, `@event` is the triggering event; returned events are appended as side effects.

## Say what the reaction does

`on` tells the reader what wakes the reactor up. `produces` and `executes` tell them what happens next - the output side of an automation slice, which is the whole reason the slice exists:

```screenplay
reactor AcceptedInvitationProvisioner
on InvitationToJoinAccepted
produces UserAccountProvisioned

reactor StockKeeping
on BookReserved
executes DecreaseStock
```

Both are repeatable, so one trigger can produce several events and execute several commands. `produces` names an event declared in the document (or imported); `executes` names a command. The compiler resolves both and warns about a name it cannot find, the same way `command produces` has always been checked.

Together with `on`, this closes the Event Modeling loop the document is meant to describe: state change → events → projections → automation → commands → state change.

## Declare the intent before the code exists

You write the document first and Stage performs it, so a reactor has to be sayable before anything implements it. A trigger with no body is a complete, valid statement of intent:

```screenplay
reactor AcceptedInvitationProvisioner
on InvitationToJoinAccepted
```

That reads as "when an invitation is accepted, this reactor runs" - which is what an author knows at modeling time. The `file` line arrives later, when the slice is implemented, and it never changes what the reactor *means*:

```screenplay
reactor AcceptedInvitationProvisioner
on InvitationToJoinAccepted
file Admin/Invitations/Provision/Provision.cs
```

This is the general rule across the language: **a document must be expressible and meaningful with zero `file` references.** `file` is attachable realization metadata on any construct that supports it. Hand-authored documents precede the code and gain `file` lines as slices are implemented; generated documents arrive with them already attached. Same language, two directions.

## Examples

Expand Down
20 changes: 20 additions & 0 deletions Documentation/screenplay/slices.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,26 @@ module Invoicing
| `reactor` | `Automation` | [Reactors](reactors.md) |
| `capture` | `Translate` | [Captures](captures.md) |

Every construct is repeatable - a slice declares as many events, commands, queries, projections, screens, reactors and captures as the behavior needs.

### More than one projection

A slice often needs a second read model that nobody outside the slice ever queries: the state its own command reads to make a decision, or the companion model that guards access to the first one. Each `projection` names the read model it builds, so there is nothing ambiguous about declaring several:

```screenplay
slice StateView CustomerPortalReport
query Report => PortalReport
...

projection PortalReport => PortalReport
...

projection RevokedCustomerPortalToken => RevokedCustomerPortalToken
...
```

Keeping the decision model beside the view model is how the slice stays the unit that changes together - splitting it into an artificial second slice describes an application that does not exist.

## Example

```screenplay
Expand Down
29 changes: 28 additions & 1 deletion Source/DotNET/Screenplay/Parsing/QueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,25 @@ public static QuerySyntax Parse(ParserContext context, SourceLine header)
QueryParameterSyntax? by = null;
var filters = new List<QueryParameterSyntax>();
AuthorizeSyntax? authorize = null;
var isObservable = false;

while (context.TryPeekChild(header.Indent, out var line))
{
context.Reader.TakeSignificant();
switch (LineText.FirstWord(line.Content))
{
case "by":
if (by is not null)
{
context.Error($"Query '{match.Groups[1].Value}' already declares a 'by' parameter - a query can have at most one", line.Location);
break;
}

by = ParseParameter(context, line, "by");
break;
case "observable":
isObservable = ParseObservable(context, line, isObservable, match.Groups[1].Value);
break;
case "filter":
if (ParseParameter(context, line, "filter") is { } filter)
{
Expand All @@ -57,7 +67,24 @@ public static QuerySyntax Parse(ParserContext context, SourceLine header)
}
}

return new(match.Groups[1].Value, returnType, by, filters, authorize, header.Location);
return new(match.Groups[1].Value, returnType, by, filters, authorize, header.Location, isObservable);
}

static bool ParseObservable(ParserContext context, SourceLine line, bool existing, string queryName)
{
if (line.Content != "observable")
{
context.Error($"Invalid observable declaration '{line.Content}' - expected 'observable'", line.Location);
context.SkipBlock(line.Indent);
return existing;
}

if (existing)
{
context.Error($"Query '{queryName}' already declares 'observable' - declare it at most once", line.Location);
}

return true;
}

static QueryParameterSyntax? ParseParameter(ParserContext context, SourceLine line, string keyword)
Expand Down
Loading
Loading