diff --git a/Directory.Build.props b/Directory.Build.props
index 302df1c..b0f4508 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -7,6 +7,18 @@
true
+
+ true
+ 2.0.0
+
True
True
True
diff --git a/Source/Contracts/Commands/AuthorizationRequirement.cs b/Source/Contracts/Commands/AuthorizationRequirement.cs
new file mode 100644
index 0000000..ad474df
--- /dev/null
+++ b/Source/Contracts/Commands/AuthorizationRequirement.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json.Serialization;
+
+namespace Cratis.Stage.Contracts.Commands;
+
+///
+/// Represents what a command's authorize declaration requires of the caller — a policy, or policies combined.
+///
+///
+/// A tree rather than a flat list of policy names, deliberately. A set of names cannot distinguish
+/// A or B and C from (A or B) and C, so a consumer deciding whether a caller is allowed cannot
+/// answer from one — which is what Screenplay#68
+/// was about. Use for the flat set when only the names are needed.
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
+[JsonDerivedType(typeof(PolicyReference), "policy")]
+[JsonDerivedType(typeof(LogicalRequirement), "logical")]
+public abstract record AuthorizationRequirement
+{
+ ///
+ /// Gets every policy the requirement names, in the order they appear.
+ ///
+ /// The names of the policies held anywhere in the requirement.
+ ///
+ /// Derived from the tree rather than stored beside it, for a consumer that only needs to know which policies
+ /// are involved — resolving them, listing them — and not how they combine.
+ ///
+ public IEnumerable Policies() =>
+ this switch
+ {
+ PolicyReference reference => [reference.Policy],
+ LogicalRequirement logical => logical.Left.Policies().Concat(logical.Right.Policies()),
+ _ => []
+ };
+}
+
+///
+/// Represents a reference to a single named policy the caller must satisfy.
+///
+/// The name of the referenced policy.
+public record PolicyReference(string Policy) : AuthorizationRequirement;
+
+///
+/// Represents two authorization requirements combined with and or or.
+///
+/// The left hand requirement.
+/// The operator combining the requirements.
+/// The right hand requirement.
+public record LogicalRequirement(
+ AuthorizationRequirement Left,
+ ProducedEventLogicalOperator Operator,
+ AuthorizationRequirement Right) : AuthorizationRequirement;
diff --git a/Source/Contracts/Commands/CommandDefinition.cs b/Source/Contracts/Commands/CommandDefinition.cs
index 26f40de..ced617e 100644
--- a/Source/Contracts/Commands/CommandDefinition.cs
+++ b/Source/Contracts/Commands/CommandDefinition.cs
@@ -18,6 +18,13 @@ namespace Cratis.Stage.Contracts.Commands;
/// The name of the command property whose value identifies the event source the command
/// appends to, or when the command declares none - in which case every execution opens a
/// stream of its own. At most one property can be the identifier.
+///
+/// Capability added after the record shipped is an init property rather than a trailing parameter of the
+/// primary constructor, deliberately. A trailing parameter on a positional record is source compatible and
+/// binary breaking: it replaces the constructor and Deconstruct in the compiled signature, so a
+/// package built against the previous version fails at run time with a missing method and no compiler error
+/// anywhere. Package validation now fails the build on that, and is how this record should grow from here.
+///
public record CommandDefinition(
Guid Id,
string Name,
@@ -26,4 +33,21 @@ public record CommandDefinition(
IReadOnlyList Rules,
string LogicDescription,
IReadOnlyList Produces,
- string? Identifier = null);
+ string? Identifier = null)
+{
+ ///
+ /// Gets what the caller must satisfy to execute the command, or when the command
+ /// declares no authorization and anyone may execute it.
+ ///
+ public AuthorizationRequirement? Authorization { get; init; }
+
+ ///
+ /// Gets the conditions the command as a whole must satisfy — the modeled require rules.
+ ///
+ public IReadOnlyList Requirements { get; init; } = [];
+
+ ///
+ /// Gets the read models the command consults before it decides — the modeled reads declarations.
+ ///
+ public IReadOnlyList Reads { get; init; } = [];
+}
diff --git a/Source/Contracts/Commands/ProducedEvent.cs b/Source/Contracts/Commands/ProducedEvent.cs
index 1d2b9ea..210d0a7 100644
--- a/Source/Contracts/Commands/ProducedEvent.cs
+++ b/Source/Contracts/Commands/ProducedEvent.cs
@@ -14,4 +14,11 @@ public record ProducedEvent(
string Event,
ProducedEventCondition? When,
IReadOnlyList Properties,
- IReadOnlyList Tags);
+ IReadOnlyList Tags)
+{
+ ///
+ /// Gets where the event source this event is appended to comes from — the modeled for clause — or
+ /// when the event lands on the command's own event source.
+ ///
+ public ProducedEventSource? For { get; init; }
+}
diff --git a/Source/Contracts/Commands/ProducedEventSource.cs b/Source/Contracts/Commands/ProducedEventSource.cs
new file mode 100644
index 0000000..726756e
--- /dev/null
+++ b/Source/Contracts/Commands/ProducedEventSource.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Stage.Contracts.Commands;
+
+///
+/// Represents where the event source a produced event is appended to comes from — the modeled
+/// produces … for clause.
+///
+/// Where the value comes from.
+/// The source, interpreted according to — a command property name,
+/// JSON literal text, an identity path, an environment variable name or a template.
+///
+/// Carries no property name, unlike : the value identifies the stream the event
+/// is appended to rather than filling a property of its payload. A produces with none of these lands on the
+/// command's own event source, which is the common case and stays unstated.
+///
+public record ProducedEventSource(ProducedValueKind Kind, string Expression);
diff --git a/Source/Contracts/Commands/ReadsDefinition.cs b/Source/Contracts/Commands/ReadsDefinition.cs
new file mode 100644
index 0000000..807ef89
--- /dev/null
+++ b/Source/Contracts/Commands/ReadsDefinition.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Stage.Contracts.Commands;
+
+///
+/// Represents a reads declaration on a command — the read model the command consults before it decides.
+///
+/// The name of the read model the command reads.
+/// The command property the read model is looked up by, or when the read
+/// model is not keyed — a single view the whole application shares rather than one instance per identifier.
+public record ReadsDefinition(string ReadModel, string? By);
diff --git a/Source/Contracts/Commands/Requirement.cs b/Source/Contracts/Commands/Requirement.cs
new file mode 100644
index 0000000..7f3f172
--- /dev/null
+++ b/Source/Contracts/Commands/Requirement.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Stage.Contracts.Commands;
+
+///
+/// Represents a require rule on a command — a condition the command as a whole must satisfy, rather than a
+/// rule about one of its properties.
+///
+/// The condition that must hold.
+/// The message reported when it does not, or when none is declared.
+///
+/// Carries the same tree a produces when clause carries, so and
+/// and or mean here exactly what they mean there. A property rule says something about one value; a
+/// requirement says something about the command as a whole — most often against state it
+/// reads.
+///
+public record Requirement(ProducedEventCondition Condition, string? Message);
diff --git a/Source/Contracts/EventModel.schema.json b/Source/Contracts/EventModel.schema.json
index bd2ed22..b52f78b 100644
--- a/Source/Contracts/EventModel.schema.json
+++ b/Source/Contracts/EventModel.schema.json
@@ -78,7 +78,8 @@
"SourceEventId": { "type": "string", "description": "Empty when the event is owned by this slice; otherwise the id of the event this one references (a State Change slice's event referenced by a consumer slice)." },
"Schema": { "type": "string", "description": "The event payload as a JSON Schema string." },
"UniqueEventTypeConstraint": { "oneOf": [{ "$ref": "#/$defs/UniqueEventTypeConstraint" }, { "type": "null" }] },
- "UniqueConstraint": { "oneOf": [{ "$ref": "#/$defs/UniqueConstraint" }, { "type": "null" }] }
+ "UniqueConstraint": { "oneOf": [{ "$ref": "#/$defs/UniqueConstraint" }, { "type": "null" }] },
+ "Tags": { "type": "array", "description": "The tags declared on the event, applied to every append of it.", "items": { "type": "string" } }
},
"additionalProperties": false
},
@@ -93,7 +94,43 @@
"Rules": { "type": "array", "items": { "$ref": "#/$defs/CommandPropertyRules" } },
"LogicDescription": { "type": "string" },
"Produces": { "type": "array", "description": "The events the command appends, in declaration order.", "items": { "$ref": "#/$defs/ProducedEvent" } },
- "Identifier": { "type": ["string", "null"], "description": "The name of the command property whose value identifies the event source the command appends to. Null when the command declares none, in which case every execution opens a stream of its own." }
+ "Identifier": { "type": ["string", "null"], "description": "The name of the command property whose value identifies the event source the command appends to. Null when the command declares none, in which case every execution opens a stream of its own." },
+ "Authorization": { "oneOf": [{ "$ref": "#/$defs/AuthorizationRequirement" }, { "type": "null" }], "description": "What the caller must satisfy to execute the command; null when the command declares none and anyone may execute it." },
+ "Requirements": { "type": "array", "description": "The conditions the command as a whole must satisfy - the modeled 'require' rules.", "items": { "$ref": "#/$defs/Requirement" } },
+ "Reads": { "type": "array", "description": "The read models the command consults before it decides - the modeled 'reads' declarations.", "items": { "$ref": "#/$defs/ReadsDefinition" } }
+ },
+ "additionalProperties": false
+ },
+ "AuthorizationRequirement": {
+ "type": "object",
+ "description": "What an 'authorize' declaration requires of the caller. Discriminated by 'kind': 'policy' names one policy, 'logical' combines two requirements. A tree rather than a flat list of names, because a flat list cannot distinguish 'A or B and C' from '(A or B) and C'.",
+ "required": ["kind"],
+ "properties": {
+ "kind": { "type": "string", "enum": ["policy", "logical"] },
+ "Policy": { "type": "string", "description": "Policy only - the name of the referenced policy." },
+ "Left": { "$ref": "#/$defs/AuthorizationRequirement" },
+ "Operator": { "type": "string", "enum": ["And", "Or"], "description": "Logical only - the operator combining the requirements." },
+ "Right": { "$ref": "#/$defs/AuthorizationRequirement" }
+ },
+ "additionalProperties": false
+ },
+ "Requirement": {
+ "type": "object",
+ "description": "A rule the command as a whole must satisfy - the modeled 'require' rule. Carries the same condition tree a 'produces when' guard carries.",
+ "required": ["Condition"],
+ "properties": {
+ "Condition": { "$ref": "#/$defs/ProducedEventCondition" },
+ "Message": { "type": ["string", "null"], "description": "The message reported when the condition does not hold; null when none is declared." }
+ },
+ "additionalProperties": false
+ },
+ "ReadsDefinition": {
+ "type": "object",
+ "description": "A read model the command consults before it decides - the modeled 'reads' declaration.",
+ "required": ["ReadModel"],
+ "properties": {
+ "ReadModel": { "type": "string", "description": "The name of the read model the command reads." },
+ "By": { "type": ["string", "null"], "description": "The command property the read model is looked up by; null when the read model is not keyed." }
},
"additionalProperties": false
},
@@ -105,7 +142,17 @@
"Event": { "type": "string", "description": "The name of the event type to append." },
"When": { "oneOf": [{ "$ref": "#/$defs/ProducedEventCondition" }, { "type": "null" }], "description": "The condition guarding the production; null when the event is always produced." },
"Properties": { "type": "array", "items": { "$ref": "#/$defs/ProducedEventProperty" } },
- "Tags": { "type": "array", "items": { "type": "string" } }
+ "Tags": { "type": "array", "items": { "type": "string" } },
+ "For": { "oneOf": [{ "$ref": "#/$defs/ProducedEventSource" }, { "type": "null" }], "description": "Where the event source this event is appended to comes from - the modeled 'for' clause. Null when the event lands on the command's own event source." }
+ },
+ "additionalProperties": false
+ },
+ "ProducedEventSource": {
+ "type": "object",
+ "description": "Where the event source a produced event is appended to comes from. Carries no property name, unlike ProducedEventProperty: the value identifies the stream rather than filling a property of the payload.",
+ "properties": {
+ "Kind": { "type": "string", "enum": ["CommandProperty", "Literal", "Occurred", "Identity", "Environment", "Template", "Unsupported"] },
+ "Expression": { "type": "string", "description": "The source, interpreted according to Kind." }
},
"additionalProperties": false
},
@@ -214,7 +261,9 @@
"Given": { "type": "array", "items": { "$ref": "#/$defs/SpecificationGivenEvent" } },
"When": { "oneOf": [{ "$ref": "#/$defs/SpecificationCommand" }, { "type": "null" }] },
"ThenEvents": { "type": "array", "items": { "$ref": "#/$defs/SpecificationThenEvent" } },
- "ThenErrors": { "type": "array", "items": { "$ref": "#/$defs/SpecificationError" } }
+ "ThenErrors": { "type": "array", "items": { "$ref": "#/$defs/SpecificationError" } },
+ "GivenReadModels": { "type": "array", "description": "The read model states that establish the Given precondition.", "items": { "$ref": "#/$defs/SpecificationReadModel" } },
+ "ThenReadModels": { "type": "array", "description": "The read model states expected in the Then step - what a ThenReadModel run step verifies.", "items": { "$ref": "#/$defs/SpecificationReadModel" } }
},
"additionalProperties": false
},
@@ -227,6 +276,16 @@
"Values": { "type": "string", "description": "The event values as a JSON string." }
}
},
+ "SpecificationReadModel": {
+ "type": "object",
+ "description": "A read model state in a specification - used for both the Given precondition and the Then expectation.",
+ "properties": {
+ "Id": { "type": "string", "format": "uuid" },
+ "Name": { "type": "string" },
+ "ReadModelId": { "type": "string", "format": "uuid" },
+ "Values": { "type": "string", "description": "The read model values as a JSON string." }
+ }
+ },
"SpecificationCommand": {
"type": "object",
"properties": {
diff --git a/Source/Contracts/Events/EventDefinition.cs b/Source/Contracts/Events/EventDefinition.cs
index c28d6ec..86e114a 100644
--- a/Source/Contracts/Events/EventDefinition.cs
+++ b/Source/Contracts/Events/EventDefinition.cs
@@ -20,4 +20,10 @@ public record EventDefinition(
string SourceEventId,
string Schema,
UniqueEventTypeConstraint? UniqueEventTypeConstraint,
- UniqueConstraint? UniqueConstraint);
+ UniqueConstraint? UniqueConstraint)
+{
+ ///
+ /// Gets the tags declared on the event, applied to every occurrence of it.
+ ///
+ public IReadOnlyList Tags { get; init; } = [];
+}
diff --git a/Source/Contracts/Screenplay/AuthorizationConverter.cs b/Source/Contracts/Screenplay/AuthorizationConverter.cs
new file mode 100644
index 0000000..d7edfda
--- /dev/null
+++ b/Source/Contracts/Screenplay/AuthorizationConverter.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Screenplay.Syntax;
+using Cratis.Stage.Contracts.Commands;
+
+namespace Cratis.Stage.Contracts.Screenplay;
+
+///
+/// Converts a Screenplay into the Stage tree.
+///
+///
+/// The tree is carried whole rather than flattened to the policy names it mentions. Flattening cannot distinguish
+/// A or B and C from (A or B) and C, so a consumer deciding whether a caller is allowed could not
+/// answer from the result — see Screenplay#68.
+/// A consumer that only wants the names calls .
+///
+public static class AuthorizationConverter
+{
+ ///
+ /// Converts an authorize declaration into its Stage requirement.
+ ///
+ /// The declaration, or when the construct declares none.
+ /// The Stage requirement, or when nothing is required.
+ public static AuthorizationRequirement? Convert(AuthorizeSyntax? authorize) =>
+ authorize is null ? null : Convert(authorize.Requirement);
+
+ static AuthorizationRequirement? Convert(PolicyRequirementSyntax requirement) =>
+ requirement switch
+ {
+ PolicyReferenceSyntax reference => new PolicyReference(reference.Name),
+ LogicalPolicyRequirementSyntax logical when Convert(logical.Left) is { } left && Convert(logical.Right) is { } right =>
+ new LogicalRequirement(left, ConditionConverter.Operator(logical.Operator), right),
+ _ => null
+ };
+}
diff --git a/Source/Contracts/Screenplay/CommandConverter.cs b/Source/Contracts/Screenplay/CommandConverter.cs
index 4438e70..f233b6c 100644
--- a/Source/Contracts/Screenplay/CommandConverter.cs
+++ b/Source/Contracts/Screenplay/CommandConverter.cs
@@ -30,7 +30,25 @@ public static CommandDefinition Convert(CommandSyntax command, SchemaSynthesizer
ValidationRuleConverter.Convert(command.Validations),
string.Empty,
ProducesConverter.Convert(command.Produces),
- Identifier(command));
+ Identifier(command))
+ {
+ Authorization = AuthorizationConverter.Convert(command.Authorize),
+ Requirements = Requirements(command.Validations),
+ Reads = [.. (command.Reads ?? []).Select(reads => new ReadsDefinition(reads.ReadModel, reads.By))]
+ };
+
+ // 'require' rules live inside the declarative validate blocks rather than on the command itself, alongside the
+ // per-property rules ValidationRuleConverter takes from the same blocks. A requirement whose condition has no
+ // Stage equivalent is dropped rather than carried as an empty rule that would always hold.
+ static IReadOnlyList Requirements(IEnumerable validations) =>
+ [
+ .. validations
+ .OfType()
+ .SelectMany(block => block.Requirements ?? [])
+ .Select(requirement => (Condition: ConditionConverter.Convert(requirement.Condition), requirement.Message))
+ .Where(requirement => requirement.Condition is not null)
+ .Select(requirement => new Requirement(requirement.Condition!, requirement.Message))
+ ];
// The Screenplay compiler already reports a second 'identifier' as an error and drops it, so a compiled document
// never carries more than one. A syntax tree built any other way still can - and silently taking the first would
diff --git a/Source/Contracts/Screenplay/ConditionConverter.cs b/Source/Contracts/Screenplay/ConditionConverter.cs
new file mode 100644
index 0000000..79043d4
--- /dev/null
+++ b/Source/Contracts/Screenplay/ConditionConverter.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Screenplay.Syntax;
+using Cratis.Stage.Contracts.Commands;
+
+namespace Cratis.Stage.Contracts.Screenplay;
+
+///
+/// Converts a Screenplay into the Stage tree.
+///
+///
+/// One condition converter for every construct that carries a condition — a produces when guard and a
+/// require rule are the same condition grammar in the language, so they are the same tree here. A second
+/// tree for requirements would let and and or drift apart between the two.
+///
+public static class ConditionConverter
+{
+ ///
+ /// Converts a condition into its Stage tree.
+ ///
+ /// The condition to convert, or when none is declared.
+ /// The Stage condition, or when there is none to express.
+ public static ProducedEventCondition? Convert(ConditionSyntax? condition) =>
+ condition switch
+ {
+ null => null,
+ ComparisonConditionSyntax comparison => new ProducedEventComparison(
+ comparison.Left,
+ Operator(comparison.Operator),
+ ProducedValueConverter.Convert(comparison.Right) is { Kind: ProducedValueKind.Literal } literal ? literal.Expression : "null"),
+ LogicalConditionSyntax logical when Convert(logical.Left) is { } left && Convert(logical.Right) is { } right =>
+ new ProducedEventLogicalCondition(left, Operator(logical.Operator), right),
+ _ => null
+ };
+
+ ///
+ /// Converts a Screenplay logical operator into its Stage equivalent.
+ ///
+ /// The operator to convert.
+ /// The Stage operator.
+ public static ProducedEventLogicalOperator Operator(LogicalOperator @operator) =>
+ @operator == LogicalOperator.Or ? ProducedEventLogicalOperator.Or : ProducedEventLogicalOperator.And;
+
+ static ProducedEventComparisonOperator Operator(ComparisonOperator @operator) =>
+ @operator switch
+ {
+ ComparisonOperator.Equal => ProducedEventComparisonOperator.Equal,
+ ComparisonOperator.NotEqual => ProducedEventComparisonOperator.NotEqual,
+ ComparisonOperator.GreaterThan => ProducedEventComparisonOperator.GreaterThan,
+ ComparisonOperator.GreaterThanOrEqual => ProducedEventComparisonOperator.GreaterThanOrEqual,
+ ComparisonOperator.LessThan => ProducedEventComparisonOperator.LessThan,
+ ComparisonOperator.LessThanOrEqual => ProducedEventComparisonOperator.LessThanOrEqual,
+ _ => ProducedEventComparisonOperator.Equal
+ };
+}
diff --git a/Source/Contracts/Screenplay/EventConverter.cs b/Source/Contracts/Screenplay/EventConverter.cs
index be68314..1dd925e 100644
--- a/Source/Contracts/Screenplay/EventConverter.cs
+++ b/Source/Contracts/Screenplay/EventConverter.cs
@@ -33,7 +33,10 @@ public static IReadOnlyList Convert(
string.Empty,
schema.ForProperties(@event.Properties),
UniqueEventTypeConstraint: null,
- UniqueConstraint: null)).ToList();
+ UniqueConstraint: null)
+ {
+ Tags = ProducedValueConverter.Tags(@event.Tags)
+ }).ToList();
foreach (var constraint in constraints)
{
diff --git a/Source/Contracts/Screenplay/ProducedValueConverter.cs b/Source/Contracts/Screenplay/ProducedValueConverter.cs
new file mode 100644
index 0000000..c078332
--- /dev/null
+++ b/Source/Contracts/Screenplay/ProducedValueConverter.cs
@@ -0,0 +1,111 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using Cratis.Screenplay.Syntax;
+using Cratis.Screenplay.Syntax.Projections;
+using Cratis.Stage.Contracts.Commands;
+
+namespace Cratis.Stage.Contracts.Screenplay;
+
+///
+/// Converts a Screenplay into the and expression text
+/// the engine evaluates at runtime — the vocabulary shared by a produced event's property mappings, the event source
+/// a produces … for names, and the constant side of a condition.
+///
+public static class ProducedValueConverter
+{
+ ///
+ /// Converts an expression into the kind and expression text describing where its value comes from.
+ ///
+ /// The expression to convert.
+ /// The kind and its expression text.
+ public static (ProducedValueKind Kind, string Expression) Convert(ExpressionSyntax expression) =>
+ expression switch
+ {
+ PathExpressionSyntax path => (ProducedValueKind.CommandProperty, path.Path),
+ LiteralExpressionSyntax literal => (ProducedValueKind.Literal, Literal(literal.Value)),
+ EventContextExpressionSyntax eventContext => EventContext(eventContext.Path),
+
+ // $context.occurred is the time the event happened; anything else under $context that names the
+ // identity resolves against the identity that caused the command.
+ ContextExpressionSyntax context => Context(context.Path),
+ CausedByExpressionSyntax causedBy => (ProducedValueKind.Identity, causedBy.Property ?? "id"),
+ EnvironmentExpressionSyntax environment => (ProducedValueKind.Environment, environment.Name),
+ TemplateExpressionSyntax template => (ProducedValueKind.Template, Template(template)),
+ _ => (ProducedValueKind.Unsupported, string.Empty)
+ };
+
+ ///
+ /// Converts an expression used as a tag into its constant text.
+ ///
+ /// The expression to convert.
+ /// The tag text, or an empty string when the tag has no constant form.
+ ///
+ /// A tag has to be a constant string by the time the event is appended. Literal and bare-identifier tags render
+ /// directly; a tag sourced from the runtime context has no constant form and is left off.
+ ///
+ public static string Tag(ExpressionSyntax expression) =>
+ expression switch
+ {
+ LiteralExpressionSyntax { Value: string text } => text,
+ PathExpressionSyntax path => path.Path,
+ _ => string.Empty
+ };
+
+ ///
+ /// Converts a set of tag declarations into the constant tags they carry, dropping those with no constant form.
+ ///
+ /// The tag declarations, or when none are declared.
+ /// The constant tag texts.
+ public static IReadOnlyList Tags(IEnumerable? tags) =>
+ [.. (tags ?? []).Select(tag => Tag(tag.Value)).Where(tag => tag.Length > 0)];
+
+ static (ProducedValueKind Kind, string Expression) Context(string path)
+ {
+ if (path.Equals("occurred", StringComparison.OrdinalIgnoreCase))
+ {
+ return (ProducedValueKind.Occurred, string.Empty);
+ }
+
+ return path.StartsWith("identity.", StringComparison.OrdinalIgnoreCase)
+ ? (ProducedValueKind.Identity, path["identity.".Length..])
+ : (ProducedValueKind.Unsupported, path);
+ }
+
+ static (ProducedValueKind Kind, string Expression) EventContext(string path) =>
+ path.Equals("occurred", StringComparison.OrdinalIgnoreCase)
+ ? (ProducedValueKind.Occurred, string.Empty)
+ : (ProducedValueKind.Unsupported, path);
+
+ static string Template(TemplateExpressionSyntax template)
+ {
+ var builder = new StringBuilder();
+ foreach (var part in template.Parts)
+ {
+ switch (part)
+ {
+ case TemplateTextSyntax text:
+ builder.Append(text.Text);
+ break;
+ case TemplateInterpolationSyntax interpolation when interpolation.Expression is PathExpressionSyntax path:
+ builder.Append("${").Append(path.Path).Append('}');
+ break;
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ static string Literal(object? value) =>
+ value switch
+ {
+ null => "null",
+ string text => JsonSerializer.Serialize(text),
+ bool boolean => boolean ? "true" : "false",
+ double number => number.ToString(CultureInfo.InvariantCulture),
+ _ => JsonSerializer.Serialize(System.Convert.ToString(value, CultureInfo.InvariantCulture))
+ };
+}
diff --git a/Source/Contracts/Screenplay/ProducesConverter.cs b/Source/Contracts/Screenplay/ProducesConverter.cs
index b395f14..83da55a 100644
--- a/Source/Contracts/Screenplay/ProducesConverter.cs
+++ b/Source/Contracts/Screenplay/ProducesConverter.cs
@@ -1,11 +1,7 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-using System.Globalization;
-using System.Text;
-using System.Text.Json;
using Cratis.Screenplay.Syntax;
-using Cratis.Screenplay.Syntax.Projections;
using Cratis.Stage.Contracts.Commands;
namespace Cratis.Stage.Contracts.Screenplay;
@@ -25,115 +21,32 @@ public static IReadOnlyList Convert(IEnumerable p
[
.. produces.Select(declaration => new ProducedEvent(
declaration.Event,
- Condition(declaration.When),
+ ConditionConverter.Convert(declaration.When),
[.. declaration.Mappings.Select(Property)],
- [.. (declaration.Tags ?? []).Select(tag => Tag(tag.Value)).Where(tag => tag.Length > 0)]))
- ];
-
- // A tag has to be a constant string by the time the event is appended. Literal and bare-identifier tags render
- // directly; a tag sourced from the runtime context has no constant form and is left off.
- static string Tag(ExpressionSyntax expression) =>
- expression switch
+ ProducedValueConverter.Tags(declaration.Tags))
{
- LiteralExpressionSyntax { Value: string text } => text,
- PathExpressionSyntax path => path.Path,
- _ => string.Empty
- };
-
- static ProducedEventProperty Property(PropertyMappingSyntax mapping)
- {
- var (kind, expression) = Source(mapping.Source);
-
- return new(mapping.Property, kind, expression);
- }
-
- static (ProducedValueKind Kind, string Expression) Source(ExpressionSyntax expression) =>
- expression switch
- {
- PathExpressionSyntax path => (ProducedValueKind.CommandProperty, path.Path),
- LiteralExpressionSyntax literal => (ProducedValueKind.Literal, Literal(literal.Value)),
- EventContextExpressionSyntax eventContext => EventContext(eventContext.Path),
-
- // $context.occurred is the time the event happened; anything else under $context that names the
- // identity resolves against the identity that caused the command.
- ContextExpressionSyntax context => Context(context.Path),
- CausedByExpressionSyntax causedBy => (ProducedValueKind.Identity, causedBy.Property ?? "id"),
- EnvironmentExpressionSyntax environment => (ProducedValueKind.Environment, environment.Name),
- TemplateExpressionSyntax template => (ProducedValueKind.Template, Template(template)),
- _ => (ProducedValueKind.Unsupported, string.Empty)
- };
+ For = EventSource(declaration.For)
+ })
+ ];
- static (ProducedValueKind Kind, string Expression) Context(string path)
+ // A 'for' expression the engine has no way to evaluate would silently redirect the append to the command's own
+ // event source, which is a different stream than the document asked for — so it is left off instead.
+ static ProducedEventSource? EventSource(ExpressionSyntax? expression)
{
- if (path.Equals("occurred", StringComparison.OrdinalIgnoreCase))
+ if (expression is null)
{
- return (ProducedValueKind.Occurred, string.Empty);
+ return null;
}
- return path.StartsWith("identity.", StringComparison.OrdinalIgnoreCase)
- ? (ProducedValueKind.Identity, path["identity.".Length..])
- : (ProducedValueKind.Unsupported, path);
- }
+ var (kind, text) = ProducedValueConverter.Convert(expression);
- static (ProducedValueKind Kind, string Expression) EventContext(string path) =>
- path.Equals("occurred", StringComparison.OrdinalIgnoreCase)
- ? (ProducedValueKind.Occurred, string.Empty)
- : (ProducedValueKind.Unsupported, path);
+ return kind is ProducedValueKind.Unsupported ? null : new ProducedEventSource(kind, text);
+ }
- static string Template(TemplateExpressionSyntax template)
+ static ProducedEventProperty Property(PropertyMappingSyntax mapping)
{
- var builder = new StringBuilder();
- foreach (var part in template.Parts)
- {
- switch (part)
- {
- case TemplateTextSyntax text:
- builder.Append(text.Text);
- break;
- case TemplateInterpolationSyntax interpolation when interpolation.Expression is PathExpressionSyntax path:
- builder.Append("${").Append(path.Path).Append('}');
- break;
- }
- }
+ var (kind, expression) = ProducedValueConverter.Convert(mapping.Source);
- return builder.ToString();
+ return new(mapping.Property, kind, expression);
}
-
- static string Literal(object? value) =>
- value switch
- {
- null => "null",
- string text => JsonSerializer.Serialize(text),
- bool boolean => boolean ? "true" : "false",
- double number => number.ToString(CultureInfo.InvariantCulture),
- _ => JsonSerializer.Serialize(System.Convert.ToString(value, CultureInfo.InvariantCulture))
- };
-
- static ProducedEventCondition? Condition(ConditionSyntax? condition) =>
- condition switch
- {
- null => null,
- ComparisonConditionSyntax comparison => new ProducedEventComparison(
- comparison.Left,
- Operator(comparison.Operator),
- Source(comparison.Right) is { Kind: ProducedValueKind.Literal } literal ? literal.Expression : "null"),
- LogicalConditionSyntax logical when Condition(logical.Left) is { } left && Condition(logical.Right) is { } right =>
- new ProducedEventLogicalCondition(left, Operator(logical.Operator), right),
- _ => null
- };
-
- static ProducedEventComparisonOperator Operator(ComparisonOperator @operator) =>
- @operator switch
- {
- ComparisonOperator.Equal => ProducedEventComparisonOperator.Equal,
- ComparisonOperator.NotEqual => ProducedEventComparisonOperator.NotEqual,
- ComparisonOperator.GreaterThan => ProducedEventComparisonOperator.GreaterThan,
- ComparisonOperator.GreaterThanOrEqual => ProducedEventComparisonOperator.GreaterThanOrEqual,
- ComparisonOperator.LessThan => ProducedEventComparisonOperator.LessThan,
- ComparisonOperator.LessThanOrEqual => ProducedEventComparisonOperator.LessThanOrEqual,
- _ => ProducedEventComparisonOperator.Equal
- };
-
- static ProducedEventLogicalOperator Operator(LogicalOperator @operator) =>
- @operator == LogicalOperator.Or ? ProducedEventLogicalOperator.Or : ProducedEventLogicalOperator.And;
}
diff --git a/Source/Contracts/Screenplay/SchemaSynthesizer.cs b/Source/Contracts/Screenplay/SchemaSynthesizer.cs
index 538a25a..016fe82 100644
--- a/Source/Contracts/Screenplay/SchemaSynthesizer.cs
+++ b/Source/Contracts/Screenplay/SchemaSynthesizer.cs
@@ -20,6 +20,30 @@ public sealed class SchemaSynthesizer(IReadOnlyDictionary
///
public const string EmptyObjectSchema = """{"type":"object","properties":{}}""";
+ ///
+ /// The schema keyword naming the concept a property is typed as.
+ ///
+ ///
+ /// A concept resolves to its underlying primitive in the schema, which erases which concept it was. Naming it
+ /// keeps the property joinable back to the concept it came from.
+ ///
+ public const string ConceptKeyword = "x-concept";
+
+ ///
+ /// The schema keyword carrying the attributes declared on the concept a property is typed as, as a map of
+ /// attribute name to its declared reason (an empty string when none was declared).
+ ///
+ ///
+ /// Carries @pii and @sensitive — and anything the language adds later, since the map is keyed by
+ /// whatever the concept declares rather than by a fixed set. Without it a compliance marker does not survive
+ /// the import at all: the property is indistinguishable from an ordinary string once the concept is resolved.
+ ///
+ /// This states the marker; it does not enforce it. Chronicle carries its own compliance schema keyword
+ /// that drives encryption at rest, which is a separate and deliberate step.
+ ///
+ ///
+ public const string ConceptAttributesKeyword = "x-conceptAttributes";
+
///
/// Synthesizes a JSON Schema object for a set of typed properties (a command or event payload).
///
@@ -79,6 +103,39 @@ public string ForReadModel(IEnumerable> properties
_ => null
};
+ // Applied after the underlying type is resolved so the marker lands on the node that actually holds the value —
+ // for a collection that is the item schema, which is where a reader of the property looks.
+ static JsonNode Annotate(JsonNode node, ConceptSyntax concept)
+ {
+ if (node is not JsonObject schema)
+ {
+ return node;
+ }
+
+ schema[ConceptKeyword] = concept.Name;
+
+ var attributes = concept.Attributes.ToArray();
+ if (attributes.Length == 0)
+ {
+ return schema;
+ }
+
+ // A concept based on another concept resolves through this method twice; the inner attributes are already
+ // on the node, so they are merged into rather than replaced.
+ if (schema[ConceptAttributesKeyword] is not JsonObject declared)
+ {
+ declared = [];
+ schema[ConceptAttributesKeyword] = declared;
+ }
+
+ foreach (var attribute in attributes)
+ {
+ declared[attribute.Name] = attribute.Reason ?? string.Empty;
+ }
+
+ return schema;
+ }
+
JsonNode ForType(TypeRefSyntax type)
{
var inner = ForTypeName(type.Name);
@@ -96,9 +153,11 @@ JsonNode ForTypeName(string name)
if (concepts.TryGetValue(name, out var concept))
{
- return concept.IsEnum
+ var node = concept.IsEnum
? new JsonObject { ["type"] = "string", ["enum"] = new JsonArray([.. concept.Values.Select(value => (JsonNode)JsonValue.Create(value))]) }
: ForTypeName(concept.Type);
+
+ return Annotate(node, concept);
}
return new JsonObject { ["type"] = "object" };
diff --git a/Source/Contracts/Screenplay/SpecificationConverter.cs b/Source/Contracts/Screenplay/SpecificationConverter.cs
index 48b744a..e70522b 100644
--- a/Source/Contracts/Screenplay/SpecificationConverter.cs
+++ b/Source/Contracts/Screenplay/SpecificationConverter.cs
@@ -61,9 +61,27 @@ public static Specification Convert(SpecificationSyntax specification, string sl
given,
when,
thenEvents,
- thenErrors);
+ thenErrors)
+ {
+ GivenReadModels = ReadModels(specification.GivenReadModels, $"{specificationPath}.given", slicePath),
+ ThenReadModels = ReadModels(specification.ThenReadModels, $"{specificationPath}.then", slicePath)
+ };
}
+ // The read model is referred to by name, resolved to the same identifier ReadModelConverter derives for the
+ // slice's read model — the way the event and command steps already resolve what they refer to.
+ static IReadOnlyList ReadModels(
+ IEnumerable? readModels,
+ string stepPath,
+ string slicePath) =>
+ [
+ .. (readModels ?? []).Select((readModel, index) => new SpecificationReadModel(
+ DeterministicId.From($"{stepPath}.readmodel.{index}.{readModel.Name}"),
+ readModel.Name,
+ DeterministicId.From($"{slicePath}.readmodel.{readModel.Name}"),
+ Values(readModel.Properties)))
+ ];
+
static string Values(IEnumerable mappings)
{
var values = new JsonObject();
diff --git a/Source/Contracts/Specifications/Specification.cs b/Source/Contracts/Specifications/Specification.cs
index e5a0f21..c8f65e5 100644
--- a/Source/Contracts/Specifications/Specification.cs
+++ b/Source/Contracts/Specifications/Specification.cs
@@ -18,4 +18,16 @@ public record Specification(
IReadOnlyList Given,
SpecificationCommand? When,
IReadOnlyList ThenEvents,
- IReadOnlyList ThenErrors);
+ IReadOnlyList ThenErrors)
+{
+ ///
+ /// Gets the read model states that establish the Given precondition.
+ ///
+ public IReadOnlyList GivenReadModels { get; init; } = [];
+
+ ///
+ /// Gets the read model states expected in the Then step — what
+ /// verifies.
+ ///
+ public IReadOnlyList ThenReadModels { get; init; } = [];
+}
diff --git a/Source/Contracts/Specifications/SpecificationReadModel.cs b/Source/Contracts/Specifications/SpecificationReadModel.cs
new file mode 100644
index 0000000..4f18d93
--- /dev/null
+++ b/Source/Contracts/Specifications/SpecificationReadModel.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Stage.Contracts.Specifications;
+
+///
+/// Represents a read model state in a specification — used for both the Given precondition and the Then expectation.
+///
+/// The unique identifier of the specification item.
+/// The name of the read model.
+/// The identifier of the slice read model this item refers to.
+/// The JSON object of property values for the read model.
+public record SpecificationReadModel(
+ Guid Id,
+ string Name,
+ Guid ReadModelId,
+ string Values);
diff --git a/Source/Contracts/for_EventModelLoader/given/a_compiled_model_using_2x_constructs.cs b/Source/Contracts/for_EventModelLoader/given/a_compiled_model_using_2x_constructs.cs
new file mode 100644
index 0000000..9a76559
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/given/a_compiled_model_using_2x_constructs.cs
@@ -0,0 +1,97 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Specifications;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader.given;
+
+///
+/// A document exercising the constructs the contract gained for Screenplay 2.x — concept compliance attributes,
+/// command authorization, require, reads, produces … for, event tags and specification read
+/// model steps — compiled the same way the engine compiles one at startup.
+///
+public class a_compiled_model_using_2x_constructs : Specification
+{
+ protected const string Source =
+ """
+ concept InvoiceId : Uuid
+ concept ContractId : Uuid
+ concept EmailAddress : String @pii
+ pii reason "Billing contact - lawful basis: contract performance"
+
+ concept BankAccount : String @pii @sensitive
+ sensitive reason "A leaked account number enables direct financial harm"
+
+ policy IsAccountant
+ require role "Accountant"
+
+ policy IsFinance
+ require role "Finance"
+
+ policy OwnsInvoice
+ require authenticated
+
+ module Invoicing
+
+ feature Billing
+
+ slice StateChange ActivateInvoice
+
+ readmodel InvoiceScope
+ isStarted Bool
+ phase String
+
+ command ActivateInvoice
+ invoiceId InvoiceId identifier
+ contractId ContractId
+ email EmailAddress
+ account BankAccount
+
+ reads InvoiceScope by invoiceId
+
+ authorize IsAccountant or IsFinance and OwnsInvoice
+
+ validate
+ require InvoiceScope.isStarted == false
+ message "Already started"
+ require InvoiceScope.phase == "Contract"
+
+ produces InvoiceActivated
+ invoiceId = invoiceId
+ email = email
+
+ produces ContractPolicyActivated
+ for contractId
+ contractId = contractId
+
+ event InvoiceActivated
+ invoiceId InvoiceId
+ email EmailAddress
+ tag invoicing
+ tag "billing"
+
+ event ContractPolicyActivated
+ contractId ContractId
+
+ specification ActivatesAnInvoice
+ given readmodel InvoiceScope
+ isStarted = false
+ phase = "Contract"
+ when ActivateInvoice
+ invoiceId = "9c858901-8a57-4791-81fe-4c455b099bc9"
+ then InvoiceActivated
+ invoiceId = "9c858901-8a57-4791-81fe-4c455b099bc9"
+ then readmodel InvoiceScope
+ isStarted = true
+ """;
+
+ protected EventModel _model = null!;
+
+ protected Slice _slice = null!;
+
+ void Establish()
+ {
+ _model = EventModelLoader.LoadFromSource(Source);
+ _slice = _model.Collections[0].Modules[0].Features[0].Slices.Single(slice => slice.Name == "ActivateInvoice");
+ }
+}
diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_authorization.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_authorization.cs
new file mode 100644
index 0000000..3adfdc3
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_authorization.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Specifications;
+using Cratis.Stage.Contracts.Commands;
+using Xunit;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader;
+
+public class when_inspecting_the_command_authorization : given.a_compiled_model_using_2x_constructs
+{
+ AuthorizationRequirement _authorization = null!;
+
+ void Because() => _authorization = _slice.Command!.Authorization!;
+
+ [Fact] void should_carry_the_requirement() => _authorization.ShouldNotBeNull();
+
+ // 'IsAccountant or IsFinance and OwnsInvoice' — 'and' binds tighter, so the root is the 'or'. A flat list of
+ // the three names could not tell this apart from '(IsAccountant or IsFinance) and OwnsInvoice'.
+ [Fact] void should_root_the_tree_at_the_or() =>
+ ((LogicalRequirement)_authorization).Operator.ShouldEqual(ProducedEventLogicalOperator.Or);
+ [Fact] void should_put_the_first_policy_on_the_left() =>
+ ((PolicyReference)((LogicalRequirement)_authorization).Left).Policy.ShouldEqual("IsAccountant");
+ [Fact] void should_group_the_remaining_two_under_an_and() =>
+ ((LogicalRequirement)((LogicalRequirement)_authorization).Right).Operator.ShouldEqual(ProducedEventLogicalOperator.And);
+ [Fact] void should_name_every_policy_in_declaration_order() =>
+ _authorization.Policies().ShouldContainOnly(["IsAccountant", "IsFinance", "OwnsInvoice"]);
+}
diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_requirements.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_requirements.cs
new file mode 100644
index 0000000..5c89aa3
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_command_requirements.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Specifications;
+using Cratis.Stage.Contracts.Commands;
+using Xunit;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader;
+
+public class when_inspecting_the_command_requirements : given.a_compiled_model_using_2x_constructs
+{
+ IReadOnlyList _requirements = [];
+
+ void Because() => _requirements = _slice.Command!.Requirements;
+
+ [Fact] void should_carry_every_declared_requirement() => _requirements.Count.ShouldEqual(2);
+ [Fact] void should_carry_the_declared_message() => _requirements[0].Message.ShouldEqual("Already started");
+ [Fact] void should_leave_an_undeclared_message_unset() => _requirements[1].Message.ShouldBeNull();
+
+ // The same condition tree a 'produces when' guard carries — one condition grammar in the language, one here.
+ [Fact] void should_carry_the_condition_as_a_comparison() =>
+ _requirements[0].Condition.ShouldBeOfExactType();
+ [Fact] void should_name_the_state_the_requirement_reads() =>
+ ((ProducedEventComparison)_requirements[0].Condition).Property.ShouldEqual("InvoiceScope.isStarted");
+ [Fact] void should_carry_the_comparison_operator() =>
+ ((ProducedEventComparison)_requirements[0].Condition).Operator.ShouldEqual(ProducedEventComparisonOperator.Equal);
+ [Fact] void should_carry_the_compared_value() =>
+ ((ProducedEventComparison)_requirements[0].Condition).Value.ShouldEqual("false");
+}
diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_concept_attributes.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_concept_attributes.cs
new file mode 100644
index 0000000..42cd31f
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_concept_attributes.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json;
+using Cratis.Specifications;
+using Cratis.Stage.Contracts.Screenplay;
+using Xunit;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader;
+
+///
+/// A concept resolves to its underlying primitive in a synthesized schema, which used to erase the compliance
+/// markers declared on it entirely — a @pii property arrived indistinguishable from any other string.
+///
+public class when_inspecting_the_concept_attributes : given.a_compiled_model_using_2x_constructs
+{
+ JsonElement _email;
+ JsonElement _account;
+ JsonElement _plain;
+
+ void Because()
+ {
+ using var schema = JsonDocument.Parse(_slice.Command!.Schema);
+ var properties = schema.RootElement.GetProperty("properties").Clone();
+ _email = properties.GetProperty("email");
+ _account = properties.GetProperty("account");
+ _plain = properties.GetProperty("invoiceId");
+ }
+
+ [Fact] void should_still_resolve_the_concept_to_its_primitive() =>
+ _email.GetProperty("type").GetString().ShouldEqual("string");
+ [Fact] void should_name_the_concept_the_property_is_typed_as() =>
+ _email.GetProperty(SchemaSynthesizer.ConceptKeyword).GetString().ShouldEqual("EmailAddress");
+ [Fact] void should_carry_the_pii_marker() =>
+ Attributes(_email).EnumerateObject().Select(attribute => attribute.Name).ShouldContain("pii");
+ [Fact] void should_carry_the_declared_reason() =>
+ Attributes(_email).GetProperty("pii").GetString().ShouldEqual("Billing contact - lawful basis: contract performance");
+ [Fact] void should_carry_every_marker_the_concept_declares() =>
+ Attributes(_account).EnumerateObject().Select(attribute => attribute.Name).ShouldContainOnly(["pii", "sensitive"]);
+ [Fact] void should_leave_a_marker_with_no_reason_empty() =>
+ Attributes(_account).GetProperty("pii").GetString().ShouldEqual(string.Empty);
+ [Fact] void should_not_annotate_a_concept_that_declares_none() =>
+ _plain.TryGetProperty(SchemaSynthesizer.ConceptAttributesKeyword, out _).ShouldBeFalse();
+
+ static JsonElement Attributes(JsonElement property) => property.GetProperty(SchemaSynthesizer.ConceptAttributesKeyword);
+}
diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_event_tags.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_event_tags.cs
new file mode 100644
index 0000000..b2811d5
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_event_tags.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Specifications;
+using Xunit;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader;
+
+public class when_inspecting_the_event_tags : given.a_compiled_model_using_2x_constructs
+{
+ IReadOnlyList _tagged = [];
+ IReadOnlyList _untagged = [];
+
+ void Because()
+ {
+ _tagged = _slice.Events.Single(@event => @event.Name == "InvoiceActivated").Tags;
+ _untagged = _slice.Events.Single(@event => @event.Name == "ContractPolicyActivated").Tags;
+ }
+
+ // A bare identifier and a quoted literal are both constant tags and both survive.
+ [Fact] void should_carry_every_declared_tag() => _tagged.ShouldContainOnly(["invoicing", "billing"]);
+ [Fact] void should_leave_an_untagged_event_with_no_tags() => _untagged.ShouldBeEmpty();
+}
diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_the_specification_read_models.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_the_specification_read_models.cs
new file mode 100644
index 0000000..c69721e
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/when_inspecting_the_specification_read_models.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json;
+using Cratis.Specifications;
+using Cratis.Stage.Contracts.Specifications;
+using Xunit;
+
+using GivenWhenThen = Cratis.Stage.Contracts.Specifications.Specification;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader;
+
+public class when_inspecting_the_specification_read_models : given.a_compiled_model_using_2x_constructs
+{
+ GivenWhenThen _specification = null!;
+
+ void Because() => _specification = _slice.Specifications.Single(specification => specification.Name == "ActivatesAnInvoice");
+
+ [Fact] void should_carry_the_given_read_model() => _specification.GivenReadModels.Count.ShouldEqual(1);
+ [Fact] void should_carry_the_then_read_model() => _specification.ThenReadModels.Count.ShouldEqual(1);
+ [Fact] void should_name_the_given_read_model() => _specification.GivenReadModels[0].Name.ShouldEqual("InvoiceScope");
+ [Fact] void should_carry_the_given_values() => Value(_specification.GivenReadModels[0], "phase").ShouldEqual("Contract");
+ [Fact] void should_carry_the_expected_values() => Value(_specification.ThenReadModels[0], "isStarted").ShouldEqual("True");
+
+ // Both steps name the same read model, so both resolve to the same identifier — the way the event and command
+ // steps already resolve what they refer to.
+ [Fact] void should_resolve_both_steps_to_the_same_read_model() =>
+ _specification.ThenReadModels[0].ReadModelId.ShouldEqual(_specification.GivenReadModels[0].ReadModelId);
+ [Fact] void should_give_each_step_its_own_identity() =>
+ _specification.ThenReadModels[0].Id.ShouldNotEqual(_specification.GivenReadModels[0].Id);
+
+ static string Value(SpecificationReadModel readModel, string property)
+ {
+ using var values = JsonDocument.Parse(readModel.Values);
+
+ return values.RootElement.GetProperty(property).ToString();
+ }
+}
diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_what_the_command_reads.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_what_the_command_reads.cs
new file mode 100644
index 0000000..18c37f2
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/when_inspecting_what_the_command_reads.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Specifications;
+using Cratis.Stage.Contracts.Commands;
+using Xunit;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader;
+
+public class when_inspecting_what_the_command_reads : given.a_compiled_model_using_2x_constructs
+{
+ IReadOnlyList _reads = [];
+
+ void Because() => _reads = _slice.Command!.Reads;
+
+ [Fact] void should_carry_the_declared_read() => _reads.Count.ShouldEqual(1);
+ [Fact] void should_name_the_read_model() => _reads[0].ReadModel.ShouldEqual("InvoiceScope");
+ [Fact] void should_carry_the_property_it_is_looked_up_by() => _reads[0].By.ShouldEqual("invoiceId");
+}
diff --git a/Source/Contracts/for_EventModelLoader/when_inspecting_where_a_produced_event_lands.cs b/Source/Contracts/for_EventModelLoader/when_inspecting_where_a_produced_event_lands.cs
new file mode 100644
index 0000000..f45be08
--- /dev/null
+++ b/Source/Contracts/for_EventModelLoader/when_inspecting_where_a_produced_event_lands.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Specifications;
+using Cratis.Stage.Contracts.Commands;
+using Xunit;
+
+namespace Cratis.Stage.Contracts.for_EventModelLoader;
+
+public class when_inspecting_where_a_produced_event_lands : given.a_compiled_model_using_2x_constructs
+{
+ ProducedEvent _own = null!;
+ ProducedEvent _elsewhere = null!;
+
+ void Because()
+ {
+ var produces = _slice.Command!.Produces;
+ _own = produces.Single(produced => produced.Event == "InvoiceActivated");
+ _elsewhere = produces.Single(produced => produced.Event == "ContractPolicyActivated");
+ }
+
+ // An event with no 'for' lands on the command's own event source — the common case, left unstated.
+ [Fact] void should_leave_the_event_source_unset_when_none_is_declared() => _own.For.ShouldBeNull();
+ [Fact] void should_carry_the_declared_event_source() => _elsewhere.For.ShouldNotBeNull();
+ [Fact] void should_resolve_the_event_source_from_a_command_property() =>
+ _elsewhere.For!.Kind.ShouldEqual(ProducedValueKind.CommandProperty);
+ [Fact] void should_name_the_property_the_event_source_comes_from() =>
+ _elsewhere.For!.Expression.ShouldEqual("contractId");
+}