From b2c62199699d42015a7347b6c6f7f8f37d4f6b74 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 12 Aug 2026 01:04:52 +0200 Subject: [PATCH 1/4] Fail the build on a binary break in a published contract The contract records are positional records, so adding a trailing optional parameter is source compatible and binary breaking: it replaces the constructor and Deconstruct in the compiled signature, and a consumer built against the older package fails at run time with a missing method and no compiler error anywhere. Studio consumes these records, so the fault would surface in the designer rather than here. Baselined at 2.0.0, the last released version of all three packable projects. The current contract passes it unchanged. --- Directory.Build.props | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 5b7bc41..9db9d2e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,6 +7,18 @@ true + + true + 2.0.0 + True True True From 6a16c8a763d1db784c5c1318b76be53436076a37 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 12 Aug 2026 01:05:00 +0200 Subject: [PATCH 2/4] Extract the produced-value and condition conversion Both are about to be needed by constructs beyond a produces declaration - a 'for' clause names an event source with the same expression vocabulary, and a 'require' rule carries the same condition tree a 'produces when' guard does. The language has one condition grammar, so the converter should be one too rather than a second tree that can drift from it. Pure move: no behavior changes. --- .../Screenplay/ConditionConverter.cs | 56 +++++++++ .../Screenplay/ProducedValueConverter.cs | 111 ++++++++++++++++ .../Contracts/Screenplay/ProducesConverter.cs | 119 +++--------------- 3 files changed, 183 insertions(+), 103 deletions(-) create mode 100644 Source/Contracts/Screenplay/ConditionConverter.cs create mode 100644 Source/Contracts/Screenplay/ProducedValueConverter.cs 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/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; } From b0c85e3215fdd04eda06aa61a16a230e273dc26d Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 12 Aug 2026 01:05:09 +0200 Subject: [PATCH 3/4] Give the contract somewhere to hold the Screenplay 2.x constructs Command authorization, 'require' rules, 'reads', where a produced event lands, event tags and specification read model steps had no member on the contract, so a document declaring them arrived at Studio with them gone. Every addition is an init property rather than a trailing constructor parameter, so none of it breaks a consumer compiled against 2.0.0. Authorization is a tree, not a list of policy names: a flat list cannot distinguish 'A or B and C' from '(A or B) and C', which is what makes it unable to answer whether a caller is allowed. Policies() gives the flat set to a consumer that only wants the names. The specification read model steps close an orphan - SpecificationRunStepKind already had ThenReadModel with nothing able to feed it. --- .../Commands/AuthorizationRequirement.cs | 54 +++++++++++++++ .../Contracts/Commands/CommandDefinition.cs | 26 ++++++- Source/Contracts/Commands/ProducedEvent.cs | 9 ++- .../Contracts/Commands/ProducedEventSource.cs | 18 +++++ Source/Contracts/Commands/ReadsDefinition.cs | 12 ++++ Source/Contracts/Commands/Requirement.cs | 18 +++++ Source/Contracts/EventModel.schema.json | 67 +++++++++++++++++-- Source/Contracts/Events/EventDefinition.cs | 8 ++- .../Contracts/Specifications/Specification.cs | 14 +++- .../Specifications/SpecificationReadModel.cs | 17 +++++ 10 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 Source/Contracts/Commands/AuthorizationRequirement.cs create mode 100644 Source/Contracts/Commands/ProducedEventSource.cs create mode 100644 Source/Contracts/Commands/ReadsDefinition.cs create mode 100644 Source/Contracts/Commands/Requirement.cs create mode 100644 Source/Contracts/Specifications/SpecificationReadModel.cs 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/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); From 6dbc2484a8c9537920648f23e0bc4e829e03bc87 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 12 Aug 2026 01:05:18 +0200 Subject: [PATCH 4/4] Fill the new contract members from the compiled document The members are useless until the converters populate them, so this is where the constructs actually start surviving an import. Concept compliance markers are the one that was not a fidelity nicety. SchemaSynthesizer resolves a concept to its underlying primitive, which erased @pii and @sensitive entirely - a billing contact address arrived indistinguishable from any other string, with no way for a compliance reader to find it. The concept name and its declared attributes, with the reason each was declared for, now travel on the property's schema node. The marker is stated, not enforced; Chronicle's own compliance keyword that drives encryption at rest is a separate and deliberate step. Specs compile real .play source through the loader rather than asserting on hand-built syntax trees, so they cover the whole import path. --- .../Screenplay/AuthorizationConverter.cs | 36 +++++++ .../Contracts/Screenplay/CommandConverter.cs | 20 +++- Source/Contracts/Screenplay/EventConverter.cs | 5 +- .../Contracts/Screenplay/SchemaSynthesizer.cs | 61 +++++++++++- .../Screenplay/SpecificationConverter.cs | 20 +++- .../a_compiled_model_using_2x_constructs.cs | 97 +++++++++++++++++++ ...en_inspecting_the_command_authorization.cs | 28 ++++++ ...hen_inspecting_the_command_requirements.cs | 29 ++++++ .../when_inspecting_the_concept_attributes.cs | 46 +++++++++ .../when_inspecting_the_event_tags.cs | 23 +++++ ...nspecting_the_specification_read_models.cs | 38 ++++++++ .../when_inspecting_what_the_command_reads.cs | 19 ++++ ...inspecting_where_a_produced_event_lands.cs | 29 ++++++ 13 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 Source/Contracts/Screenplay/AuthorizationConverter.cs create mode 100644 Source/Contracts/for_EventModelLoader/given/a_compiled_model_using_2x_constructs.cs create mode 100644 Source/Contracts/for_EventModelLoader/when_inspecting_the_command_authorization.cs create mode 100644 Source/Contracts/for_EventModelLoader/when_inspecting_the_command_requirements.cs create mode 100644 Source/Contracts/for_EventModelLoader/when_inspecting_the_concept_attributes.cs create mode 100644 Source/Contracts/for_EventModelLoader/when_inspecting_the_event_tags.cs create mode 100644 Source/Contracts/for_EventModelLoader/when_inspecting_the_specification_read_models.cs create mode 100644 Source/Contracts/for_EventModelLoader/when_inspecting_what_the_command_reads.cs create mode 100644 Source/Contracts/for_EventModelLoader/when_inspecting_where_a_produced_event_lands.cs 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/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/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/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"); +}