From e4545102e22b675adea12f791645c994dd01a33c Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 7 Sep 2026 15:24:54 +0800 Subject: [PATCH 1/3] Support targeted workflow delivery service bindings Allow delivery packages to map each external connection slot to an explicit workflow YAML location so multi-service merchant workflows can stay package-driven instead of runtime-special-cased. Co-Authored-By: Claude Opus 4.6 --- .../WorkflowDeliveryGAgent.cs | 6 ++ .../workflow_delivery_messages.proto | 1 + .../Abstractions/WorkflowDeliveryContracts.cs | 3 +- .../WorkflowDeliveryConfigurationRenderer.cs | 49 ++----------- .../Delivery/WorkflowDeliveryOptions.cs | 2 + .../WorkflowDeliveryPackageCatalog.cs | 7 +- .../Delivery/WorkflowDeliveryService.cs | 4 +- ...rDispatchWorkflowDeliveryCommandService.cs | 1 + .../ProjectionWorkflowDeliveryQueryPort.cs | 2 +- ...atchWorkflowDeliveryCommandServiceTests.cs | 7 +- ...kflowDeliveryConfigurationRendererTests.cs | 68 +++++++++++++++++++ .../WorkflowDeliveryGAgentTests.cs | 1 + .../WorkflowDeliveryPackageCatalogTests.cs | 16 +++++ .../WorkflowDeliveryServiceTests.cs | 3 +- 14 files changed, 121 insertions(+), 49 deletions(-) diff --git a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs index 5f91e3274b..f54750b2a5 100644 --- a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs +++ b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs @@ -947,6 +947,12 @@ WorkflowDeliveryAcceptanceMode.AutomaticPreview or string.IsNullOrWhiteSpace(package.AcceptancePolicy.Limitation)) throw new InvalidOperationException("manual workflow delivery acceptance policy requires a limitation."); WorkflowDeliveryConventions.ValidateAcceptanceInput(package.AcceptancePolicy.Input); + foreach (var slot in package.ConnectionSlots) + { + WorkflowDeliveryConventions.NormalizeRequired(slot.Key, "connection_slot.key"); + WorkflowDeliveryConventions.NormalizeRequired(slot.ServiceSlug, "connection_slot.service_slug"); + WorkflowDeliveryConventions.NormalizeRequired(slot.YamlPointer, "connection_slot.yaml_pointer"); + } var expectedPackageHash = WorkflowDeliveryConventions.ComputePackageHash(package); if (!string.Equals(package.PackageHash, expectedPackageHash, StringComparison.Ordinal)) throw new InvalidOperationException("workflow delivery package hash does not match its immutable content."); diff --git a/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto b/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto index c26069c18a..8c0c99af06 100644 --- a/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto +++ b/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto @@ -98,6 +98,7 @@ message WorkflowDeliveryConnectionSlotDefinition { string label = 2; string service_slug = 3; bool required = 4; + string yaml_pointer = 5; } enum WorkflowDeliveryAcceptanceDateProjection { diff --git a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs index 23ac0ed353..4e7b0ecd9d 100644 --- a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs +++ b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs @@ -101,7 +101,8 @@ public sealed record WorkflowDeliveryConnectionSlotDefinition( string Key, string Label, string ServiceSlug, - bool Required); + bool Required, + string YamlPointer); public enum WorkflowDeliveryAcceptanceDateProjection { diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs index 2d0aa6c58c..e44af63f9b 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs @@ -109,17 +109,14 @@ public WorkflowDeliveryRenderResult Render( } var resolvedConnections = ResolveConnections(package, connectionReferences); - if (resolvedConnections.Count != 0) + foreach (var (slotKey, userServiceId) in resolvedConnections) { - var replacements = ReplaceUserServiceIds( - stream.Documents[0].RootNode, - resolvedConnections.Values.Single()); - if (replacements == 0) - { - throw new WorkflowDeliveryConfigurationException( - "CONNECTION_BINDING_NOT_FOUND", - "The workflow package has no structured user_service_id fields for its connection slot."); - } + var slot = package.ConnectionSlots.Single(item => string.Equals(item.Key, slotKey, StringComparison.Ordinal)); + var yamlNode = ResolveYamlPointer(stream.Documents[0].RootNode, slot.YamlPointer); + if (yamlNode is not YamlScalarNode scalar) + throw InvalidPointer(slot.Key); + scalar.Value = userServiceId; + scalar.Style = ScalarStyle.Plain; } string resolvedYaml; @@ -156,8 +153,6 @@ private static IReadOnlyDictionary ResolveConnections( } resolved.Add(slot.Key, raw.Trim()); } - if (resolved.Count > 1) - throw new WorkflowDeliveryConfigurationException("MULTIPLE_CONNECTIONS_UNSUPPORTED", "This MVP supports one connection slot per workflow package."); return resolved; } @@ -260,36 +255,6 @@ JsonArray array when int.TryParse(segments[i], NumberStyles.None, CultureInfo.In } } - private static int ReplaceUserServiceIds(YamlNode node, string userServiceId) - { - var count = 0; - switch (node) - { - case YamlMappingNode mapping: - foreach (var pair in mapping.Children) - { - if (pair.Key is YamlScalarNode key && - string.Equals(key.Value, "user_service_id", StringComparison.Ordinal) && - pair.Value is YamlScalarNode value) - { - value.Value = userServiceId; - value.Style = ScalarStyle.Plain; - count++; - } - else - { - count += ReplaceUserServiceIds(pair.Value, userServiceId); - } - } - break; - case YamlSequenceNode sequence: - foreach (var child in sequence.Children) - count += ReplaceUserServiceIds(child, userServiceId); - break; - } - return count; - } - private static string DecodePointerSegment(string value) => value.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal); diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs index 1ff05a537a..93dda46fbe 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs @@ -84,6 +84,8 @@ public sealed class WorkflowDeliveryConnectionSlotOptions public string ServiceSlug { get; set; } = string.Empty; + public string YamlPointer { get; set; } = string.Empty; + public bool Required { get; set; } } diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs index 3d79dd5eb1..3fc63d3f10 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs @@ -174,6 +174,7 @@ private static WorkflowDeliveryConnectionSlotDefinition ToProto(ConnectionSlotDe Label = value.Label, ServiceSlug = value.ServiceSlug, Required = value.Required, + YamlPointer = value.YamlPointer, }; private static PackageDefinition ToDefinition(WorkflowDeliveryPackageOptions options) @@ -195,7 +196,8 @@ private static PackageDefinition ToDefinition(WorkflowDeliveryPackageOptions opt NormalizeRequired(value.Key, "delivery connection slot key"), NormalizeRequired(value.Label, "delivery connection slot label"), NormalizeRequired(value.ServiceSlug, "delivery connection service slug"), - value.Required)).ToArray(); + value.Required, + NormalizeRequired(value.YamlPointer, "delivery connection yaml pointer"))).ToArray(); if (connectionSlots.Select(static value => value.Key).Distinct(StringComparer.Ordinal).Count() != connectionSlots.Length) throw new InvalidOperationException("Delivery package connection slot keys must be unique."); @@ -413,7 +415,8 @@ private sealed record ConnectionSlotDefinition( string Key, string Label, string ServiceSlug, - bool Required); + bool Required, + string YamlPointer); } public sealed class WorkflowDeliveryPackageNotAllowedException(string workflowName) diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs index 676b757256..f4ab972c8c 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs @@ -1099,7 +1099,8 @@ private static PackageSnapshot ToContract(WorkflowPackageVersionSnapshot package item.Key, item.Label, item.ServiceSlug, - item.Required)).ToArray(), + item.Required, + item.YamlPointer)).ToArray(), package.Capabilities.ToArray(), package.RiskSummary, package.ParserDiagnostics.ToArray(), @@ -1142,6 +1143,7 @@ private static WorkflowPackageVersionSnapshot ToActorPackage(PackageSnapshot pac Label = item.Label, ServiceSlug = item.ServiceSlug, Required = item.Required, + YamlPointer = item.YamlPointer, })); result.Capabilities.Add(package.Capabilities); result.ParserDiagnostics.Add(package.ParserDiagnostics); diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs index da494b2d11..4811c8d3fe 100644 --- a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs @@ -520,6 +520,7 @@ private static WorkflowDeliveryConnectionSlotDefinition MapConnectionSlot( Label = value.Label, ServiceSlug = value.ServiceSlug, Required = value.Required, + YamlPointer = value.YamlPointer, }; private static WorkflowDeliveryTriggerIntent MapTrigger(DeliveryApplication.WorkflowDeliveryTriggerIntent value) diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs index a2bc4334da..bdcaee59f9 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs @@ -315,7 +315,7 @@ private static ApplicationVariableDefinition MapVariable( private static ApplicationConnectionSlotDefinition MapConnectionSlot( Aevatar.GAgents.WorkflowDelivery.WorkflowDeliveryConnectionSlotDefinition value) => - new(value.Key, value.Label, value.ServiceSlug, value.Required); + new(value.Key, value.Label, value.ServiceSlug, value.Required, value.YamlPointer); private static WorkflowDeliveryConnectionSnapshot MapConnection( WorkflowDeliveryConnectionState value) => diff --git a/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs index 187bc9e659..6a7ef058c0 100644 --- a/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs +++ b/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs @@ -354,7 +354,12 @@ private static WorkflowDeliveryPackageSnapshot Package(bool inputDeclared = true null, "10"), ], - [new ApplicationConnectionSlotDefinition("mail", "Mail", "lark", true)], + [new ApplicationConnectionSlotDefinition( + "mail", + "Mail", + "lark", + true, + "/steps/0/capability/nyxid_request/user_service_id")], ["network.write"], "Writes a notification", [], diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs index af44933e19..932838e110 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs @@ -131,6 +131,73 @@ public void Render_WhenOptionalConfigurationFieldIsMissing_ShouldKeepPackageDefa result.ResolvedYaml.Should().Contain("\"keep\":\"yes\""); } + [Fact] + public void Render_ShouldResolveMultipleConnectionSlotsByDeclaredYamlPointers() + { + const string sourceYaml = """ + name: merchant-intake-workflow + description: 'user_service_id: decoy' + steps: + - id: calendar + type: tool_call + capability: + nyxid_request: + user_service_id: calendar-placeholder + - id: document + type: tool_call + capability: + nyxid_request: + user_service_id: document-placeholder + """; + var package = new WorkflowPackageVersionSnapshot + { + PackageId = "merchant-intake-workflow", + PackageVersionId = "merchant-intake-workflow@source-alpha", + WorkflowName = "merchant-intake-workflow", + Version = "1", + DisplayName = "Merchant Intake Workflow", + SourceYaml = sourceYaml, + SourceHash = Hash(sourceYaml), + CreatedBy = "admin-alpha", + }; + package.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotDefinition + { + Key = "calendar", + Label = "Calendar", + ServiceSlug = "service-calendar", + Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", + }); + package.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotDefinition + { + Key = "document", + Label = "Document", + ServiceSlug = "service-document", + Required = true, + YamlPointer = "/steps/1/capability/nyxid_request/user_service_id", + }); + var renderer = new WorkflowDeliveryConfigurationRenderer(); + + var result = renderer.Render( + package, + null, + new Dictionary + { + ["calendar"] = "user-service-calendar", + ["document"] = "user-service-document", + }); + + result.ConnectionReferences.Should().Contain("calendar", "user-service-calendar"); + result.ConnectionReferences.Should().Contain("document", "user-service-document"); + var root = ParseRoot(result.ResolvedYaml); + Scalar(root, "description").Value.Should().Be("user_service_id: decoy"); + var steps = (YamlSequenceNode)Child(root, "steps"); + var calendar = (YamlMappingNode)Child((YamlMappingNode)Child((YamlMappingNode)steps.Children[0], "capability"), "nyxid_request"); + var document = (YamlMappingNode)Child((YamlMappingNode)Child((YamlMappingNode)steps.Children[1], "capability"), "nyxid_request"); + Scalar(calendar, "user_service_id").Value.Should().Be("user-service-calendar"); + Scalar(document, "user_service_id").Value.Should().Be("user-service-document"); + } + private static WorkflowPackageVersionSnapshot Package() { var package = new WorkflowPackageVersionSnapshot @@ -160,6 +227,7 @@ private static WorkflowPackageVersionSnapshot Package() Label = "Mail", ServiceSlug = "api-lark-bot", Required = true, + YamlPointer = "/steps/1/capability/nyxid_request/user_service_id", }); return package; } diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs index 706b580d6b..089b440f37 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs @@ -1392,6 +1392,7 @@ private static CreateWorkflowDeliveryCommand CreateCommandWithConnectionSlot() Label = "Lark", ServiceSlug = "api-lark", Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", }); command.Package.PackageHash = WorkflowDeliveryConventions.ComputePackageHash(command.Package); command.Package.Version = command.Package.PackageHash[..16]; diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs index 0a4d6e02ad..bc8a12338c 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs @@ -56,6 +56,7 @@ public async Task ListAsync_ShouldExposeExactlyTheExternallyConfiguredParseableP packages.Should().OnlyContain(static package => package.PackageId == package.WorkflowName && package.SourceYaml.Length > 0 && + package.ConnectionSlots.All(slot => slot.YamlPointer == "/steps/0/capability/nyxid_request/user_service_id") && package.AcceptancePolicy.Input.Literals.Fields.ContainsKey("dry_run")); var parser = new WorkflowParser(); @@ -119,6 +120,7 @@ public async Task GetAsync_PackageIdentity_ShouldCoverAllImmutableDeliverySemant { Mutate(package, value => value.VariableSchema[0].Label += " changed"), Mutate(package, value => value.ConnectionSlots[0].ServiceSlug += "-changed"), + Mutate(package, value => value.ConnectionSlots[0].YamlPointer += "-changed"), Mutate(package, value => value.Capabilities.Add("new.capability")), Mutate(package, value => value.RiskSummary += " changed"), Mutate(package, value => value.ParserDiagnostics.Add("new diagnostic")), @@ -214,6 +216,19 @@ await action.Should().ThrowAsync() .WithMessage("*delivery display name is required*"); } + [Fact] + public async Task StartupProbe_WhenConnectionSlotDoesNotDeclareYamlPointer_ShouldFailHostStartup() + { + var definition = Package("workflow-alpha"); + definition.ConnectionSlots[0].YamlPointer = " "; + var probe = new WorkflowDeliveryPackageCatalogStartupProbe(CreateCatalog([definition])); + + var action = () => probe.StartAsync(CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("*delivery connection yaml pointer is required*"); + } + [Fact] public async Task StartupProbe_WhenConfiguredPackageSourceIsMissing_ShouldFailHostStartup() { @@ -536,6 +551,7 @@ private static WorkflowDeliveryPackageOptions Package(string workflowName) => Label = "Provider", ServiceSlug = "provider-alpha", Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", }, ], Acceptance = new WorkflowDeliveryAcceptanceOptions diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs index 7bee3dfb1a..50a13c5bf6 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs @@ -1069,7 +1069,8 @@ private static WorkflowDeliverySnapshot DeliveryWithLarkSlot() "lark", "Lark", "api-lark", - true), + true, + "/steps/0/capability/nyxid_request/user_service_id"), ], }, }; From 3d74029859f3864fd60aec645661b087417cc2a8 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 7 Sep 2026 17:14:52 +0800 Subject: [PATCH 2/3] Reject duplicate workflow delivery connection pointers Fail closed when package connection slots target the same YAML location so each external service binding remains unambiguous through catalog, actor, and projection paths. Co-Authored-By: Claude Opus 4.6 --- .../WorkflowDeliveryGAgent.cs | 2 ++ .../WorkflowDeliveryPackageCatalog.cs | 2 ++ ...rojectionWorkflowDeliveryQueryPortTests.cs | 13 +++++++++++ .../WorkflowDeliveryGAgentTests.cs | 22 +++++++++++++++++++ .../WorkflowDeliveryPackageCatalogTests.cs | 20 +++++++++++++++++ 5 files changed, 59 insertions(+) diff --git a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs index f54750b2a5..307278f3dd 100644 --- a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs +++ b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs @@ -966,6 +966,8 @@ WorkflowDeliveryAcceptanceMode.AutomaticPreview or throw new InvalidOperationException("workflow delivery variable keys must be unique."); if (package.ConnectionSlots.Select(x => x.Key).Distinct(StringComparer.Ordinal).Count() != package.ConnectionSlots.Count) throw new InvalidOperationException("workflow delivery connection slot keys must be unique."); + if (package.ConnectionSlots.Select(x => x.YamlPointer).Distinct(StringComparer.Ordinal).Count() != package.ConnectionSlots.Count) + throw new InvalidOperationException("workflow delivery connection slot yaml pointers must be unique."); } private void ValidateInstallation(StartWorkflowInstallationCommand command) diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs index 3fc63d3f10..9f9f4f8721 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs @@ -200,6 +200,8 @@ private static PackageDefinition ToDefinition(WorkflowDeliveryPackageOptions opt NormalizeRequired(value.YamlPointer, "delivery connection yaml pointer"))).ToArray(); if (connectionSlots.Select(static value => value.Key).Distinct(StringComparer.Ordinal).Count() != connectionSlots.Length) throw new InvalidOperationException("Delivery package connection slot keys must be unique."); + if (connectionSlots.Select(static value => value.YamlPointer).Distinct(StringComparer.Ordinal).Count() != connectionSlots.Length) + throw new InvalidOperationException("Delivery package connection slot yaml pointers must be unique."); var acceptance = options.Acceptance ?? throw new InvalidOperationException( "Delivery package acceptance policy is required."); diff --git a/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs b/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs index 4089783fdd..e54bfba60b 100644 --- a/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs +++ b/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs @@ -42,6 +42,8 @@ public async Task GetForScopeAsync_ShouldFilterBeforeMappingAndReturnTypedImmuta -2)); snapshot.Package.AcceptancePolicy.Input.Bindings[1].Source.Should().BeOfType< DeliveryApplication.WorkflowDeliveryAuthenticatedOwnerExternalUserIdInput>(); + snapshot.Package.ConnectionSlots.Should().ContainSingle().Which.YamlPointer + .Should().Be("/steps/0/capability/nyxid_request/user_service_id"); snapshot.LifecycleStatus.Should().Be(DeliveryApplication.WorkflowDeliveryLifecycleStatus.Active); snapshot.Installation!.Status.Should().Be(DeliveryApplication.WorkflowInstallationStatus.Ready); snapshot.Installation.AcceptanceInput.Should().NotBeNull(); @@ -300,6 +302,17 @@ private static WorkflowDeliveryCurrentStateDocument ValidDocument() => }, CreatedBy = "admin-alpha", CreatedAtUtc = At(0), + ConnectionSlots = + { + new WorkflowDeliveryConnectionSlotDefinition + { + Key = "calendar", + Label = "Calendar", + ServiceSlug = "service-calendar", + Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", + }, + }, }, TargetScopeId = "scope-alpha", ExpiresAtUtc = At(8), diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs index 089b440f37..5d3e634b97 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs @@ -110,6 +110,28 @@ await action.Should().ThrowAsync() agent.EventSourcing!.CurrentVersion.Should().Be(0); } + [Fact] + public async Task Create_WhenConnectionSlotYamlPointersAreDuplicated_ShouldRejectBeforeCommit() + { + var agent = await CreateAgentAsync("delivery-alpha"); + var command = CreateCommandWithConnectionSlot(); + command.Package.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotDefinition + { + Key = "calendar-secondary", + Label = "Calendar Secondary", + ServiceSlug = "api-calendar-secondary", + Required = true, + YamlPointer = command.Package.ConnectionSlots[0].YamlPointer, + }); + ResealPackage(command.Package); + + var action = () => agent.HandleCreateAsync(command); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must be unique*"); + agent.EventSourcing!.CurrentVersion.Should().Be(0); + } + [Fact] public async Task DuplicateCreate_WithDefaultExpiryClockDrift_ShouldKeepFirstExpiryWhileExplicitDriftConflicts() { diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs index bc8a12338c..b6d6a58ea3 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs @@ -229,6 +229,26 @@ await action.Should().ThrowAsync() .WithMessage("*delivery connection yaml pointer is required*"); } + [Fact] + public async Task StartupProbe_WhenConnectionSlotYamlPointersAreDuplicated_ShouldFailHostStartup() + { + var definition = Package("workflow-alpha"); + definition.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotOptions + { + Key = "provider-secondary", + Label = "Provider Secondary", + ServiceSlug = "provider-beta", + Required = true, + YamlPointer = definition.ConnectionSlots[0].YamlPointer, + }); + var probe = new WorkflowDeliveryPackageCatalogStartupProbe(CreateCatalog([definition])); + + var action = () => probe.StartAsync(CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must be unique*"); + } + [Fact] public async Task StartupProbe_WhenConfiguredPackageSourceIsMissing_ShouldFailHostStartup() { From 8da4cb27d3ef0e86c1bb91ca69200d4d759e0682 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 7 Sep 2026 18:31:16 +0800 Subject: [PATCH 3/3] Harden workflow delivery connection rendering. Fail closed when delivery connection YAML pointers overlap configuration fields, and clear omitted optional connection slots so package defaults cannot leak into installed workflows. Co-Authored-By: Claude Opus 4.6 --- .../WorkflowDeliveryGAgent.cs | 3 + .../WorkflowDeliveryConfigurationRenderer.cs | 5 +- .../WorkflowDeliveryPackageCatalog.cs | 3 + ...kflowDeliveryConfigurationRendererTests.cs | 24 +++++ .../WorkflowDeliveryGAgentTests.cs | 24 +++++ .../WorkflowDeliveryPackageCatalogTests.cs | 13 +++ .../WorkflowDeliveryServiceTests.cs | 89 ++++++++++++++++++- 7 files changed, 156 insertions(+), 5 deletions(-) diff --git a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs index 307278f3dd..9bec345409 100644 --- a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs +++ b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs @@ -968,6 +968,9 @@ WorkflowDeliveryAcceptanceMode.AutomaticPreview or throw new InvalidOperationException("workflow delivery connection slot keys must be unique."); if (package.ConnectionSlots.Select(x => x.YamlPointer).Distinct(StringComparer.Ordinal).Count() != package.ConnectionSlots.Count) throw new InvalidOperationException("workflow delivery connection slot yaml pointers must be unique."); + var variableYamlPointers = package.VariableSchema.Select(x => x.YamlPointer).ToHashSet(StringComparer.Ordinal); + if (package.ConnectionSlots.Any(x => variableYamlPointers.Contains(x.YamlPointer))) + throw new InvalidOperationException("workflow delivery connection slot yaml pointers must not overlap variable yaml pointers."); } private void ValidateInstallation(StartWorkflowInstallationCommand command) diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs index e44af63f9b..7907ad5faf 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs @@ -109,13 +109,12 @@ public WorkflowDeliveryRenderResult Render( } var resolvedConnections = ResolveConnections(package, connectionReferences); - foreach (var (slotKey, userServiceId) in resolvedConnections) + foreach (var slot in package.ConnectionSlots) { - var slot = package.ConnectionSlots.Single(item => string.Equals(item.Key, slotKey, StringComparison.Ordinal)); var yamlNode = ResolveYamlPointer(stream.Documents[0].RootNode, slot.YamlPointer); if (yamlNode is not YamlScalarNode scalar) throw InvalidPointer(slot.Key); - scalar.Value = userServiceId; + scalar.Value = resolvedConnections.GetValueOrDefault(slot.Key) ?? string.Empty; scalar.Style = ScalarStyle.Plain; } diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs index 9f9f4f8721..1ace130512 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs @@ -202,6 +202,9 @@ private static PackageDefinition ToDefinition(WorkflowDeliveryPackageOptions opt throw new InvalidOperationException("Delivery package connection slot keys must be unique."); if (connectionSlots.Select(static value => value.YamlPointer).Distinct(StringComparer.Ordinal).Count() != connectionSlots.Length) throw new InvalidOperationException("Delivery package connection slot yaml pointers must be unique."); + var variableYamlPointers = variables.Select(static value => value.YamlPointer).ToHashSet(StringComparer.Ordinal); + if (connectionSlots.Any(value => variableYamlPointers.Contains(value.YamlPointer))) + throw new InvalidOperationException("Delivery package connection slot yaml pointers must not overlap variable yaml pointers."); var acceptance = options.Acceptance ?? throw new InvalidOperationException( "Delivery package acceptance policy is required."); diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs index 932838e110..d5332b9ee8 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs @@ -131,6 +131,30 @@ public void Render_WhenOptionalConfigurationFieldIsMissing_ShouldKeepPackageDefa result.ResolvedYaml.Should().Contain("\"keep\":\"yes\""); } + [Fact] + public void Render_WhenOptionalConnectionSlotIsMissing_ShouldClearPackageDefault() + { + var package = Package(); + package.ConnectionSlots[0].Required = false; + var renderer = new WorkflowDeliveryConfigurationRenderer(); + + var result = renderer.Render( + package, + new Dictionary + { + ["threshold"] = Json("25"), + }, + null); + + result.ConnectionReferences.Should().BeEmpty(); + var root = ParseRoot(result.ResolvedYaml); + var steps = (YamlSequenceNode)Child(root, "steps"); + var call = (YamlMappingNode)steps.Children[1]; + var capability = (YamlMappingNode)Child(call, "capability"); + var request = (YamlMappingNode)Child(capability, "nyxid_request"); + Scalar(request, "user_service_id").Value.Should().BeEmpty(); + } + [Fact] public void Render_ShouldResolveMultipleConnectionSlotsByDeclaredYamlPointers() { diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs index 5d3e634b97..6a9c341eb0 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs @@ -132,6 +132,30 @@ await action.Should().ThrowAsync() agent.EventSourcing!.CurrentVersion.Should().Be(0); } + [Fact] + public async Task Create_WhenConnectionSlotYamlPointerOverlapsVariable_ShouldRejectBeforeCommit() + { + var agent = await CreateAgentAsync("delivery-alpha"); + var command = CreateCommandWithConnectionSlot(); + command.Package.VariableSchema.Add(new WorkflowDeliveryVariableDefinition + { + Key = "threshold", + Label = "Threshold", + Description = "Approval threshold", + Kind = WorkflowDeliveryVariableKind.Integer, + Required = true, + YamlPointer = "/steps/0/parameters/value", + }); + command.Package.ConnectionSlots[0].YamlPointer = command.Package.VariableSchema[0].YamlPointer; + ResealPackage(command.Package); + + var action = () => agent.HandleCreateAsync(command); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must not overlap variable yaml pointers*"); + agent.EventSourcing!.CurrentVersion.Should().Be(0); + } + [Fact] public async Task DuplicateCreate_WithDefaultExpiryClockDrift_ShouldKeepFirstExpiryWhileExplicitDriftConflicts() { diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs index b6d6a58ea3..dca0a242ed 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs @@ -249,6 +249,19 @@ await action.Should().ThrowAsync() .WithMessage("*connection slot yaml pointers must be unique*"); } + [Fact] + public async Task StartupProbe_WhenConnectionSlotYamlPointerOverlapsVariable_ShouldFailHostStartup() + { + var definition = Package("workflow-alpha"); + definition.ConnectionSlots[0].YamlPointer = definition.Variables[0].YamlPointer; + var probe = new WorkflowDeliveryPackageCatalogStartupProbe(CreateCatalog([definition])); + + var action = () => probe.StartAsync(CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must not overlap variable yaml pointers*"); + } + [Fact] public async Task StartupProbe_WhenConfiguredPackageSourceIsMissing_ShouldFailHostStartup() { diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs index 50a13c5bf6..62b3555e02 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs @@ -118,6 +118,30 @@ public async Task PublishAsync_WhenRepeatedWithSameIdempotencyKey_ShouldPersistA context.Provisioning.Requests.Should().BeEmpty(); } + [Fact] + public async Task PublishAsync_ShouldRenderMultipleCompletedConnectionsIntoProvisionRequest() + { + var context = new TestContext( + DeliveryWithMultipleConnectionSlots(), + renderer: new WorkflowDeliveryConfigurationRenderer()); + + await context.Service.PublishAsync( + "delivery-alpha", + "scope-alpha", + PublishRequest("digest-alpha", "write"), + Caller()); + + var workflowYaml = context.Provisioning.PreparationRequests.Should().ContainSingle().Subject.WorkflowYaml; + workflowYaml.Should().Contain("user_service_id: user-service-calendar"); + workflowYaml.Should().Contain("user_service_id: user-service-document"); + workflowYaml.Should().NotContain("calendar-placeholder"); + workflowYaml.Should().NotContain("document-placeholder"); + + var start = context.Commands.Started.Should().ContainSingle().Subject; + start.ConnectionReferences.Should().Contain("calendar", "user-service-calendar"); + start.ConnectionReferences.Should().Contain("document", "user-service-document"); + } + [Fact] public async Task PublishAsync_WhenInstallationAlreadyExistsWithDifferentRequest_ShouldConflictBeforePreparation() { @@ -1076,6 +1100,66 @@ private static WorkflowDeliverySnapshot DeliveryWithLarkSlot() }; } + private static WorkflowDeliverySnapshot DeliveryWithMultipleConnectionSlots() + { + const string sourceYaml = """ + name: workflow-alpha + steps: + - id: calendar + type: tool_call + capability: + nyxid_request: + user_service_id: calendar-placeholder + - id: document + type: tool_call + capability: + nyxid_request: + user_service_id: document-placeholder + """; + var snapshot = DeliverySnapshot(); + var now = DateTimeOffset.Parse("2026-08-16T01:30:00Z"); + return snapshot with + { + Package = snapshot.Package with + { + SourceYaml = sourceYaml, + SourceHash = WorkflowDeliveryPackageCatalog.ComputeHash(sourceYaml), + ConnectionSlots = + [ + new WorkflowDeliveryConnectionSlotDefinition( + "calendar", + "Calendar", + "service-calendar", + true, + "/steps/0/capability/nyxid_request/user_service_id"), + new WorkflowDeliveryConnectionSlotDefinition( + "document", + "Document", + "service-document", + true, + "/steps/1/capability/nyxid_request/user_service_id"), + ], + }, + Connections = + [ + new WorkflowDeliveryConnectionSnapshot( + "calendar", + "service-calendar", + string.Empty, + WorkflowDeliveryConnectionStatus.Completed, + "user-service-calendar", + now), + new WorkflowDeliveryConnectionSnapshot( + "document", + "service-document", + string.Empty, + WorkflowDeliveryConnectionStatus.Completed, + "user-service-document", + now), + ], + }; + } + private static NyxIdUserServiceInventoryItem UserService( string userServiceId, string instanceSlug = "customer-lark", @@ -1113,7 +1197,8 @@ public TestContext( Action? beginProjection = null, Action? attachProjection = null, Action? startProjection = null, - IWorkflowDeliveryPackageCatalog? packageCatalog = null) + IWorkflowDeliveryPackageCatalog? packageCatalog = null, + IWorkflowDeliveryConfigurationRenderer? renderer = null) { Queries = new StubQueryPort(snapshot ?? DeliverySnapshot()); var projector = beginProjection ?? @@ -1130,7 +1215,7 @@ public TestContext( Provisioning = new RecordingProvisioningService(); Service = new WorkflowDeliveryService( packageCatalog ?? new UnusedPackageCatalog(), - new StubRenderer(), + renderer ?? new StubRenderer(), Commands, Queries, connectLinks ?? new UnusedConnectLinkPort(),