Skip to content

Carry the Screenplay 2.x constructs on the contract - #27

Merged
woksin merged 5 commits into
mainfrom
feat/contracts-screenplay-2x
Aug 11, 2026
Merged

Carry the Screenplay 2.x constructs on the contract#27
woksin merged 5 commits into
mainfrom
feat/contracts-screenplay-2x

Conversation

@woksin

@woksin woksin commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Added

Fixed

  • A binary break in a published contract now fails the build instead of surfacing as a missing-method fault in a consumer at run time.

woksin added 4 commits August 12, 2026 01:04
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.
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.
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.
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.
@woksin woksin added the minor label Aug 11, 2026
@woksin

woksin commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context — not release notes.

The premise this was planned on is wrong, and the build now proves it

The plan was "copy Screenplay's discipline: trailing optional parameters plus init properties." Trailing optional parameters are not safe — they are a hard binary break, and Screenplay's own newest guidance says so at QuerySyntax.Scope:

A trailing parameter on a positional record is source compatible and binary breaking: it replaces the constructor and Deconstruct in the compiled signature […] Adding capability as an init property is neither, and is how this record should grow from here.

The trailing optionals in Screenplay (SliceSyntax.ReadModels, CommandSyntax.Reads, ProducesSyntax.For) are the old pattern — they are what broke Cratis.Arc.Screenplay between 1.5.2 and 1.7.0, which is why package validation was added there.

I turned package validation on first and measured it rather than reasoning about it. Adding IReadOnlyList<string>? Tags = null as a trailing optional to EventDefinition:

error CP0002: Member 'EventDefinition.EventDefinition(Guid, string, string, string,
  UniqueEventTypeConstraint?, UniqueConstraint?)' exists on [Baseline] but not on
  lib/net10.0/Cratis.Stage.Contracts.dll
error CP0002: Member 'void EventDefinition.Deconstruct(...)' exists on [Baseline] but not on ...
error : API breaking changes found.

The same addition as an init property packs clean. So everything here is an init property, and the collections are non-nullable defaulting to [] — which is also nicer for Studio than the nullable trailing optionals Screenplay has. dotnet pack -c Release is the gate and it passes.

This is why the PR is minor, and now it is checkable rather than asserted.

Second correction: require does not hang off the command

RequirementSyntax is on DeclarativeValidateSyntax.Requirements, not CommandSyntaxrequire lives inside a validate block, beside the per-property rules. CommandConverter reaches through the validate blocks, the same ones ValidationRuleConverter already walks.

Two design calls that differ from the plan — please rule

1. ProducedEvent.For is a new ProducedEventSource(Kind, Expression), not ProducedEventProperty?. ProducedEventProperty carries a Property name identifying a slot on the event payload. A for clause names the event source the event is appended to, so that field would have been permanently empty — a lie in the contract that Studio would have to know to ignore. Confirmed against the language docs: "Only one for per produces: an event is appended to one event source."

2. LogicalRequirement reuses ProducedEventLogicalOperator rather than getting its own And/Or enum. Screenplay has exactly one LogicalOperator shared by conditions and policy requirements, and I mirrored that — one logical operator in the contract. The cost is the name: LogicalRequirement { Operator: ProducedEventLogicalOperator.Or } reads badly for a policy. The alternative is a second identically-shaped enum in the same namespace, which is worse while require sits next to it using the first one. I think the rename belongs in the deliberate Tier-2 major alongside the cardinality changes — say if you would rather take the duplicate now.

Concept attributes: carried, deliberately not enforced

This turned out to be bigger than expected. Chronicle already has a compliance schema format, and Stage feeds these schemas straight to it (StageChronicleDefinitions.cs:81,84):

"compliance": [ { "metadataType": "PII", "details": "<reason>" } ]

ComplianceJsonSchemaExtensions.ComplianceKey, parsed by ParseFromJsonArray; ComplianceMetadataType.PII is the constant. Both fields must be non-null or the entry is silently dropped.

Emitting that would mean a @pii concept in a .play produces values actually encrypted at rest by the Chronicle kernel — the real fix, not just a marker. I did not do it here. It drives kernel-side encryption requiring IEncryptionKeyStorage and subject resolution, which I cannot verify end to end in this repo, and turning it on blind is how a green build becomes a broken append. This PR carries the marker (x-concept / x-conceptAttributes, following Chronicle's own x-enumNames extension convention) so nothing is lost on import; the enforcement step deserves its own PR with a real round trip behind it. Worth filing — that is where the compliance regression actually gets closed.

Known limitation, unchanged by this PR

SpecificationReadModel.ReadModelId resolves as {slicePath}.readmodel.{name} — same-slice, by name. A spec referencing a read model built in another slice gets an id that resolves to nothing. This is exactly how the existing Given/ThenEvents/When steps already resolve events and commands, so I kept it consistent rather than inventing cross-slice resolution in this PR. Same applies to ReadsDefinition.ReadModel, which is carried as a name and not resolved at all.

Also unchanged, as agreed: the Tier-2 cardinality break (Slice.CommandCommands, Slice.ReadModelReadModels, ReadModelDefinition.ProjectionProjections). All three together, one deliberate major.

Verification

dotnet build -c Debug    → 6 projects, 0 errors, 0 warnings
dotnet build -c Release  → 6 projects, 0 errors, 0 warnings
dotnet test  -c Debug    → Contracts 125 (was 90), Stage 19, Rendering.Cratis 211 — 0 failed
dotnet pack  -c Release  → 3 packages, no CP0002 against the 2.0.0 baseline

The 35 new specs were mutation-tested rather than trusted. Neutering each converter in turn (Authorization = null, Requirements = [], Reads = [], For = null, the concept annotation, Tags = [], the spec read models) fails 31 of them; only the deliberate negative assertions — "leaves the event source unset when none is declared", "no tags on an untagged event" — stay green under an emptying mutation, which is correct. The specs compile real .play source through EventModelLoader, so they cover the whole import path rather than a hand-built syntax tree.

The updated EventModel.schema.json was validated, not eyeballed. Every definition carries additionalProperties: false, so a stale schema would have rejected the new members. I serialized a model carrying all of them and ran it through the schema: 0 errors. Sample of the authorization wire form:

{ "kind": "logical",
  "Left":  { "kind": "policy", "Policy": "IsAccountant" },
  "Operator": "Or",
  "Right": { "kind": "logical",
             "Left":  { "kind": "policy", "Policy": "IsFinance" },
             "Operator": "And",
             "Right": { "kind": "policy", "Policy": "OwnsInvoice" } } }

IsAccountant or IsFinance and OwnsInvoiceand bound tighter, and the spec asserts that shape specifically, because it is the thing a flat list could not have represented.

Stacked on #25 (base is fix/release-build-roslynator), which restores the Release build — main has not built in Release since the Roslynator 4.16.0 bump. GitHub retargets this to main when #25 merges.

@woksin
woksin changed the base branch from fix/release-build-roslynator to main August 11, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant