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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions Documentation/architecture/command-responses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Command responses and form continuations

A form's `onSubmit` actions run **after its native command succeeds**, not when the user presses Submit. Arc still owns form execution, validation, authorization and exception feedback. Scene forwards the native success response without executing the command again. An absent or empty `onSubmit` preserves the existing form behavior.

## Bind the successful response

For a command returning `{ "projectId": "created-id" }`, a form can navigate using that id:

```json
{
"name": "Create project",
"forCommand": "CreateProject",
"fields": [],
"onSubmit": [
{
"kind": "Navigate",
"screen": "project-detail",
"arguments": [
{
"name": "id",
"value": { "kind": "commandResponse", "path": "projectId", "expectedValueType": "string" }
}
]
}
]
}
```

The same binding works inside an `ExecuteCommandAction.onSuccess` list. An empty path selects the entire scalar or object response. False, zero and an empty string are present responses. For a directly returned address, `street` selects its street; for `{ "address": { "street": "Main" } }`, use `address.street`.

Paths traverse only own, named object properties. Missing members, inherited members, prototype paths, indexing, wildcards and array traversal are refused. An array-valued member can be copied whole when its item type fits the destination; a top-level collection response is not supported. A present nullable object makes projections through it nullable. A missing response or member is never made valid by `nullBehavior`. Arc omits top-level null responses, so they are unavailable to response-dependent actions.

## Supply source and destination contracts

Response contracts are producer-supplied validation inputs, not persisted command catalogs. TypeScript hosts implement `ResponseValidationContracts`; C# producers implement `IResponseValidationContracts`:

- `commandResponse(command)` returns `{ kind: 'void' }`, or `{ kind: 'value', type }`. Unknown metadata returns `undefined` in TypeScript or null in C#; unknown is not void.
- A value type has `kind: 'string'`, `'number'` or `'boolean'`; an object adds `properties`, and an array adds `item`. Any type can be `nullable`.
- `targetType(action, operand)` supplies the actual destination type. An operand is an `argument` with a `name`, a `stateValue`, or a `message`.

Normalize concepts to their actual JSON types and use emitted wire member names. Input schemas and response-kind flags alone do not describe a response's structure. Object destinations require assignable fields; extra source fields are allowed. Array items are checked structurally, primitives are not coerced, and nullable sources need nullable destinations. `expectedValueType` is an additional category check, never a replacement for source or destination metadata. Response bindings are one-way only.

Call `validateResponseBindings(entry, contracts)` in TypeScript or `ResponseBindings.Validate(entry, contracts)` in C#. An entry is either `{ kind: 'actions', actions }` or `{ kind: 'form', command, onSubmit }`. Validation walks every continuation, even a branch that is not selected at runtime. Response-independent actions need no metadata.

## Run in a lexical scope

Provide the same metadata through `InteractionContext.responseContracts` on the enclosing `InteractionScope`. Scene's shared runner and native form bridge use `commandResponseContext` to decorate the existing resolver; non-response bindings still delegate to it.

A successful command replaces the response only inside its `onSuccess` continuation. Failure continuations mask any enclosing response, including a payload returned with a failed outcome. Returning from a nested command restores the outer response; later siblings do not acquire a preceding command's response. Non-command continuations inherit their existing scope. Concurrent runs have no shared last-response state.

Before an effect runs, Scene checks response operands and their actual values. Missing data, unknown metadata or incompatible values produce an `invalidResponseBinding` finding and a `Reported` stop instead of dispatching an undefined argument. Navigated, Declined and Reported stops propagate through enclosing continuations. Response-independent success actions can run even when the command returns nothing.

Guarded selection is independent of response binding. These bindings do not introduce a guard predicate source or weaken command authorization.
2 changes: 2 additions & 0 deletions Documentation/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
href: architecture/binding-resolution.md
- name: Guarded actions and interactions
href: architecture/guarded-actions.md
- name: Command responses and form continuations
href: architecture/command-responses.md
- name: Glossary
href: glossary.md
- name: Packages
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// 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.Scene.Engine.Bindings;
using Cratis.Scene.Model.Common;
using Cratis.Scene.Model.Interactions;

namespace Cratis.Scene.Engine.for_ResponseBindings;

public class when_running_the_shared_corpus : Specification
{
JsonElement _corpus;
readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web);

void Establish()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) directory = directory.Parent;
_corpus = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "command-response-fixtures.json"))).RootElement;
}

[Fact]
void should_validate_every_shared_binding()
{
foreach (var entry in _corpus.GetProperty("cases").EnumerateArray())
{
var contracts = Substitute.For<IResponseValidationContracts>();
contracts.CommandResponse("create").Returns(entry.TryGetProperty("response", out var response) ? response.Deserialize<ResponseContract>(_options) : null);
contracts.TargetType(Arg.Any<InteractionAction>(), Arg.Any<ResponseOperand>()).Returns(entry.TryGetProperty("target", out var target) ? target.Deserialize<ResponseValueType>(_options) : null);
var binding = new BindingExpression(entry.GetProperty("path").GetString()!, BindingSourceKind.CommandResponse, Mode: entry.TryGetProperty("mode", out _) ? BindingMode.TwoWay : null, ExpectedValueType: entry.TryGetProperty("expectedValueType", out var expected) ? expected.GetString() : null);
var action = new SetStateAction("id", binding);
(ResponseBindings.Validate(new("form", Command: "create", OnSubmit: [action]), contracts).Count == 0).ShouldEqual(entry.GetProperty("valid").GetBoolean());
}
}

[Fact]
void should_validate_every_shared_lexical_scope()
{
var contracts = Substitute.For<IResponseValidationContracts>();
contracts.CommandResponse("known").Returns(new ResponseContract("value", new("string")));
contracts.TargetType(Arg.Any<InteractionAction>(), Arg.Any<ResponseOperand>()).Returns(new ResponseValueType("string"));
foreach (var entry in _corpus.GetProperty("scopes").EnumerateArray())
{
var actions = entry.GetProperty("actions").EnumerateArray().Select(ReadAction).ToArray();
ResponseBindings.Validate(new("actions", Actions: actions), contracts).Count.ShouldEqual(entry.GetProperty("diagnostics").GetInt32());
}
}

InteractionAction ReadAction(JsonElement entry)
{
InteractionAction action = entry.GetProperty("kind").GetString() switch
{
"ExecuteCommand" => new ExecuteCommandAction(entry.GetProperty("command").GetString()!),
"SetState" => new SetStateAction(entry.GetProperty("target").GetString()!, new(entry.GetProperty("value").GetProperty("path").GetString()!, BindingSourceKind.CommandResponse)),
"RefreshQuery" => new RefreshQueryAction(entry.GetProperty("query").GetString()!),
_ => throw new InvalidOperationException("Unknown fixture action")
};
return action with { OnSuccess = ReadContinuation(entry, "onSuccess"), OnFailure = ReadContinuation(entry, "onFailure"), OnResult = ReadContinuation(entry, "onResult") };
}

InteractionAction[] ReadContinuation(JsonElement entry, string name) => entry.TryGetProperty(name, out var actions) ? actions.EnumerateArray().Select(ReadAction).ToArray() : [];

[Fact]
void should_reject_response_reads_outside_success()
{
var contracts = Substitute.For<IResponseValidationContracts>();
var action = new SetStateAction("id", new("", BindingSourceKind.CommandResponse));
ResponseBindings.Validate(new("actions", Actions: [action]), contracts).Count.ShouldEqual(1);
}
}
21 changes: 21 additions & 0 deletions Source/DotNET/Engine/Bindings/IResponseValidationContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Scene.Model.Interactions;

namespace Cratis.Scene.Engine.Bindings;

/// <summary>Supplies semantic types without persisting a command catalog in Scene.</summary>
public interface IResponseValidationContracts
{
/// <summary>Looks up a command's response contract.</summary>
/// <param name="command">The resolved command name.</param>
/// <returns>The response contract, or null when metadata is unknown.</returns>
ResponseContract? CommandResponse(string command);

/// <summary>Looks up the actual destination type of a binding-bearing operand.</summary>
/// <param name="action">The action owning the destination.</param>
/// <param name="operand">The destination to inspect.</param>
/// <returns>The destination type, or null when metadata is unknown.</returns>
ResponseValueType? TargetType(InteractionAction action, ResponseOperand operand);
}
75 changes: 75 additions & 0 deletions Source/DotNET/Engine/Bindings/ResponseBindings.Types.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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.RegularExpressions;

namespace Cratis.Scene.Engine.Bindings;

public static partial class ResponseBindings
{
/// <summary>Projects a scalar, object or whole array member through own object properties.</summary>
/// <param name="type">The command's response value type.</param>
/// <param name="path">The dotted member path; empty selects a scalar or object response.</param>
/// <returns>The projected type, or null for an unsupported path.</returns>
public static ResponseValueType? ProjectType(ResponseValueType type, string path)
{
if (path.Length == 0) return RootType(type);
var selected = type;
foreach (var segment in path.Split('.'))
{
var property = MemberType(selected, segment);
if (property is null) return null;
selected = NullableProjection(selected, property);
}
return selected;
}

/// <summary>Checks recursive structural compatibility without coercion.</summary>
/// <param name="source">The projected response type.</param>
/// <param name="target">The actual destination type.</param>
/// <returns>Whether the destination accepts every value of the source type.</returns>
public static bool IsAssignable(ResponseValueType source, ResponseValueType target)
{
if (source.Nullable && !target.Nullable) return false;
if (source.Kind != target.Kind) return false;
return AssignableContents(source, target);
}

static ResponseValueType? RootType(ResponseValueType type) => type.Kind == "array" ? null : type;
static ResponseValueType NullableProjection(ResponseValueType parent, ResponseValueType property) => property with { Nullable = parent.Nullable || property.Nullable };

static bool AssignableContents(ResponseValueType source, ResponseValueType target) => source.Kind switch
{
"object" => ObjectAssignable(source, target),
"array" => ArrayAssignable(source, target),
_ => IsPrimitive(source.Kind)
};

static bool IsPrimitive(string kind) => kind == "string" || kind == "number" || kind == "boolean";

static bool ObjectAssignable(ResponseValueType source, ResponseValueType target)
{
if (target.Properties is null || source.Properties is null) return false;
return target.Properties.All(property => source.Properties.TryGetValue(property.Key, out var value) && IsAssignable(value, property.Value));
}

static bool ArrayAssignable(ResponseValueType source, ResponseValueType target)
{
if (source.Item is null || target.Item is null) return false;
return IsAssignable(source.Item, target.Item);
}

static IReadOnlyDictionary<string, ResponseValueType>? ObjectProperties(ResponseValueType type) => type.Kind == "object" ? type.Properties : null;

static ResponseValueType? MemberType(ResponseValueType type, string segment)
{
if (!ValidMemberName(segment)) return null;
return ObjectProperties(type)?.GetValueOrDefault(segment);
}

static bool ValidMemberName(string segment) => MemberName().IsMatch(segment) && !ProhibitedMember(segment);
static bool ProhibitedMember(string segment) => segment == "constructor" || segment == "prototype" || segment == "__proto__";

[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant, 1000)]
private static partial Regex MemberName();
}
103 changes: 103 additions & 0 deletions Source/DotNET/Engine/Bindings/ResponseBindings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Scene.Model.Common;
using Cratis.Scene.Model.Interactions;

namespace Cratis.Scene.Engine.Bindings;

/// <summary>Validates command-response bindings in lexical continuation scopes.</summary>
public static partial class ResponseBindings
{
static IReadOnlyDictionary<string, string[]> ExpectedCategories { get; } = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["json"] = ["string", "number", "boolean", "object", "array"],
["collection"] = ["array"],
["object"] = ["object"],
["string"] = ["string", "object"],
["enum"] = ["string", "object"],
["icon"] = ["string", "object"],
["destination"] = ["string", "object"],
["queryReference"] = ["string", "object"],
["number"] = ["number"],
["boolean"] = ["boolean"]
};

/// <summary>Validates every branch, including branches not reachable at runtime.</summary>
/// <param name="entry">The action-list or form-success entry point.</param>
/// <param name="contracts">The producer's source and destination metadata.</param>
/// <returns>Diagnostics for unavailable or incompatible response operands.</returns>
public static IReadOnlyList<BindingDiagnostic> Validate(ResponseValidationEntry entry, IResponseValidationContracts contracts)
{
var diagnostics = new List<BindingDiagnostic>();
var actions = entry.Kind == "form" ? entry.OnSubmit : entry.Actions;
Visit(Continuation(actions), EntryResponse(entry, contracts), contracts, diagnostics);
return diagnostics;
}

static ResponseContract? EntryResponse(ResponseValidationEntry entry, IResponseValidationContracts contracts)
{
if (entry.Kind != "form" || entry.Command is null) return null;
return contracts.CommandResponse(entry.Command);
}

static void Visit(IReadOnlyList<InteractionAction> actions, ResponseContract? response, IResponseValidationContracts contracts, List<BindingDiagnostic> diagnostics)
{
foreach (var action in actions)
{
ValidateAction(action, response, contracts, diagnostics);
Visit(Continuation(action.OnSuccess), SuccessResponse(action, response, contracts), contracts, diagnostics);
Visit(Continuation(action.OnFailure), FailureResponse(action, response), contracts, diagnostics);
Visit(Continuation(action.OnResult), response, contracts, diagnostics);
}
}

static IReadOnlyList<InteractionAction> Continuation(IReadOnlyList<InteractionAction>? actions) => actions ?? [];
static ResponseContract? SuccessResponse(InteractionAction action, ResponseContract? enclosing, IResponseValidationContracts contracts) => action is ExecuteCommandAction command ? contracts.CommandResponse(command.Command) : enclosing;
static ResponseContract? FailureResponse(InteractionAction action, ResponseContract? enclosing) => action is ExecuteCommandAction ? null : enclosing;
static ResponseValueType? ResponseType(ResponseContract? response) => response?.Kind == "value" ? response.Type : null;

static void ValidateAction(InteractionAction action, ResponseContract? response, IResponseValidationContracts contracts, List<BindingDiagnostic> diagnostics)
{
foreach (var (binding, operand) in Operands(action).Where(value => value.Binding.Kind == BindingSourceKind.CommandResponse))
{
if (!CompatibleOperand(binding, response, contracts.TargetType(action, operand))) diagnostics.Add(new("invalidResponseBinding", "Command response binding requires an available, compatible source and destination.", binding.Path));
}
}

static bool CompatibleOperand(BindingExpression binding, ResponseContract? response, ResponseValueType? target)
{
if (binding.Mode == BindingMode.TwoWay) return false;
var type = ResponseType(response);
if (type is null || target is null) return false;
return CompatibleProjection(binding, type, target);
}

static bool CompatibleProjection(BindingExpression binding, ResponseValueType type, ResponseValueType target)
{
var source = ProjectType(type, binding.Path);
if (source is null) return false;
return IsAssignable(source, target) && ExpectedCategory(source, binding.ExpectedValueType);
}

static IEnumerable<(BindingExpression Binding, ResponseOperand Operand)> Operands(InteractionAction action)
{
foreach (var argument in action.Arguments ?? []) yield return (argument.Value, new("argument", argument.Name));
if (action is SetStateAction state) yield return (state.Value, new("stateValue"));
var binding = MessageBinding(action);
if (binding is not null) yield return (binding, new("message"));
}

static BindingExpression? MessageBinding(InteractionAction action) => action switch
{
NotifyAction notify => notify.Message.Binding,
ConfirmAction confirm => confirm.Message.Binding,
_ => null
};

static bool ExpectedCategory(ResponseValueType type, string? expected)
{
if (expected is null) return true;
return ExpectedCategories.GetValueOrDefault(expected)?.Contains(type.Kind, StringComparer.Ordinal) ?? false;
}
}
9 changes: 9 additions & 0 deletions Source/DotNET/Engine/Bindings/ResponseContract.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Scene.Engine.Bindings;

/// <summary>A known command response, distinct from unavailable metadata.</summary>
/// <param name="Kind">Either void or value.</param>
/// <param name="Type">The structural type when the response is a value.</param>
public record ResponseContract(string Kind, ResponseValueType? Type = null);
9 changes: 9 additions & 0 deletions Source/DotNET/Engine/Bindings/ResponseOperand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Scene.Engine.Bindings;

/// <summary>Identifies an action's destination for structural validation.</summary>
/// <param name="Kind">Either argument, stateValue or message.</param>
/// <param name="Name">The argument name, when the operand is an argument.</param>
public record ResponseOperand(string Kind, string? Name = null);
Loading
Loading