diff --git a/aevatar.slnx b/aevatar.slnx
index e49e88ad9..bd3c86746 100644
--- a/aevatar.slnx
+++ b/aevatar.slnx
@@ -16,6 +16,7 @@
+
diff --git a/agents/Aevatar.GAgents.StudioMember/Aevatar.GAgents.StudioMember.csproj b/agents/Aevatar.GAgents.StudioMember/Aevatar.GAgents.StudioMember.csproj
new file mode 100644
index 000000000..6dc06ac9c
--- /dev/null
+++ b/agents/Aevatar.GAgents.StudioMember/Aevatar.GAgents.StudioMember.csproj
@@ -0,0 +1,24 @@
+
+
+ net10.0
+ enable
+ enable
+ Aevatar.GAgents.StudioMember
+ Aevatar.GAgents.StudioMember
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/agents/Aevatar.GAgents.StudioMember/StudioMemberConventions.cs b/agents/Aevatar.GAgents.StudioMember/StudioMemberConventions.cs
new file mode 100644
index 000000000..810d8134d
--- /dev/null
+++ b/agents/Aevatar.GAgents.StudioMember/StudioMemberConventions.cs
@@ -0,0 +1,62 @@
+namespace Aevatar.GAgents.StudioMember;
+
+///
+/// Canonical naming for StudioMember actor IDs and the rename-safe
+/// publishedServiceId derived from the immutable member id.
+///
+/// Both helpers are pure functions of their inputs; they do not read any
+/// runtime state and they produce no side effects. The publishedServiceId
+/// computed here is meant to be persisted on first create_member and
+/// then read back from state — never recomputed from a mutable display name.
+///
+public static class StudioMemberConventions
+{
+ public const string ActorIdPrefix = "studio-member";
+ public const string PublishedServiceIdPrefix = "member";
+
+ ///
+ /// Builds the actor id used by .
+ ///
+ public static string BuildActorId(string scopeId, string memberId)
+ {
+ var normalizedScopeId = NormalizeScopeId(scopeId);
+ var normalizedMemberId = NormalizeMemberId(memberId);
+ return $"{ActorIdPrefix}:{normalizedScopeId}:{normalizedMemberId}";
+ }
+
+ ///
+ /// Derives the rename-safe published service id from the immutable
+ /// . Should be invoked once at creation time
+ /// and the result persisted on the actor state — callers must not
+ /// recompute it on read.
+ ///
+ public static string BuildPublishedServiceId(string memberId)
+ {
+ var normalizedMemberId = NormalizeMemberId(memberId);
+ return $"{PublishedServiceIdPrefix}-{normalizedMemberId}";
+ }
+
+ public static string NormalizeScopeId(string? scopeId)
+ {
+ var trimmed = scopeId?.Trim();
+ if (string.IsNullOrEmpty(trimmed))
+ throw new ArgumentException("scopeId is required.", nameof(scopeId));
+ if (ContainsActorIdSeparator(trimmed))
+ throw new ArgumentException(
+ "scopeId must not contain ':' (it is the actor-id separator).", nameof(scopeId));
+ return trimmed;
+ }
+
+ public static string NormalizeMemberId(string? memberId)
+ {
+ var trimmed = memberId?.Trim();
+ if (string.IsNullOrEmpty(trimmed))
+ throw new ArgumentException("memberId is required.", nameof(memberId));
+ if (ContainsActorIdSeparator(trimmed))
+ throw new ArgumentException(
+ "memberId must not contain ':' (it is the actor-id separator).", nameof(memberId));
+ return trimmed;
+ }
+
+ private static bool ContainsActorIdSeparator(string value) => value.Contains(':');
+}
diff --git a/agents/Aevatar.GAgents.StudioMember/StudioMemberGAgent.cs b/agents/Aevatar.GAgents.StudioMember/StudioMemberGAgent.cs
new file mode 100644
index 000000000..b7b452c21
--- /dev/null
+++ b/agents/Aevatar.GAgents.StudioMember/StudioMemberGAgent.cs
@@ -0,0 +1,217 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.Foundation.Abstractions.Attributes;
+using Aevatar.Foundation.Core;
+using Aevatar.Foundation.Core.EventSourcing;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.GAgents.StudioMember;
+
+///
+/// Per-member actor that owns the canonical StudioMember authority state.
+///
+/// Actor ID convention: studio-member:{scopeId}:{memberId}.
+/// The actor is the only writer of published_service_id, which is
+/// generated once at creation from the immutable member_id and never
+/// recomputed on rename. The convention is re-derived inside the actor in
+/// so a stale or hand-crafted event payload
+/// cannot break the rename-safe invariant.
+///
+public sealed class StudioMemberGAgent : GAgentBase, IProjectedActor
+{
+ public static string ProjectionKind => "studio-member";
+
+ [EventHandler(EndpointName = "createMember")]
+ public async Task HandleCreated(StudioMemberCreatedEvent evt)
+ {
+ if (!string.IsNullOrEmpty(State.MemberId))
+ {
+ // First-write-wins on identity: a re-create with a different
+ // memberId is a hard conflict (someone is reusing an existing
+ // actor id for a different member). A re-create with the same
+ // memberId but mismatched non-identity fields is also rejected
+ // so a stray duplicate cannot silently overwrite the persisted
+ // displayName / kind / description and leave callers confused
+ // about which version persisted.
+ if (!string.Equals(State.MemberId, evt.MemberId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"member already initialized with id '{State.MemberId}'.");
+ }
+
+ if (!string.Equals(State.DisplayName, evt.DisplayName, StringComparison.Ordinal)
+ || !string.Equals(State.Description, evt.Description, StringComparison.Ordinal)
+ || State.ImplementationKind != evt.ImplementationKind)
+ {
+ throw new InvalidOperationException(
+ $"member '{State.MemberId}' already exists with different displayName / description / implementationKind. " +
+ "First-write-wins on member identity; use rename / updateImplementation to change later.");
+ }
+
+ // Same memberId + same identity-stable fields = idempotent no-op.
+ return;
+ }
+
+ await PersistDomainEventAsync(evt);
+ }
+
+ [EventHandler(EndpointName = "renameMember")]
+ public async Task HandleRenamed(StudioMemberRenamedEvent evt)
+ {
+ if (string.IsNullOrEmpty(State.MemberId))
+ {
+ throw new InvalidOperationException("member not yet created.");
+ }
+
+ await PersistDomainEventAsync(evt);
+ }
+
+ [EventHandler(EndpointName = "updateImplementation")]
+ public async Task HandleImplementationUpdated(StudioMemberImplementationUpdatedEvent evt)
+ {
+ if (string.IsNullOrEmpty(State.MemberId))
+ {
+ throw new InvalidOperationException("member not yet created.");
+ }
+
+ // ImplementationKind is locked at create. Reject mismatched kinds so
+ // a Script member can't be silently mutated into a Workflow member by
+ // dispatching an UpdatedEvent with a different kind. Unspecified is
+ // accepted as "carry the existing kind" (defensive default).
+ if (evt.ImplementationKind != StudioMemberImplementationKind.Unspecified
+ && evt.ImplementationKind != State.ImplementationKind)
+ {
+ throw new InvalidOperationException(
+ $"member '{State.MemberId}' implementationKind is locked at create. " +
+ $"Was {State.ImplementationKind}, attempted {evt.ImplementationKind}. " +
+ "Use create with the correct kind, or rename / impl-update with the same kind.");
+ }
+
+ await PersistDomainEventAsync(evt);
+ }
+
+ [EventHandler(EndpointName = "recordBinding")]
+ public async Task HandleBound(StudioMemberBoundEvent evt)
+ {
+ if (string.IsNullOrEmpty(State.MemberId))
+ {
+ throw new InvalidOperationException("member not yet created.");
+ }
+
+ await PersistDomainEventAsync(evt);
+ }
+
+ protected override StudioMemberState TransitionState(
+ StudioMemberState current, IMessage evt)
+ {
+ return StateTransitionMatcher
+ .Match(current, evt)
+ .On(ApplyCreated)
+ .On(ApplyRenamed)
+ .On(ApplyImplementationUpdated)
+ .On(ApplyBound)
+ .OrCurrent();
+ }
+
+ private static StudioMemberState ApplyCreated(
+ StudioMemberState state, StudioMemberCreatedEvent evt)
+ {
+ // Re-derive publishedServiceId from the immutable memberId rather
+ // than trusting evt.PublishedServiceId. The dispatcher today already
+ // builds it via the same convention; deriving here keeps the
+ // single-source-of-truth on the actor and protects against a
+ // historical or hand-rolled event whose derivation rule drifted.
+ var derivedPublishedServiceId = StudioMemberConventions.BuildPublishedServiceId(evt.MemberId);
+
+ return new StudioMemberState
+ {
+ MemberId = evt.MemberId,
+ ScopeId = evt.ScopeId,
+ DisplayName = evt.DisplayName,
+ Description = evt.Description,
+ ImplementationKind = evt.ImplementationKind,
+ ImplementationRef = null,
+ PublishedServiceId = derivedPublishedServiceId,
+ LifecycleStage = StudioMemberLifecycleStage.Created,
+ CreatedAtUtc = evt.CreatedAtUtc,
+ UpdatedAtUtc = evt.CreatedAtUtc,
+ LastBinding = null,
+ };
+ }
+
+ private static StudioMemberState ApplyRenamed(
+ StudioMemberState state, StudioMemberRenamedEvent evt)
+ {
+ var next = state.Clone();
+ next.DisplayName = evt.DisplayName;
+ next.Description = evt.Description;
+ next.UpdatedAtUtc = evt.UpdatedAtUtc;
+ return next;
+ }
+
+ private static StudioMemberState ApplyImplementationUpdated(
+ StudioMemberState state, StudioMemberImplementationUpdatedEvent evt)
+ {
+ var next = state.Clone();
+ // ImplementationKind is locked at create — see HandleImplementationUpdated.
+ // Do not mutate it here even if the event payload disagrees, so the
+ // invariant holds even on hand-rolled / replayed events.
+ next.ImplementationRef = evt.ImplementationRef?.Clone();
+ next.UpdatedAtUtc = evt.UpdatedAtUtc;
+
+ // Lifecycle:
+ // Created + resolved impl ref → BuildReady
+ // BindReady + new impl event → downgrade to BuildReady
+ // (the published revision is now stale until next bind)
+ // BuildReady + new impl event → stays BuildReady
+ //
+ // The bind orchestration explicitly does (impl_updated → bound),
+ // so the temporary downgrade is upgraded again by ApplyBound on
+ // the same bind; only out-of-band impl updates leave the member
+ // visibly non-bind-ready until rebind.
+ var hasResolvedRef = HasResolvedImplementationRef(evt.ImplementationRef);
+ if (hasResolvedRef)
+ {
+ next.LifecycleStage = StudioMemberLifecycleStage.BuildReady;
+ }
+ else if (next.LifecycleStage == StudioMemberLifecycleStage.BindReady)
+ {
+ // Cleared impl ref on a previously-bound member: still need to
+ // surface that the bound revision is stale.
+ next.LifecycleStage = StudioMemberLifecycleStage.BuildReady;
+ }
+
+ return next;
+ }
+
+ private static StudioMemberState ApplyBound(
+ StudioMemberState state, StudioMemberBoundEvent evt)
+ {
+ var next = state.Clone();
+ next.LastBinding = new StudioMemberBindingContract
+ {
+ PublishedServiceId = evt.PublishedServiceId,
+ RevisionId = evt.RevisionId,
+ ImplementationKind = evt.ImplementationKind,
+ BoundAtUtc = evt.BoundAtUtc,
+ };
+ next.LifecycleStage = StudioMemberLifecycleStage.BindReady;
+ next.UpdatedAtUtc = evt.BoundAtUtc;
+ return next;
+ }
+
+ private static bool HasResolvedImplementationRef(StudioMemberImplementationRef? implRef)
+ {
+ if (implRef == null)
+ return false;
+
+ if (implRef.Workflow != null && !string.IsNullOrEmpty(implRef.Workflow.WorkflowId))
+ return true;
+ if (implRef.Script != null && !string.IsNullOrEmpty(implRef.Script.ScriptId))
+ return true;
+ if (implRef.Gagent != null && !string.IsNullOrEmpty(implRef.Gagent.ActorTypeName))
+ return true;
+
+ return false;
+ }
+}
diff --git a/agents/Aevatar.GAgents.StudioMember/studio_member_messages.proto b/agents/Aevatar.GAgents.StudioMember/studio_member_messages.proto
new file mode 100644
index 000000000..85d71f52f
--- /dev/null
+++ b/agents/Aevatar.GAgents.StudioMember/studio_member_messages.proto
@@ -0,0 +1,124 @@
+syntax = "proto3";
+package aevatar.gagents.studio_member;
+option csharp_namespace = "Aevatar.GAgents.StudioMember";
+
+import "google/protobuf/timestamp.proto";
+
+// ─── Canonical enums ───
+//
+// StudioMember is the Studio's only first-class subject. workflow / script /
+// gagent are implementation kinds for a member, not parallel member types.
+
+enum StudioMemberImplementationKind {
+ STUDIO_MEMBER_IMPLEMENTATION_KIND_UNSPECIFIED = 0;
+ STUDIO_MEMBER_IMPLEMENTATION_KIND_WORKFLOW = 1;
+ STUDIO_MEMBER_IMPLEMENTATION_KIND_SCRIPT = 2;
+ STUDIO_MEMBER_IMPLEMENTATION_KIND_GAGENT = 3;
+}
+
+// Lifecycle stages mirror the Build/Bind/Invoke/Observe spec:
+// created - member exists but has no implementation
+// build_ready - implementation revision available, can bind
+// bind_ready - bound to its publishedServiceId, can invoke
+enum StudioMemberLifecycleStage {
+ STUDIO_MEMBER_LIFECYCLE_STAGE_UNSPECIFIED = 0;
+ STUDIO_MEMBER_LIFECYCLE_STAGE_CREATED = 1;
+ STUDIO_MEMBER_LIFECYCLE_STAGE_BUILD_READY = 2;
+ STUDIO_MEMBER_LIFECYCLE_STAGE_BIND_READY = 3;
+}
+
+// ─── Implementation refs ───
+//
+// Each implementation kind carries a typed sub-message rather than a generic
+// metadata bag. Only one of these is populated at a time, matching
+// implementation_kind on the parent state.
+
+message StudioMemberWorkflowRef {
+ string workflow_id = 1;
+ string workflow_revision = 2;
+}
+
+message StudioMemberScriptRef {
+ string script_id = 1;
+ string script_revision = 2;
+}
+
+message StudioMemberGAgentRef {
+ string actor_type_name = 1;
+}
+
+message StudioMemberImplementationRef {
+ StudioMemberWorkflowRef workflow = 1;
+ StudioMemberScriptRef script = 2;
+ StudioMemberGAgentRef gagent = 3;
+}
+
+// ─── Last bound contract ───
+//
+// Record of the last successful Bind. publishedServiceId is the stable
+// per-member service identity; revisionId is the most recently published
+// implementation revision routed through that service.
+
+message StudioMemberBindingContract {
+ string published_service_id = 1;
+ string revision_id = 2;
+ StudioMemberImplementationKind implementation_kind = 3;
+ google.protobuf.Timestamp bound_at_utc = 4;
+}
+
+// ─── State ───
+//
+// State for a single StudioMember actor. Actor ID format:
+// studio-member:{scopeId}:{memberId}
+//
+// publishedServiceId is generated once at creation from the immutable
+// memberId and never recomputed afterwards. Renaming the display name does
+// not change publishedServiceId.
+
+message StudioMemberState {
+ string member_id = 1;
+ string scope_id = 2;
+ string display_name = 3;
+ string description = 4;
+ StudioMemberImplementationKind implementation_kind = 5;
+ StudioMemberImplementationRef implementation_ref = 6;
+ string published_service_id = 7;
+ StudioMemberLifecycleStage lifecycle_stage = 8;
+ google.protobuf.Timestamp created_at_utc = 9;
+ google.protobuf.Timestamp updated_at_utc = 10;
+ StudioMemberBindingContract last_binding = 11;
+}
+
+// ─── Events ───
+//
+// One event per discrete transition. The actor materializes state from these
+// events; projectors materialize the read model from the same envelope.
+
+message StudioMemberCreatedEvent {
+ string member_id = 1;
+ string scope_id = 2;
+ string display_name = 3;
+ string description = 4;
+ StudioMemberImplementationKind implementation_kind = 5;
+ string published_service_id = 6;
+ google.protobuf.Timestamp created_at_utc = 7;
+}
+
+message StudioMemberRenamedEvent {
+ string display_name = 1;
+ string description = 2;
+ google.protobuf.Timestamp updated_at_utc = 3;
+}
+
+message StudioMemberImplementationUpdatedEvent {
+ StudioMemberImplementationKind implementation_kind = 1;
+ StudioMemberImplementationRef implementation_ref = 2;
+ google.protobuf.Timestamp updated_at_utc = 3;
+}
+
+message StudioMemberBoundEvent {
+ string published_service_id = 1;
+ string revision_id = 2;
+ StudioMemberImplementationKind implementation_kind = 3;
+ google.protobuf.Timestamp bound_at_utc = 4;
+}
diff --git a/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj b/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj
index 7c7c54023..e19edc6f7 100644
--- a/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj
+++ b/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj
@@ -33,4 +33,8 @@
GrpcServices="None"
AdditionalImportDirs="..\Aevatar.Scripting.Abstractions\Protos" />
+
+
+
+
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs
new file mode 100644
index 000000000..8c5c19588
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs
@@ -0,0 +1,47 @@
+using Aevatar.Studio.Application.Studio.Contracts;
+
+namespace Aevatar.Studio.Application.Studio.Abstractions;
+
+///
+/// Write-side port for the StudioMember authority. Dispatches commands to
+/// the per-member StudioMemberGAgent actor; never reads downstream state
+/// (queries flow through ).
+///
+public interface IStudioMemberCommandPort
+{
+ ///
+ /// Creates a new member under the given scope and returns the resulting
+ /// summary. and the request's
+ /// ImplementationKind are required; publishedServiceId is
+ /// generated by this call from the immutable member id and persisted on
+ /// the actor state.
+ ///
+ Task CreateAsync(
+ string scopeId,
+ CreateStudioMemberRequest request,
+ CancellationToken ct = default);
+
+ ///
+ /// Updates the implementation reference for an existing member (e.g. once
+ /// Studio has produced a workflow yaml, script revision, or wired up a
+ /// gagent type for the member). Idempotent on identical inputs.
+ ///
+ Task UpdateImplementationAsync(
+ string scopeId,
+ string memberId,
+ StudioMemberImplementationRefResponse implementation,
+ CancellationToken ct = default);
+
+ ///
+ /// Records that the member has been bound to its published service at the
+ /// given revision. Called by the member binding orchestrator after the
+ /// underlying scope binding upsert succeeds.
+ ///
+ Task RecordBindingAsync(
+ string scopeId,
+ string memberId,
+ string publishedServiceId,
+ string revisionId,
+ string implementationKindName,
+ CancellationToken ct = default);
+}
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberQueryPort.cs
new file mode 100644
index 000000000..35afb4ba0
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberQueryPort.cs
@@ -0,0 +1,21 @@
+using Aevatar.Studio.Application.Studio.Contracts;
+
+namespace Aevatar.Studio.Application.Studio.Abstractions;
+
+///
+/// Pure-read query port for StudioMember. Reads exclusively from the
+/// projection document store; does not replay events or read actor state
+/// directly.
+///
+public interface IStudioMemberQueryPort
+{
+ Task ListAsync(
+ string scopeId,
+ StudioMemberRosterPageRequest? page = null,
+ CancellationToken ct = default);
+
+ Task GetAsync(
+ string scopeId,
+ string memberId,
+ CancellationToken ct = default);
+}
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs
new file mode 100644
index 000000000..c8e3ea03c
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs
@@ -0,0 +1,61 @@
+using Aevatar.Studio.Application.Studio.Contracts;
+
+namespace Aevatar.Studio.Application.Studio.Abstractions;
+
+///
+/// Application-level facade for member-first Studio APIs. Orchestrates the
+/// command and query ports plus the underlying scope binding capability so
+/// the HTTP layer never has to know about ServiceId or scope-default
+/// fallback. Endpoints depend on this interface rather than reaching for
+/// ,
+/// or the platform binding port directly.
+///
+public interface IStudioMemberService
+{
+ Task CreateAsync(
+ string scopeId,
+ CreateStudioMemberRequest request,
+ CancellationToken ct = default);
+
+ Task ListAsync(
+ string scopeId,
+ StudioMemberRosterPageRequest? page = null,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns the member detail. Throws
+ /// when no member with
+ /// the given id exists in the scope — endpoints map this to 404
+ /// STUDIO_MEMBER_NOT_FOUND, the same body every member-centric
+ /// endpoint returns for missing-member.
+ ///
+ Task GetAsync(
+ string scopeId,
+ string memberId,
+ CancellationToken ct = default);
+
+ ///
+ /// Binds the given member to its own stable publishedServiceId
+ /// (never the scope default service). Resolves the member, builds a
+ /// scope binding request with ServiceId = publishedServiceId,
+ /// delegates to the existing scope binding command port, and records the
+ /// resulting revision back on the member authority.
+ ///
+ Task BindAsync(
+ string scopeId,
+ string memberId,
+ UpdateStudioMemberBindingRequest request,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns the last successful binding contract for the member, or
+ /// null when the member exists but has never been bound.
+ /// Throws when the member
+ /// itself does not exist — endpoints distinguish "missing member" (404)
+ /// from "exists, never bound" (200 with null binding).
+ ///
+ Task GetBindingAsync(
+ string scopeId,
+ string memberId,
+ CancellationToken ct = default);
+}
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberNotFoundException.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberNotFoundException.cs
new file mode 100644
index 000000000..f37ceb97d
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberNotFoundException.cs
@@ -0,0 +1,25 @@
+namespace Aevatar.Studio.Application.Studio.Abstractions;
+
+///
+/// Thrown when a member-centric operation targets an id that has no
+/// corresponding member document in the requested scope. Endpoints map this
+/// to HTTP 404 — distinct from validation errors that map to 400.
+///
+/// Inherits so the 404 / 400 mapping in
+/// endpoints is type-disjoint from
+/// (validation throws). Reordering catch blocks cannot silently downgrade a
+/// 404 to a 400.
+///
+public sealed class StudioMemberNotFoundException : KeyNotFoundException
+{
+ public StudioMemberNotFoundException(string scopeId, string memberId)
+ : base($"member '{memberId}' not found in scope '{scopeId}'.")
+ {
+ ScopeId = scopeId;
+ MemberId = memberId;
+ }
+
+ public string ScopeId { get; }
+
+ public string MemberId { get; }
+}
diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs
new file mode 100644
index 000000000..411f88230
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs
@@ -0,0 +1,136 @@
+namespace Aevatar.Studio.Application.Studio.Contracts;
+
+///
+/// Wire-format implementation kind for HTTP/JSON. Uses lowercase strings so
+/// Studio's HTTP surface stays member-centric and frontend-friendly. Mapped
+/// onto StudioMemberImplementationKind at the boundary.
+///
+public static class MemberImplementationKindNames
+{
+ public const string Workflow = "workflow";
+ public const string Script = "script";
+ public const string GAgent = "gagent";
+}
+
+///
+/// Wire-format lifecycle stage. Mirrors the
+/// StudioMemberLifecycleStage proto enum but with stable string
+/// values that Studio's frontend can switch on without taking a generated
+/// proto dependency.
+///
+public static class MemberLifecycleStageNames
+{
+ public const string Created = "created";
+ public const string BuildReady = "build_ready";
+ public const string BindReady = "bind_ready";
+}
+
+///
+/// Implementation reference returned to the caller. Always typed — never a
+/// generic property bag — so the frontend can dispatch on
+/// without parsing arbitrary keys.
+///
+public sealed record StudioMemberImplementationRefResponse(
+ string ImplementationKind,
+ string? WorkflowId = null,
+ string? WorkflowRevision = null,
+ string? ScriptId = null,
+ string? ScriptRevision = null,
+ string? ActorTypeName = null);
+
+public sealed record StudioMemberSummaryResponse(
+ string MemberId,
+ string ScopeId,
+ string DisplayName,
+ string Description,
+ string ImplementationKind,
+ string LifecycleStage,
+ string PublishedServiceId,
+ string? LastBoundRevisionId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public sealed record StudioMemberDetailResponse(
+ StudioMemberSummaryResponse Summary,
+ StudioMemberImplementationRefResponse? ImplementationRef,
+ StudioMemberBindingContractResponse? LastBinding);
+
+public sealed record StudioMemberBindingContractResponse(
+ string PublishedServiceId,
+ string RevisionId,
+ string ImplementationKind,
+ DateTimeOffset BoundAt);
+
+///
+/// Wrapper returned from GET /members/{memberId}/binding so the
+/// response is always a JSON object — distinguishes "exists but never
+/// bound" ( is null, status 200) from
+/// "member missing" (typed 404 STUDIO_MEMBER_NOT_FOUND).
+///
+public sealed record StudioMemberBindingViewResponse(
+ StudioMemberBindingContractResponse? LastBinding);
+
+public sealed record StudioMemberRosterResponse(
+ string ScopeId,
+ IReadOnlyList Members,
+ string? NextPageToken = null);
+
+public sealed record StudioMemberRosterPageRequest(
+ int? PageSize = null,
+ string? PageToken = null);
+
+public sealed record CreateStudioMemberRequest(
+ string DisplayName,
+ string ImplementationKind,
+ string? Description = null,
+ string? MemberId = null);
+
+///
+/// Centralized input bounds applied at the create boundary so a single
+/// request cannot push 10MB of displayName / description / memberId all the
+/// way through to the actor state and read model. Slug pattern on
+/// memberId keeps caller-supplied ids URL-safe and free of separators that
+/// the actor-id convention reserves.
+///
+public static class StudioMemberInputLimits
+{
+ public const int MaxDisplayNameLength = 256;
+ public const int MaxDescriptionLength = 2048;
+ public const int MaxMemberIdLength = 64;
+
+ public static readonly System.Text.RegularExpressions.Regex MemberIdPattern =
+ new(@"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$", System.Text.RegularExpressions.RegexOptions.Compiled);
+}
+
+public sealed record UpdateStudioMemberBindingRequest(
+ string? RevisionId = null,
+ StudioMemberWorkflowBindingSpec? Workflow = null,
+ StudioMemberScriptBindingSpec? Script = null,
+ StudioMemberGAgentBindingSpec? GAgent = null);
+
+public sealed record StudioMemberWorkflowBindingSpec(
+ IReadOnlyList WorkflowYamls);
+
+public sealed record StudioMemberScriptBindingSpec(
+ string ScriptId,
+ string? ScriptRevision = null);
+
+public sealed record StudioMemberGAgentEndpointSpec(
+ string EndpointId,
+ string DisplayName,
+ string Kind,
+ string RequestTypeUrl,
+ string ResponseTypeUrl,
+ string? Description = null);
+
+public sealed record StudioMemberGAgentBindingSpec(
+ string ActorTypeName,
+ IReadOnlyList? Endpoints = null);
+
+public sealed record StudioMemberBindingResponse(
+ string MemberId,
+ string PublishedServiceId,
+ string RevisionId,
+ string ImplementationKind,
+ string ScopeId,
+ string ExpectedActorId);
diff --git a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs
index 502d5f6f2..5cafc4ad3 100644
--- a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs
@@ -1,5 +1,7 @@
+using Aevatar.Studio.Application.Studio.Abstractions;
using Aevatar.Studio.Application.Studio.Services;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Aevatar.Studio.Application.Studio.DependencyInjection;
@@ -15,6 +17,7 @@ public static IServiceCollection AddStudioApplication(this IServiceCollection se
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.TryAddSingleton();
return services;
}
}
diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberCreateRequestValidator.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberCreateRequestValidator.cs
new file mode 100644
index 000000000..5422dd59a
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberCreateRequestValidator.cs
@@ -0,0 +1,76 @@
+using Aevatar.Studio.Application.Studio.Contracts;
+
+namespace Aevatar.Studio.Application.Studio.Services;
+
+///
+/// Application-layer enforcement of .
+/// Lives next to so a swap of the
+/// projection-side command port (alternate impl, in-memory test variant,
+/// migration tool) cannot silently drop the bounds with it.
+///
+/// The Projection-layer command service is intentionally lenient on these
+/// fields — it trusts the caller already validated. The Application layer
+/// is the single boundary where length caps + slug pattern are
+/// enforced.
+///
+internal static class StudioMemberCreateRequestValidator
+{
+ public static void Validate(CreateStudioMemberRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ ValidateDisplayName(request.DisplayName);
+ ValidateDescription(request.Description);
+ ValidateMemberId(request.MemberId);
+ }
+
+ private static void ValidateDisplayName(string? displayName)
+ {
+ var trimmed = displayName?.Trim();
+ if (string.IsNullOrEmpty(trimmed))
+ {
+ throw new InvalidOperationException(
+ "displayName is required when creating a member.");
+ }
+
+ if (trimmed.Length > StudioMemberInputLimits.MaxDisplayNameLength)
+ {
+ throw new InvalidOperationException(
+ $"displayName must be at most {StudioMemberInputLimits.MaxDisplayNameLength} characters.");
+ }
+ }
+
+ private static void ValidateDescription(string? description)
+ {
+ var trimmed = description?.Trim() ?? string.Empty;
+ if (trimmed.Length > StudioMemberInputLimits.MaxDescriptionLength)
+ {
+ throw new InvalidOperationException(
+ $"description must be at most {StudioMemberInputLimits.MaxDescriptionLength} characters.");
+ }
+ }
+
+ private static void ValidateMemberId(string? rawMemberId)
+ {
+ if (string.IsNullOrWhiteSpace(rawMemberId))
+ {
+ // Empty is allowed — the command service generates a random
+ // member id when the caller leaves it empty.
+ return;
+ }
+
+ var trimmed = rawMemberId.Trim();
+ if (trimmed.Length > StudioMemberInputLimits.MaxMemberIdLength)
+ {
+ throw new InvalidOperationException(
+ $"memberId must be at most {StudioMemberInputLimits.MaxMemberIdLength} characters.");
+ }
+
+ if (!StudioMemberInputLimits.MemberIdPattern.IsMatch(trimmed))
+ {
+ throw new InvalidOperationException(
+ "memberId must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ " +
+ "(alphanumeric, dash, underscore; starts with alphanumeric).");
+ }
+ }
+}
diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs
new file mode 100644
index 000000000..790d381d8
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs
@@ -0,0 +1,300 @@
+using Aevatar.GAgentService.Abstractions;
+using Aevatar.GAgentService.Abstractions.Ports;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+
+namespace Aevatar.Studio.Application.Studio.Services;
+
+///
+/// Member-first Studio facade. Owns the orchestration that turns a
+/// member-scoped request into the existing scope binding pipeline:
+///
+/// 1. resolve the StudioMember authority (read model)
+/// 2. build a with
+/// ServiceId = publishedServiceId — never the scope default
+/// 3. delegate to
+/// 4. derive the resolved implementation_ref from the binding
+/// result and persist it on the member authority
+/// 5. record the resulting revision back on the member actor
+///
+/// Steps 4 + 5 are what populate StudioMemberState.ImplementationRef
+/// and traverse the lifecycle Created → BuildReady → BindReady the
+/// issue specifies. Endpoints depend on this facade and never reach for the
+/// platform binding port directly, which is what kept Studio's old surface
+/// in scope-default fallback mode.
+///
+public sealed class StudioMemberService : IStudioMemberService
+{
+ private readonly IStudioMemberCommandPort _memberCommandPort;
+ private readonly IStudioMemberQueryPort _memberQueryPort;
+ private readonly IScopeBindingCommandPort _scopeBindingCommandPort;
+
+ public StudioMemberService(
+ IStudioMemberCommandPort memberCommandPort,
+ IStudioMemberQueryPort memberQueryPort,
+ IScopeBindingCommandPort scopeBindingCommandPort)
+ {
+ _memberCommandPort = memberCommandPort ?? throw new ArgumentNullException(nameof(memberCommandPort));
+ _memberQueryPort = memberQueryPort ?? throw new ArgumentNullException(nameof(memberQueryPort));
+ _scopeBindingCommandPort = scopeBindingCommandPort
+ ?? throw new ArgumentNullException(nameof(scopeBindingCommandPort));
+ }
+
+ public Task CreateAsync(
+ string scopeId,
+ CreateStudioMemberRequest request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ // Validation lives at this Application boundary (CLAUDE.md
+ // `严格分层 / 上层依赖抽象`). The Projection-layer command port is
+ // an interchangeable transport; if it ever swaps, the bounds must
+ // not silently disappear with it. Callers receive a single typed
+ // error path here regardless of which command port is wired in.
+ StudioMemberCreateRequestValidator.Validate(request);
+
+ return _memberCommandPort.CreateAsync(scopeId, request, ct);
+ }
+
+ public Task ListAsync(
+ string scopeId,
+ StudioMemberRosterPageRequest? page = null,
+ CancellationToken ct = default) =>
+ _memberQueryPort.ListAsync(scopeId, page, ct);
+
+ public async Task GetAsync(
+ string scopeId,
+ string memberId,
+ CancellationToken ct = default)
+ {
+ // Mirrors GetBindingAsync semantics: a missing member is
+ // unambiguous "404 STUDIO_MEMBER_NOT_FOUND", not a 200-with-null
+ // body that the frontend would have to pattern-match. Endpoints
+ // catch the typed exception and return the same body shape from
+ // every member-centric endpoint.
+ var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct)
+ ?? throw new StudioMemberNotFoundException(scopeId, memberId);
+ return detail;
+ }
+
+ public async Task BindAsync(
+ string scopeId,
+ string memberId,
+ UpdateStudioMemberBindingRequest request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct)
+ ?? throw new StudioMemberNotFoundException(scopeId, memberId);
+
+ var publishedServiceId = detail.Summary.PublishedServiceId;
+ if (string.IsNullOrWhiteSpace(publishedServiceId))
+ {
+ throw new InvalidOperationException(
+ $"member '{memberId}' has no publishedServiceId; this is a backend invariant violation.");
+ }
+
+ var implementationKindWire = detail.Summary.ImplementationKind;
+ var bindingRequest = BuildScopeBindingRequest(
+ scopeId,
+ memberId,
+ publishedServiceId,
+ implementationKindWire,
+ detail.Summary.DisplayName,
+ request);
+
+ // Two-phase write: scope binding first, then update the member
+ // authority. This is intentionally last-write-wins — if step 2 or
+ // step 3 fails, the platform side has a fresh revision but the
+ // member doesn't yet observe it; the next bind will create another
+ // upstream revision and only that one will be recorded. We accept
+ // this drift over a distributed transaction because the member's
+ // last_binding is a query convenience, not the source of truth —
+ // the platform read model is.
+ var bindingResult = await _scopeBindingCommandPort.UpsertAsync(bindingRequest, ct);
+
+ var resolvedImplementationRef = BuildResolvedImplementationRef(
+ implementationKindWire, bindingResult, request);
+ if (resolvedImplementationRef != null)
+ {
+ await _memberCommandPort.UpdateImplementationAsync(
+ scopeId, memberId, resolvedImplementationRef, ct);
+ }
+
+ await _memberCommandPort.RecordBindingAsync(
+ scopeId,
+ memberId,
+ bindingResult.ServiceId,
+ bindingResult.RevisionId,
+ implementationKindWire,
+ ct);
+
+ return new StudioMemberBindingResponse(
+ MemberId: memberId,
+ PublishedServiceId: bindingResult.ServiceId,
+ RevisionId: bindingResult.RevisionId,
+ ImplementationKind: implementationKindWire,
+ ScopeId: scopeId,
+ ExpectedActorId: bindingResult.ExpectedActorId);
+ }
+
+ public async Task GetBindingAsync(
+ string scopeId,
+ string memberId,
+ CancellationToken ct = default)
+ {
+ var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct)
+ ?? throw new StudioMemberNotFoundException(scopeId, memberId);
+ return detail.LastBinding;
+ }
+
+ private static ScopeBindingUpsertRequest BuildScopeBindingRequest(
+ string scopeId,
+ string memberId,
+ string publishedServiceId,
+ string implementationKindWire,
+ string displayName,
+ UpdateStudioMemberBindingRequest request)
+ {
+ return implementationKindWire switch
+ {
+ MemberImplementationKindNames.Workflow => new ScopeBindingUpsertRequest(
+ ScopeId: scopeId,
+ ImplementationKind: ScopeBindingImplementationKind.Workflow,
+ Workflow: BuildWorkflowSpec(memberId, request),
+ DisplayName: displayName,
+ RevisionId: request.RevisionId,
+ ServiceId: publishedServiceId),
+
+ MemberImplementationKindNames.Script => new ScopeBindingUpsertRequest(
+ ScopeId: scopeId,
+ ImplementationKind: ScopeBindingImplementationKind.Scripting,
+ Script: BuildScriptSpec(memberId, request),
+ DisplayName: displayName,
+ RevisionId: request.RevisionId,
+ ServiceId: publishedServiceId),
+
+ MemberImplementationKindNames.GAgent => new ScopeBindingUpsertRequest(
+ ScopeId: scopeId,
+ ImplementationKind: ScopeBindingImplementationKind.GAgent,
+ GAgent: BuildGAgentSpec(memberId, request),
+ DisplayName: displayName,
+ RevisionId: request.RevisionId,
+ ServiceId: publishedServiceId),
+
+ _ => throw new InvalidOperationException(
+ $"member '{memberId}' has unsupported implementationKind '{implementationKindWire}'."),
+ };
+ }
+
+ private static StudioMemberImplementationRefResponse? BuildResolvedImplementationRef(
+ string implementationKindWire,
+ ScopeBindingUpsertResult bindingResult,
+ UpdateStudioMemberBindingRequest request)
+ {
+ switch (implementationKindWire)
+ {
+ case MemberImplementationKindNames.Workflow:
+ if (bindingResult.Workflow == null
+ || string.IsNullOrEmpty(bindingResult.Workflow.WorkflowName))
+ {
+ return null;
+ }
+ return new StudioMemberImplementationRefResponse(
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ WorkflowId: bindingResult.Workflow.WorkflowName,
+ WorkflowRevision: bindingResult.RevisionId);
+
+ case MemberImplementationKindNames.Script:
+ var scriptId = bindingResult.Script?.ScriptId
+ ?? request.Script?.ScriptId
+ ?? string.Empty;
+ if (string.IsNullOrEmpty(scriptId))
+ return null;
+ return new StudioMemberImplementationRefResponse(
+ ImplementationKind: MemberImplementationKindNames.Script,
+ ScriptId: scriptId,
+ ScriptRevision: bindingResult.Script?.ScriptRevision
+ ?? request.Script?.ScriptRevision);
+
+ case MemberImplementationKindNames.GAgent:
+ var actorTypeName = bindingResult.GAgent?.ActorTypeName
+ ?? request.GAgent?.ActorTypeName
+ ?? string.Empty;
+ if (string.IsNullOrEmpty(actorTypeName))
+ return null;
+ return new StudioMemberImplementationRefResponse(
+ ImplementationKind: MemberImplementationKindNames.GAgent,
+ ActorTypeName: actorTypeName);
+
+ default:
+ return null;
+ }
+ }
+
+ private static ScopeBindingWorkflowSpec BuildWorkflowSpec(
+ string memberId,
+ UpdateStudioMemberBindingRequest request)
+ {
+ if (request.Workflow == null || request.Workflow.WorkflowYamls.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"member '{memberId}' bind: workflow yamls are required for workflow members.");
+ }
+
+ return new ScopeBindingWorkflowSpec(request.Workflow.WorkflowYamls);
+ }
+
+ private static ScopeBindingScriptSpec BuildScriptSpec(
+ string memberId,
+ UpdateStudioMemberBindingRequest request)
+ {
+ if (request.Script == null || string.IsNullOrWhiteSpace(request.Script.ScriptId))
+ {
+ throw new InvalidOperationException(
+ $"member '{memberId}' bind: scriptId is required for script members.");
+ }
+
+ return new ScopeBindingScriptSpec(
+ ScriptId: request.Script.ScriptId,
+ ScriptRevision: request.Script.ScriptRevision);
+ }
+
+ private static ScopeBindingGAgentSpec BuildGAgentSpec(
+ string memberId,
+ UpdateStudioMemberBindingRequest request)
+ {
+ if (request.GAgent == null || string.IsNullOrWhiteSpace(request.GAgent.ActorTypeName))
+ {
+ throw new InvalidOperationException(
+ $"member '{memberId}' bind: actorTypeName is required for gagent members.");
+ }
+
+ var endpoints = (request.GAgent.Endpoints ?? [])
+ .Select(static e => new ScopeBindingGAgentEndpoint(
+ EndpointId: e.EndpointId,
+ DisplayName: e.DisplayName,
+ Kind: ParseEndpointKind(e.Kind),
+ RequestTypeUrl: e.RequestTypeUrl,
+ ResponseTypeUrl: e.ResponseTypeUrl,
+ Description: e.Description ?? string.Empty))
+ .ToList();
+
+ return new ScopeBindingGAgentSpec(
+ ActorTypeName: request.GAgent.ActorTypeName,
+ Endpoints: endpoints);
+ }
+
+ private static ServiceEndpointKind ParseEndpointKind(string? kind)
+ {
+ var normalized = kind?.Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "command" => ServiceEndpointKind.Command,
+ "chat" => ServiceEndpointKind.Chat,
+ _ => ServiceEndpointKind.Unspecified,
+ };
+ }
+}
diff --git a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj
index 8acad441b..097700073 100644
--- a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj
+++ b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj
@@ -21,4 +21,8 @@
+
+
+
+
diff --git a/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs
new file mode 100644
index 000000000..c3506f146
--- /dev/null
+++ b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs
@@ -0,0 +1,179 @@
+using Aevatar.Hosting;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace Aevatar.Studio.Hosting.Endpoints;
+
+///
+/// Member-first Studio HTTP surface mounted under
+/// /api/scopes/{scopeId}/members. Endpoints depend only on
+/// ; they never reach for the platform
+/// scope binding port directly. ServiceId is never accepted as a user-facing
+/// input — Studio binds to the member's own stable
+/// publishedServiceId.
+///
+/// Error mapping:
+/// - → 404
+/// - other (validation) → 400
+///
+internal static class StudioMemberEndpoints
+{
+ public static void Map(IEndpointRouteBuilder app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+
+ app.MapPost("/api/scopes/{scopeId}/members", HandleCreateAsync)
+ .WithTags("StudioMembers");
+ app.MapGet("/api/scopes/{scopeId}/members", HandleListAsync)
+ .WithTags("StudioMembers");
+ app.MapGet("/api/scopes/{scopeId}/members/{memberId}", HandleGetAsync)
+ .WithTags("StudioMembers");
+ app.MapPut("/api/scopes/{scopeId}/members/{memberId}/binding", HandleBindAsync)
+ .WithTags("StudioMembers");
+ app.MapGet("/api/scopes/{scopeId}/members/{memberId}/binding", HandleGetBindingAsync)
+ .WithTags("StudioMembers");
+ }
+
+ internal static async Task HandleCreateAsync(
+ HttpContext http,
+ string scopeId,
+ CreateStudioMemberRequest request,
+ IStudioMemberService memberService,
+ CancellationToken ct)
+ {
+ if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
+ return denied;
+
+ try
+ {
+ var summary = await memberService.CreateAsync(scopeId, request, ct);
+ return Results.Created($"/api/scopes/{scopeId}/members/{summary.MemberId}", summary);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message);
+ }
+ }
+
+ internal static async Task HandleListAsync(
+ HttpContext http,
+ string scopeId,
+ IStudioMemberService memberService,
+ int? pageSize,
+ string? pageToken,
+ CancellationToken ct)
+ {
+ if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
+ return denied;
+
+ try
+ {
+ var page = (pageSize.HasValue || !string.IsNullOrWhiteSpace(pageToken))
+ ? new StudioMemberRosterPageRequest(pageSize, pageToken)
+ : null;
+ return Results.Ok(await memberService.ListAsync(scopeId, page, ct));
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message);
+ }
+ }
+
+ internal static async Task HandleGetAsync(
+ HttpContext http,
+ string scopeId,
+ string memberId,
+ IStudioMemberService memberService,
+ CancellationToken ct)
+ {
+ if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
+ return denied;
+
+ try
+ {
+ return Results.Ok(await memberService.GetAsync(scopeId, memberId, ct));
+ }
+ catch (StudioMemberNotFoundException ex)
+ {
+ return NotFound(ex);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message);
+ }
+ }
+
+ internal static async Task HandleBindAsync(
+ HttpContext http,
+ string scopeId,
+ string memberId,
+ UpdateStudioMemberBindingRequest request,
+ IStudioMemberService memberService,
+ CancellationToken ct)
+ {
+ if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
+ return denied;
+
+ try
+ {
+ return Results.Ok(await memberService.BindAsync(scopeId, memberId, request, ct));
+ }
+ catch (StudioMemberNotFoundException ex)
+ {
+ return NotFound(ex);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest("INVALID_STUDIO_MEMBER_BINDING", ex.Message);
+ }
+ }
+
+ internal static async Task HandleGetBindingAsync(
+ HttpContext http,
+ string scopeId,
+ string memberId,
+ IStudioMemberService memberService,
+ CancellationToken ct)
+ {
+ if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
+ return denied;
+
+ try
+ {
+ // Three semantically distinct outcomes, three distinct HTTP shapes:
+ // - member missing → 404 STUDIO_MEMBER_NOT_FOUND
+ // - member exists, has been bound → 200 { lastBinding: }
+ // - member exists, never bound → 200 { lastBinding: null }
+ // Bare `404 NotFound` for the "exists but never bound" case used
+ // to overload 404 with two different meanings; the wrapper keeps
+ // the response always a JSON object with a single nullable field.
+ var binding = await memberService.GetBindingAsync(scopeId, memberId, ct);
+ return Results.Ok(new StudioMemberBindingViewResponse(binding));
+ }
+ catch (StudioMemberNotFoundException ex)
+ {
+ return NotFound(ex);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message);
+ }
+ }
+
+ private static IResult BadRequest(string code, string message) =>
+ Results.BadRequest(new { code, message });
+
+ private static IResult NotFound(StudioMemberNotFoundException ex) =>
+ Results.Json(
+ new
+ {
+ code = "STUDIO_MEMBER_NOT_FOUND",
+ message = ex.Message,
+ scopeId = ex.ScopeId,
+ memberId = ex.MemberId,
+ },
+ statusCode: StatusCodes.Status404NotFound);
+}
diff --git a/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs b/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs
index 49f05a403..56b543afa 100644
--- a/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs
+++ b/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs
@@ -59,6 +59,7 @@ public static WebApplicationBuilder AddStudioCapability(this WebApplicationBuild
{
app.MapControllers();
StudioEndpoints.Map(app, embeddedWorkflowMode: true);
+ StudioMemberEndpoints.Map(app);
Controllers.ChatHistoryEndpoints.MapChatHistoryEndpoints(app);
app.MapExplorerEndpoints();
});
diff --git a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs
index c866cec2b..99c8d70f4 100644
--- a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs
+++ b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs
@@ -9,6 +9,7 @@
using Aevatar.GAgents.Registry;
using Aevatar.GAgents.RoleCatalog;
using Aevatar.GAgents.StreamingProxyParticipant;
+using Aevatar.GAgents.StudioMember;
using Aevatar.GAgents.UserConfig;
using Aevatar.GAgents.UserMemory;
using Aevatar.Studio.Projection.ReadModels;
@@ -66,6 +67,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders(
RegisterElasticsearch(services, configuration);
RegisterElasticsearch(services, configuration);
RegisterElasticsearch(services, configuration);
+ RegisterElasticsearch(services, configuration);
}
else
{
@@ -77,6 +79,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders(
RegisterInMemory(services);
RegisterInMemory(services);
RegisterInMemory(services);
+ RegisterInMemory(services);
}
return services;
@@ -124,7 +127,8 @@ private static bool HasAllStudioDocumentReaders(
&& HasDocumentReaderForProvider(services, providerKind)
&& HasDocumentReaderForProvider(services, providerKind)
&& HasDocumentReaderForProvider(services, providerKind)
- && HasDocumentReaderForProvider(services, providerKind);
+ && HasDocumentReaderForProvider(services, providerKind)
+ && HasDocumentReaderForProvider(services, providerKind);
}
private static bool HasAnyDocumentReader(IServiceCollection services)
@@ -206,7 +210,8 @@ private static TypeRegistry BuildStudioStateTypeRegistry()
UserMemoryState.Descriptor,
StreamingProxyParticipantGAgentState.Descriptor,
ChatHistoryIndexState.Descriptor,
- ChatConversationState.Descriptor);
+ ChatConversationState.Descriptor,
+ StudioMemberState.Descriptor);
}
private enum DocumentProviderKind
diff --git a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj
index f297c57df..f20376e63 100644
--- a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj
+++ b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj
@@ -22,6 +22,7 @@
+
@@ -35,4 +36,8 @@
+
+
+
+
diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs
new file mode 100644
index 000000000..b6b10d444
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs
@@ -0,0 +1,197 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Projection.Mapping;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.Studio.Projection.CommandServices;
+
+///
+/// Dispatches StudioMember command events to the per-member
+/// actor. Uses the canonical actor-id
+/// convention (studio-member:{scopeId}:{memberId}) and ensures the
+/// actor + projection scope are activated before dispatch via
+/// .
+///
+internal sealed class ActorDispatchStudioMemberCommandService : IStudioMemberCommandPort
+{
+ private const string DirectRoute = "aevatar.studio.projection.studio-member";
+
+ private readonly IStudioActorBootstrap _bootstrap;
+ private readonly IActorDispatchPort _dispatchPort;
+
+ public ActorDispatchStudioMemberCommandService(
+ IStudioActorBootstrap bootstrap,
+ IActorDispatchPort dispatchPort)
+ {
+ _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
+ _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort));
+ }
+
+ public async Task CreateAsync(
+ string scopeId,
+ CreateStudioMemberRequest request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ // Length caps + slug pattern are enforced at the Application
+ // boundary (StudioMemberCreateRequestValidator). The transport-
+ // level guards here only ensure the actor-id remains derivable —
+ // scope normalization rejects ':', and an empty memberId is
+ // replaced by a generated one. Anything else is the caller's
+ // already-validated input.
+ var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId);
+ var memberId = string.IsNullOrWhiteSpace(request.MemberId)
+ ? GenerateMemberId()
+ : StudioMemberConventions.NormalizeMemberId(request.MemberId);
+
+ var displayName = (request.DisplayName ?? string.Empty).Trim();
+ var description = (request.Description ?? string.Empty).Trim();
+
+ var implementationKind = MemberImplementationKindMapper.Parse(request.ImplementationKind);
+ var publishedServiceId = StudioMemberConventions.BuildPublishedServiceId(memberId);
+ var createdAt = DateTimeOffset.UtcNow;
+
+ var evt = new StudioMemberCreatedEvent
+ {
+ MemberId = memberId,
+ ScopeId = normalizedScopeId,
+ DisplayName = displayName,
+ Description = description,
+ ImplementationKind = implementationKind,
+ PublishedServiceId = publishedServiceId,
+ CreatedAtUtc = Timestamp.FromDateTimeOffset(createdAt),
+ };
+
+ await DispatchAsync(normalizedScopeId, memberId, evt, ct);
+
+ return new StudioMemberSummaryResponse(
+ MemberId: memberId,
+ ScopeId: normalizedScopeId,
+ DisplayName: displayName,
+ Description: evt.Description,
+ ImplementationKind: MemberImplementationKindMapper.ToWireName(implementationKind),
+ LifecycleStage: MemberLifecycleStageNames.Created,
+ PublishedServiceId: publishedServiceId,
+ LastBoundRevisionId: null,
+ CreatedAt: createdAt,
+ UpdatedAt: createdAt);
+ }
+
+ public async Task UpdateImplementationAsync(
+ string scopeId,
+ string memberId,
+ StudioMemberImplementationRefResponse implementation,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(implementation);
+
+ var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId);
+ var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId);
+ var implementationKind = MemberImplementationKindMapper.Parse(implementation.ImplementationKind);
+
+ var evt = new StudioMemberImplementationUpdatedEvent
+ {
+ ImplementationKind = implementationKind,
+ ImplementationRef = BuildImplementationRefMessage(implementation),
+ UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ };
+
+ await DispatchAsync(normalizedScopeId, normalizedMemberId, evt, ct);
+ }
+
+ public async Task RecordBindingAsync(
+ string scopeId,
+ string memberId,
+ string publishedServiceId,
+ string revisionId,
+ string implementationKindName,
+ CancellationToken ct = default)
+ {
+ var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId);
+ var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId);
+
+ if (string.IsNullOrWhiteSpace(publishedServiceId))
+ {
+ throw new InvalidOperationException(
+ $"member '{normalizedMemberId}' bind: publishedServiceId is required to record binding.");
+ }
+
+ if (string.IsNullOrWhiteSpace(revisionId))
+ {
+ throw new InvalidOperationException(
+ $"member '{normalizedMemberId}' bind: revisionId is required to record binding.");
+ }
+
+ var evt = new StudioMemberBoundEvent
+ {
+ PublishedServiceId = publishedServiceId,
+ RevisionId = revisionId,
+ ImplementationKind = MemberImplementationKindMapper.Parse(implementationKindName),
+ BoundAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ };
+
+ await DispatchAsync(normalizedScopeId, normalizedMemberId, evt, ct);
+ }
+
+ private static StudioMemberImplementationRef BuildImplementationRefMessage(
+ StudioMemberImplementationRefResponse implementation)
+ {
+ var message = new StudioMemberImplementationRef();
+ switch (implementation.ImplementationKind)
+ {
+ case MemberImplementationKindNames.Workflow:
+ message.Workflow = new StudioMemberWorkflowRef
+ {
+ WorkflowId = implementation.WorkflowId ?? string.Empty,
+ WorkflowRevision = implementation.WorkflowRevision ?? string.Empty,
+ };
+ break;
+ case MemberImplementationKindNames.Script:
+ message.Script = new StudioMemberScriptRef
+ {
+ ScriptId = implementation.ScriptId ?? string.Empty,
+ ScriptRevision = implementation.ScriptRevision ?? string.Empty,
+ };
+ break;
+ case MemberImplementationKindNames.GAgent:
+ message.Gagent = new StudioMemberGAgentRef
+ {
+ ActorTypeName = implementation.ActorTypeName ?? string.Empty,
+ };
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unknown implementationKind '{implementation.ImplementationKind}'.");
+ }
+
+ return message;
+ }
+
+ private async Task DispatchAsync(string scopeId, string memberId, IMessage payload, CancellationToken ct)
+ {
+ var actorId = StudioMemberConventions.BuildActorId(scopeId, memberId);
+ var actor = await _bootstrap.EnsureAsync(actorId, ct);
+
+ var envelope = new EventEnvelope
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Timestamp = Timestamp.FromDateTime(DateTime.UtcNow),
+ Payload = Any.Pack(payload),
+ Route = EnvelopeRouteSemantics.CreateDirect(DirectRoute, actor.Id),
+ };
+
+ await _dispatchPort.DispatchAsync(actor.Id, envelope, ct);
+ }
+
+ private static string GenerateMemberId()
+ {
+ // Member ids are immutable identifiers; the publishedServiceId is
+ // derived directly from this value, so keep the format URL-safe and
+ // free of separators that StudioMemberConventions builds with (':').
+ return $"m-{Guid.NewGuid():N}";
+ }
+}
diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
index 4f556cece..1d2f88de1 100644
--- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
@@ -78,6 +78,10 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl
StudioMaterializationContext,
ChatConversationCurrentStateProjector>();
+ services.AddCurrentStateProjectionMaterializer<
+ StudioMaterializationContext,
+ StudioMemberCurrentStateProjector>();
+
// ── Document metadata providers (for index creation in Elasticsearch) ──
services.TryAddSingleton<
@@ -112,6 +116,10 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl
IProjectionDocumentMetadataProvider,
ChatConversationCurrentStateDocumentMetadataProvider>();
+ services.TryAddSingleton<
+ IProjectionDocumentMetadataProvider,
+ StudioMemberCurrentStateDocumentMetadataProvider>();
+
// Projection scope activation port — required so Studio projectors
// actually subscribe to their actor streams and materialize events.
services.TryAddSingleton();
@@ -124,9 +132,11 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl
// Query ports (read side)
services.TryAddSingleton();
+ services.TryAddSingleton();
// Command services (write side)
services.TryAddSingleton();
+ services.TryAddSingleton();
return services;
}
diff --git a/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs b/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs
new file mode 100644
index 000000000..1e680e5a7
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs
@@ -0,0 +1,48 @@
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Contracts;
+
+namespace Aevatar.Studio.Projection.Mapping;
+
+///
+/// Boundary mapping between the lowercase wire string used by Studio's HTTP
+/// surface and the strongly-typed proto enum used by the StudioMember actor.
+/// Lives in the Projection layer (alongside other StudioMember adapters)
+/// because the Application layer does not depend on the agent proto package
+/// — see CLAUDE.md `严格分层 / 上层依赖抽象`.
+///
+public static class MemberImplementationKindMapper
+{
+ public static StudioMemberImplementationKind Parse(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new InvalidOperationException("implementationKind is required.");
+
+ return value.Trim().ToLowerInvariant() switch
+ {
+ MemberImplementationKindNames.Workflow => StudioMemberImplementationKind.Workflow,
+ MemberImplementationKindNames.Script => StudioMemberImplementationKind.Script,
+ MemberImplementationKindNames.GAgent => StudioMemberImplementationKind.Gagent,
+ _ => throw new InvalidOperationException(
+ $"Unknown implementationKind '{value}'. " +
+ $"Expected one of: {MemberImplementationKindNames.Workflow}, " +
+ $"{MemberImplementationKindNames.Script}, " +
+ $"{MemberImplementationKindNames.GAgent}."),
+ };
+ }
+
+ public static string ToWireName(StudioMemberImplementationKind kind) => kind switch
+ {
+ StudioMemberImplementationKind.Workflow => MemberImplementationKindNames.Workflow,
+ StudioMemberImplementationKind.Script => MemberImplementationKindNames.Script,
+ StudioMemberImplementationKind.Gagent => MemberImplementationKindNames.GAgent,
+ _ => string.Empty,
+ };
+
+ public static string ToWireName(StudioMemberLifecycleStage stage) => stage switch
+ {
+ StudioMemberLifecycleStage.Created => MemberLifecycleStageNames.Created,
+ StudioMemberLifecycleStage.BuildReady => MemberLifecycleStageNames.BuildReady,
+ StudioMemberLifecycleStage.BindReady => MemberLifecycleStageNames.BindReady,
+ _ => string.Empty,
+ };
+}
diff --git a/src/Aevatar.Studio.Projection/Metadata/StudioMemberCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/StudioMemberCurrentStateDocumentMetadataProvider.cs
new file mode 100644
index 000000000..cc3e8c2a5
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/Metadata/StudioMemberCurrentStateDocumentMetadataProvider.cs
@@ -0,0 +1,17 @@
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+using Aevatar.Studio.Projection.ReadModels;
+
+namespace Aevatar.Studio.Projection.Metadata;
+
+public sealed class StudioMemberCurrentStateDocumentMetadataProvider
+ : IProjectionDocumentMetadataProvider
+{
+ public DocumentIndexMetadata Metadata { get; } = new(
+ IndexName: "studio-members",
+ Mappings: new Dictionary(StringComparer.Ordinal)
+ {
+ ["dynamic"] = true,
+ },
+ Settings: new Dictionary(StringComparer.Ordinal),
+ Aliases: new Dictionary(StringComparer.Ordinal));
+}
diff --git a/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs
new file mode 100644
index 000000000..7a9107d8a
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs
@@ -0,0 +1,117 @@
+using Aevatar.CQRS.Projection.Core.Abstractions;
+using Aevatar.CQRS.Projection.Core.Orchestration;
+using Aevatar.CQRS.Projection.Runtime.Abstractions;
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Projection.Mapping;
+using Aevatar.Studio.Projection.Orchestration;
+using Aevatar.Studio.Projection.ReadModels;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.Studio.Projection.Projectors;
+
+///
+/// Materializes committed events into
+/// . Surfaces a fully-typed
+/// projection of the authority — wire-stable string enums, denormalized
+/// implementation_ref, denormalized last_binding — so the query port never
+/// has to the actor's internal state.
+///
+public sealed class StudioMemberCurrentStateProjector
+ : ICurrentStateProjectionMaterializer
+{
+ private readonly IProjectionWriteDispatcher _writeDispatcher;
+ private readonly IProjectionClock _clock;
+
+ public StudioMemberCurrentStateProjector(
+ IProjectionWriteDispatcher writeDispatcher,
+ IProjectionClock clock)
+ {
+ _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher));
+ _clock = clock ?? throw new ArgumentNullException(nameof(clock));
+ }
+
+ public async ValueTask ProjectAsync(
+ StudioMaterializationContext context,
+ EventEnvelope envelope,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(envelope);
+
+ if (!CommittedStateEventEnvelope.TryUnpackState(
+ envelope,
+ out _,
+ out var stateEvent,
+ out var state) ||
+ stateEvent?.EventData == null ||
+ state == null)
+ {
+ return;
+ }
+
+ var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow);
+
+ var document = new StudioMemberCurrentStateDocument
+ {
+ Id = context.RootActorId,
+ ActorId = context.RootActorId,
+ StateVersion = stateEvent.Version,
+ LastEventId = stateEvent.EventId ?? string.Empty,
+ UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt),
+ MemberId = state.MemberId,
+ ScopeId = state.ScopeId,
+ DisplayName = state.DisplayName,
+ Description = state.Description,
+ ImplementationKind = MemberImplementationKindMapper.ToWireName(state.ImplementationKind),
+ LifecycleStage = MemberImplementationKindMapper.ToWireName(state.LifecycleStage),
+ PublishedServiceId = state.PublishedServiceId,
+ CreatedAt = state.CreatedAtUtc,
+ };
+
+ ApplyImplementationRef(document, state.ImplementationRef);
+ ApplyLastBinding(document, state.LastBinding);
+
+ await _writeDispatcher.UpsertAsync(document, ct);
+ }
+
+ private static void ApplyImplementationRef(
+ StudioMemberCurrentStateDocument document,
+ StudioMemberImplementationRef? implementationRef)
+ {
+ if (implementationRef == null)
+ return;
+
+ if (implementationRef.Workflow != null)
+ {
+ document.ImplementationWorkflowId = implementationRef.Workflow.WorkflowId ?? string.Empty;
+ document.ImplementationWorkflowRevision = implementationRef.Workflow.WorkflowRevision ?? string.Empty;
+ }
+
+ if (implementationRef.Script != null)
+ {
+ document.ImplementationScriptId = implementationRef.Script.ScriptId ?? string.Empty;
+ document.ImplementationScriptRevision = implementationRef.Script.ScriptRevision ?? string.Empty;
+ }
+
+ if (implementationRef.Gagent != null)
+ {
+ document.ImplementationActorTypeName = implementationRef.Gagent.ActorTypeName ?? string.Empty;
+ }
+ }
+
+ private static void ApplyLastBinding(
+ StudioMemberCurrentStateDocument document,
+ StudioMemberBindingContract? lastBinding)
+ {
+ if (lastBinding == null || string.IsNullOrEmpty(lastBinding.PublishedServiceId))
+ return;
+
+ document.LastBoundPublishedServiceId = lastBinding.PublishedServiceId;
+ document.LastBoundRevisionId = lastBinding.RevisionId ?? string.Empty;
+ document.LastBoundImplementationKind = MemberImplementationKindMapper.ToWireName(
+ lastBinding.ImplementationKind);
+ if (lastBinding.BoundAtUtc != null)
+ document.LastBoundAt = lastBinding.BoundAtUtc;
+ }
+}
diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs
new file mode 100644
index 000000000..6b1c9232a
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs
@@ -0,0 +1,176 @@
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Projection.ReadModels;
+
+namespace Aevatar.Studio.Projection.QueryPorts;
+
+///
+/// Reads StudioMember roster and detail from the projection document store.
+/// Pure query semantics — never replays events, never calls the actor
+/// runtime, never falls back to the scope binding read model. Roster scans
+/// are constrained to the requested scope_id using the denormalized
+/// projector field, so members from other scopes are not visible.
+///
+/// All fields read from the document are wire-stable strings. The query
+/// port does not unpack any
+/// payload — see CLAUDE.md `状态镜像契约面向查询` and the proto comment
+/// on .
+///
+public sealed class ProjectionStudioMemberQueryPort : IStudioMemberQueryPort
+{
+ public const int MaxRosterPageSize = 200;
+
+ private readonly IProjectionDocumentReader _documentReader;
+
+ public ProjectionStudioMemberQueryPort(
+ IProjectionDocumentReader documentReader)
+ {
+ _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader));
+ }
+
+ public async Task ListAsync(
+ string scopeId,
+ StudioMemberRosterPageRequest? page = null,
+ CancellationToken ct = default)
+ {
+ var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId);
+ var requestedPageSize = page?.PageSize ?? MaxRosterPageSize;
+ if (requestedPageSize <= 0 || requestedPageSize > MaxRosterPageSize)
+ requestedPageSize = MaxRosterPageSize;
+
+ var query = new ProjectionDocumentQuery
+ {
+ Filters =
+ [
+ new ProjectionDocumentFilter
+ {
+ FieldPath = "scope_id",
+ Operator = ProjectionDocumentFilterOperator.Eq,
+ Value = ProjectionDocumentValue.FromString(normalizedScopeId),
+ },
+ ],
+ Take = requestedPageSize,
+ Cursor = string.IsNullOrWhiteSpace(page?.PageToken) ? null : page!.PageToken,
+ };
+
+ var result = await _documentReader.QueryAsync(query, ct);
+ var summaries = result.Items
+ .Where(item => string.Equals(item.ScopeId, normalizedScopeId, StringComparison.Ordinal))
+ .Select(ToSummary)
+ .ToList();
+
+ return new StudioMemberRosterResponse(
+ ScopeId: normalizedScopeId,
+ Members: summaries,
+ NextPageToken: string.IsNullOrWhiteSpace(result.NextCursor) ? null : result.NextCursor);
+ }
+
+ public async Task GetAsync(
+ string scopeId,
+ string memberId,
+ CancellationToken ct = default)
+ {
+ var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId);
+ var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId);
+ var actorId = StudioMemberConventions.BuildActorId(normalizedScopeId, normalizedMemberId);
+
+ var document = await _documentReader.GetAsync(actorId, ct);
+ if (document == null)
+ return null;
+
+ if (!string.Equals(document.ScopeId, normalizedScopeId, StringComparison.Ordinal))
+ return null;
+
+ return ToDetail(document);
+ }
+
+ private static StudioMemberSummaryResponse ToSummary(StudioMemberCurrentStateDocument document)
+ {
+ return new StudioMemberSummaryResponse(
+ MemberId: document.MemberId,
+ ScopeId: document.ScopeId,
+ DisplayName: document.DisplayName,
+ Description: document.Description,
+ ImplementationKind: NormalizeImplementationKindWire(document.ImplementationKind),
+ LifecycleStage: NormalizeLifecycleStageWire(document.LifecycleStage),
+ PublishedServiceId: document.PublishedServiceId,
+ LastBoundRevisionId: string.IsNullOrEmpty(document.LastBoundRevisionId)
+ ? null
+ : document.LastBoundRevisionId,
+ CreatedAt: document.CreatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue,
+ UpdatedAt: document.UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue);
+ }
+
+ private static StudioMemberDetailResponse ToDetail(StudioMemberCurrentStateDocument document)
+ {
+ var summary = ToSummary(document);
+ var implementationRef = ToImplementationRefResponse(document, summary.ImplementationKind);
+ var lastBinding = ToLastBindingResponse(document);
+ return new StudioMemberDetailResponse(summary, implementationRef, lastBinding);
+ }
+
+ private static StudioMemberImplementationRefResponse? ToImplementationRefResponse(
+ StudioMemberCurrentStateDocument document,
+ string implementationKindWire)
+ {
+ if (!string.IsNullOrEmpty(document.ImplementationWorkflowId))
+ {
+ return new StudioMemberImplementationRefResponse(
+ ImplementationKind: implementationKindWire,
+ WorkflowId: document.ImplementationWorkflowId,
+ WorkflowRevision: string.IsNullOrEmpty(document.ImplementationWorkflowRevision)
+ ? null
+ : document.ImplementationWorkflowRevision);
+ }
+
+ if (!string.IsNullOrEmpty(document.ImplementationScriptId))
+ {
+ return new StudioMemberImplementationRefResponse(
+ ImplementationKind: implementationKindWire,
+ ScriptId: document.ImplementationScriptId,
+ ScriptRevision: string.IsNullOrEmpty(document.ImplementationScriptRevision)
+ ? null
+ : document.ImplementationScriptRevision);
+ }
+
+ if (!string.IsNullOrEmpty(document.ImplementationActorTypeName))
+ {
+ return new StudioMemberImplementationRefResponse(
+ ImplementationKind: implementationKindWire,
+ ActorTypeName: document.ImplementationActorTypeName);
+ }
+
+ return null;
+ }
+
+ private static StudioMemberBindingContractResponse? ToLastBindingResponse(
+ StudioMemberCurrentStateDocument document)
+ {
+ if (string.IsNullOrEmpty(document.LastBoundPublishedServiceId))
+ return null;
+
+ return new StudioMemberBindingContractResponse(
+ PublishedServiceId: document.LastBoundPublishedServiceId,
+ RevisionId: document.LastBoundRevisionId,
+ ImplementationKind: NormalizeImplementationKindWire(document.LastBoundImplementationKind),
+ BoundAt: document.LastBoundAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue);
+ }
+
+ private static string NormalizeImplementationKindWire(string? wire) => wire switch
+ {
+ MemberImplementationKindNames.Workflow => MemberImplementationKindNames.Workflow,
+ MemberImplementationKindNames.Script => MemberImplementationKindNames.Script,
+ MemberImplementationKindNames.GAgent => MemberImplementationKindNames.GAgent,
+ _ => string.Empty,
+ };
+
+ private static string NormalizeLifecycleStageWire(string? wire) => wire switch
+ {
+ MemberLifecycleStageNames.Created => MemberLifecycleStageNames.Created,
+ MemberLifecycleStageNames.BuildReady => MemberLifecycleStageNames.BuildReady,
+ MemberLifecycleStageNames.BindReady => MemberLifecycleStageNames.BindReady,
+ _ => string.Empty,
+ };
+}
diff --git a/src/Aevatar.Studio.Projection/ReadModels/StudioMemberCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/StudioMemberCurrentStateDocument.Partial.cs
new file mode 100644
index 000000000..de06791ce
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/ReadModels/StudioMemberCurrentStateDocument.Partial.cs
@@ -0,0 +1,18 @@
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+
+namespace Aevatar.Studio.Projection.ReadModels;
+
+public sealed partial class StudioMemberCurrentStateDocument
+ : IProjectionReadModel
+{
+ string IProjectionReadModel.ActorId => ActorId;
+
+ long IProjectionReadModel.StateVersion => StateVersion;
+
+ string IProjectionReadModel.LastEventId => LastEventId;
+
+ DateTimeOffset IProjectionReadModel.UpdatedAt
+ {
+ get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue;
+ }
+}
diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto
index 3da8758bb..37518d7ba 100644
--- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto
+++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto
@@ -100,3 +100,51 @@ message ChatConversationCurrentStateDocument {
google.protobuf.Timestamp updated_at = 5;
google.protobuf.Any state_root = 10;
}
+
+// ─── StudioMember Current State ReadModel ───
+//
+// Materialized from StudioMemberGAgent committed events. Document id is the
+// actor id (studio-member:{scopeId}:{memberId}). scope_id is the prefix
+// used by query ports to list members per scope; published_service_id is the
+// stable per-member service id used by member binding orchestration.
+//
+// CLAUDE.md `状态镜像契约面向查询` — read-model payload is a query-shaped
+// strongly-typed contract. Implementation_ref and last_binding are
+// denormalized to top-level fields (not packed via google.protobuf.Any of
+// the actor's internal state) so the query port never has to peek at the
+// authority's internal state layout. wire-stable strings (e.g.
+// implementation_kind = "workflow") are preferred over int enum codes so a
+// stale document can never silently round-trip an out-of-range value.
+
+message StudioMemberCurrentStateDocument {
+ string id = 1;
+ string actor_id = 2;
+ int64 state_version = 3;
+ string last_event_id = 4;
+ google.protobuf.Timestamp updated_at = 5;
+
+ // ── Identity / display ──
+ string member_id = 20;
+ string scope_id = 21;
+ string display_name = 22;
+ string description = 23;
+
+ // ── Lifecycle (wire-stable) ──
+ string implementation_kind = 24; // "workflow" | "script" | "gagent" | ""
+ string lifecycle_stage = 25; // "created" | "build_ready" | "bind_ready" | ""
+ string published_service_id = 26;
+ google.protobuf.Timestamp created_at = 28;
+
+ // ── Implementation ref (typed, only one of the *_id fields is set) ──
+ string implementation_workflow_id = 30;
+ string implementation_workflow_revision = 31;
+ string implementation_script_id = 32;
+ string implementation_script_revision = 33;
+ string implementation_actor_type_name = 34;
+
+ // ── Last-binding contract (denormalized) ──
+ string last_bound_published_service_id = 40;
+ string last_bound_revision_id = 41;
+ string last_bound_implementation_kind = 42; // wire-stable string
+ google.protobuf.Timestamp last_bound_at = 43;
+}
diff --git a/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs
new file mode 100644
index 000000000..3b8d245e9
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs
@@ -0,0 +1,255 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Projection.CommandServices;
+using FluentAssertions;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Locks in the write-side invariants for the StudioMember command service:
+///
+/// - CreateAsync routes through the canonical actor id and seeds the
+/// immutable publishedServiceId from the member id (rename-safe).
+/// - All three implementation kinds (workflow / script / gagent) build the
+/// typed implementation_ref the actor expects.
+/// - RecordBindingAsync rejects empty publishedServiceId / revisionId so the
+/// member authority cannot record a degenerate binding.
+/// - Dispatch always goes through IStudioActorBootstrap before
+/// IActorDispatchPort, so the projection scope is active before the
+/// command lands on the inbox.
+///
+public sealed class ActorDispatchStudioMemberCommandServiceTests
+{
+ private const string ScopeId = "scope-1";
+
+ [Fact]
+ public async Task CreateAsync_ShouldDispatchCreatedEventToCanonicalActor()
+ {
+ var bootstrap = new RecordingBootstrap();
+ var dispatch = new RecordingDispatchPort();
+ var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch);
+
+ var summary = await service.CreateAsync(
+ ScopeId,
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ Description: "first member",
+ MemberId: "m-alpha"),
+ CancellationToken.None);
+
+ summary.MemberId.Should().Be("m-alpha");
+ summary.ScopeId.Should().Be(ScopeId);
+ summary.PublishedServiceId.Should().Be("member-m-alpha");
+ summary.LifecycleStage.Should().Be(MemberLifecycleStageNames.Created);
+ summary.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow);
+
+ bootstrap.EnsuredActorIds.Should().ContainSingle()
+ .Which.Should().Be("studio-member:scope-1:m-alpha");
+ dispatch.Dispatches.Should().ContainSingle();
+
+ var dispatched = dispatch.Dispatches[0];
+ dispatched.ActorId.Should().Be("studio-member:scope-1:m-alpha");
+ dispatched.Envelope.Payload.Is(StudioMemberCreatedEvent.Descriptor).Should().BeTrue();
+ var evt = dispatched.Envelope.Payload.Unpack();
+ evt.MemberId.Should().Be("m-alpha");
+ evt.PublishedServiceId.Should().Be("member-m-alpha");
+ evt.DisplayName.Should().Be("Alpha");
+ evt.Description.Should().Be("first member");
+ }
+
+ [Fact]
+ public async Task CreateAsync_ShouldGenerateMemberId_WhenRequestOmitsIt()
+ {
+ var bootstrap = new RecordingBootstrap();
+ var dispatch = new RecordingDispatchPort();
+ var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch);
+
+ var summary = await service.CreateAsync(
+ ScopeId,
+ new CreateStudioMemberRequest(
+ DisplayName: "Auto",
+ ImplementationKind: MemberImplementationKindNames.Script),
+ CancellationToken.None);
+
+ summary.MemberId.Should().StartWith("m-");
+ summary.PublishedServiceId.Should().Be($"member-{summary.MemberId}");
+ summary.MemberId.Should().NotContain(":");
+ }
+
+ // Note: input validation (length caps, slug pattern, empty display
+ // name) is now enforced at the Application boundary in
+ // StudioMemberCreateRequestValidator. The Projection-layer command
+ // service is intentionally lenient and trusts already-validated input.
+ // Validator-level coverage lives in StudioMemberCreateRequestValidatorTests.
+
+ [Fact]
+ public async Task CreateAsync_ShouldRejectUnknownImplementationKind()
+ {
+ var service = new ActorDispatchStudioMemberCommandService(
+ new RecordingBootstrap(),
+ new RecordingDispatchPort());
+
+ var act = () => service.CreateAsync(
+ ScopeId,
+ new CreateStudioMemberRequest(
+ DisplayName: "Test",
+ ImplementationKind: "weird"),
+ CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Unknown implementationKind*");
+ }
+
+ [Theory]
+ [InlineData(MemberImplementationKindNames.Workflow)]
+ [InlineData(MemberImplementationKindNames.Script)]
+ [InlineData(MemberImplementationKindNames.GAgent)]
+ public async Task UpdateImplementationAsync_ShouldDispatchTypedRefForEachKind(string kind)
+ {
+ var bootstrap = new RecordingBootstrap();
+ var dispatch = new RecordingDispatchPort();
+ var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch);
+
+ var implementation = kind switch
+ {
+ MemberImplementationKindNames.Workflow => new StudioMemberImplementationRefResponse(
+ ImplementationKind: kind,
+ WorkflowId: "wf-1",
+ WorkflowRevision: "v1"),
+ MemberImplementationKindNames.Script => new StudioMemberImplementationRefResponse(
+ ImplementationKind: kind,
+ ScriptId: "s-1",
+ ScriptRevision: "v2"),
+ MemberImplementationKindNames.GAgent => new StudioMemberImplementationRefResponse(
+ ImplementationKind: kind,
+ ActorTypeName: "MyActor"),
+ _ => throw new InvalidOperationException("unreachable"),
+ };
+
+ await service.UpdateImplementationAsync(ScopeId, "m-1", implementation, CancellationToken.None);
+
+ dispatch.Dispatches.Should().ContainSingle();
+ var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack();
+ switch (kind)
+ {
+ case MemberImplementationKindNames.Workflow:
+ evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Workflow);
+ evt.ImplementationRef.Workflow.WorkflowId.Should().Be("wf-1");
+ evt.ImplementationRef.Workflow.WorkflowRevision.Should().Be("v1");
+ break;
+ case MemberImplementationKindNames.Script:
+ evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Script);
+ evt.ImplementationRef.Script.ScriptId.Should().Be("s-1");
+ evt.ImplementationRef.Script.ScriptRevision.Should().Be("v2");
+ break;
+ case MemberImplementationKindNames.GAgent:
+ evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Gagent);
+ evt.ImplementationRef.Gagent.ActorTypeName.Should().Be("MyActor");
+ break;
+ }
+ }
+
+ [Fact]
+ public async Task RecordBindingAsync_ShouldRejectEmptyPublishedServiceId()
+ {
+ var service = new ActorDispatchStudioMemberCommandService(
+ new RecordingBootstrap(), new RecordingDispatchPort());
+
+ var act = () => service.RecordBindingAsync(
+ ScopeId, "m-1", "", "rev-1", MemberImplementationKindNames.Workflow, CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*publishedServiceId is required*");
+ }
+
+ [Fact]
+ public async Task RecordBindingAsync_ShouldRejectEmptyRevisionId()
+ {
+ var service = new ActorDispatchStudioMemberCommandService(
+ new RecordingBootstrap(), new RecordingDispatchPort());
+
+ var act = () => service.RecordBindingAsync(
+ ScopeId, "m-1", "member-m-1", "", MemberImplementationKindNames.Workflow, CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*revisionId is required*");
+ }
+
+ [Fact]
+ public async Task RecordBindingAsync_ShouldDispatchBoundEvent()
+ {
+ var bootstrap = new RecordingBootstrap();
+ var dispatch = new RecordingDispatchPort();
+ var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch);
+
+ await service.RecordBindingAsync(
+ ScopeId,
+ "m-1",
+ "member-m-1",
+ "rev-7",
+ MemberImplementationKindNames.GAgent,
+ CancellationToken.None);
+
+ bootstrap.EnsuredActorIds.Should().ContainSingle()
+ .Which.Should().Be("studio-member:scope-1:m-1");
+ dispatch.Dispatches.Should().ContainSingle();
+ var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack();
+ evt.PublishedServiceId.Should().Be("member-m-1");
+ evt.RevisionId.Should().Be("rev-7");
+ evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Gagent);
+ }
+
+ [Fact]
+ public void Constructor_ShouldRejectNullDependencies()
+ {
+ FluentActions.Invoking(() =>
+ new ActorDispatchStudioMemberCommandService(null!, new RecordingDispatchPort()))
+ .Should().Throw();
+ FluentActions.Invoking(() =>
+ new ActorDispatchStudioMemberCommandService(new RecordingBootstrap(), null!))
+ .Should().Throw();
+ }
+
+ private sealed class RecordingBootstrap : IStudioActorBootstrap
+ {
+ public List EnsuredActorIds { get; } = [];
+
+ public Task EnsureAsync(string actorId, CancellationToken ct = default)
+ where TAgent : IAgent, IProjectedActor
+ {
+ EnsuredActorIds.Add(actorId);
+ return Task.FromResult(new StubActor(actorId));
+ }
+ }
+
+ private sealed class StubActor(string id) : IActor
+ {
+ public string Id { get; } = id;
+ public IAgent Agent => throw new NotSupportedException();
+ public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) =>
+ Task.CompletedTask;
+ public Task GetParentIdAsync() => Task.FromResult(null);
+ public Task> GetChildrenIdsAsync() =>
+ Task.FromResult>([]);
+ }
+
+ private sealed class RecordingDispatchPort : IActorDispatchPort
+ {
+ public List Dispatches { get; } = [];
+
+ public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default)
+ {
+ Dispatches.Add(new DispatchedCommand(actorId, envelope));
+ return Task.CompletedTask;
+ }
+
+ public sealed record DispatchedCommand(string ActorId, EventEnvelope Envelope);
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj b/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj
index e72be78c4..d4ce6a4c9 100644
--- a/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj
+++ b/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj
@@ -22,6 +22,9 @@
+
+
+
diff --git a/test/Aevatar.Studio.Tests/StudioMemberConventionsTests.cs b/test/Aevatar.Studio.Tests/StudioMemberConventionsTests.cs
new file mode 100644
index 000000000..d3b01830c
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberConventionsTests.cs
@@ -0,0 +1,71 @@
+using Aevatar.GAgents.StudioMember;
+using FluentAssertions;
+
+namespace Aevatar.Studio.Tests;
+
+public sealed class StudioMemberConventionsTests
+{
+ [Fact]
+ public void BuildActorId_ShouldFollowCanonicalLayout()
+ {
+ var actorId = StudioMemberConventions.BuildActorId("scope-1", "m-abc");
+ actorId.Should().Be("studio-member:scope-1:m-abc");
+ }
+
+ [Fact]
+ public void BuildActorId_ShouldRejectMissingScope()
+ {
+ var act = () => StudioMemberConventions.BuildActorId(" ", "m-abc");
+ act.Should().Throw().WithParameterName("scopeId");
+ }
+
+ [Fact]
+ public void BuildActorId_ShouldRejectMissingMember()
+ {
+ var act = () => StudioMemberConventions.BuildActorId("scope-1", null!);
+ act.Should().Throw().WithParameterName("memberId");
+ }
+
+ [Fact]
+ public void BuildPublishedServiceId_ShouldOnlyDependOnMemberId()
+ {
+ // Renaming display name later must not affect publishedServiceId,
+ // which is enforced by deriving it solely from the immutable id.
+ var first = StudioMemberConventions.BuildPublishedServiceId("m-abc");
+ var second = StudioMemberConventions.BuildPublishedServiceId("m-abc");
+ first.Should().Be(second);
+ first.Should().Be("member-m-abc");
+ }
+
+ [Fact]
+ public void BuildPublishedServiceId_ShouldDifferAcrossMembers()
+ {
+ var a = StudioMemberConventions.BuildPublishedServiceId("m-aaa");
+ var b = StudioMemberConventions.BuildPublishedServiceId("m-bbb");
+ a.Should().NotBe(b);
+ }
+
+ [Fact]
+ public void NormalizeScopeId_ShouldRejectActorIdSeparator()
+ {
+ // Allowing ':' would let a caller forge actor IDs by collision —
+ // e.g. "scope-1:m-evil" would round-trip to the actor id of a real
+ // member in scope-1. Reject at the boundary.
+ var act = () => StudioMemberConventions.NormalizeScopeId("scope:1");
+ act.Should().Throw().WithParameterName("scopeId");
+ }
+
+ [Fact]
+ public void NormalizeMemberId_ShouldRejectActorIdSeparator()
+ {
+ var act = () => StudioMemberConventions.NormalizeMemberId("m:nested");
+ act.Should().Throw().WithParameterName("memberId");
+ }
+
+ [Fact]
+ public void BuildActorId_ShouldRejectActorIdSeparatorInMemberId()
+ {
+ var act = () => StudioMemberConventions.BuildActorId("scope-1", "m:nested");
+ act.Should().Throw().WithParameterName("memberId");
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberCreateRequestValidatorTests.cs b/test/Aevatar.Studio.Tests/StudioMemberCreateRequestValidatorTests.cs
new file mode 100644
index 000000000..6e22053b4
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberCreateRequestValidatorTests.cs
@@ -0,0 +1,102 @@
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Application.Studio.Services;
+using FluentAssertions;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Application-layer enforcement of .
+/// These tests guard against the regression where the bounds drifted into
+/// the Projection-layer command service: swap the command port and they
+/// silently disappear. The validator is the single boundary now, and a
+/// missing call from would
+/// fail integration-level coverage too.
+///
+public sealed class StudioMemberCreateRequestValidatorTests
+{
+ [Fact]
+ public void Validate_ShouldRejectEmptyDisplayName()
+ {
+ var act = () => StudioMemberCreateRequestValidator.Validate(
+ new CreateStudioMemberRequest(
+ DisplayName: " ",
+ ImplementationKind: MemberImplementationKindNames.Workflow));
+
+ act.Should().Throw()
+ .WithMessage("*displayName is required*");
+ }
+
+ [Fact]
+ public void Validate_ShouldRejectDisplayNameOverCap()
+ {
+ var act = () => StudioMemberCreateRequestValidator.Validate(
+ new CreateStudioMemberRequest(
+ DisplayName: new string('a', StudioMemberInputLimits.MaxDisplayNameLength + 1),
+ ImplementationKind: MemberImplementationKindNames.Workflow));
+
+ act.Should().Throw()
+ .WithMessage("*displayName must be at most*");
+ }
+
+ [Fact]
+ public void Validate_ShouldRejectDescriptionOverCap()
+ {
+ var act = () => StudioMemberCreateRequestValidator.Validate(
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ Description: new string('a', StudioMemberInputLimits.MaxDescriptionLength + 1)));
+
+ act.Should().Throw()
+ .WithMessage("*description must be at most*");
+ }
+
+ [Fact]
+ public void Validate_ShouldRejectMemberIdViolatingSlugPattern()
+ {
+ var act = () => StudioMemberCreateRequestValidator.Validate(
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ MemberId: "m bad space"));
+
+ act.Should().Throw()
+ .WithMessage("*memberId must match*");
+ }
+
+ [Fact]
+ public void Validate_ShouldRejectMemberIdContainingActorIdSeparator()
+ {
+ var act = () => StudioMemberCreateRequestValidator.Validate(
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ MemberId: "m:nested"));
+
+ act.Should().Throw()
+ .WithMessage("*memberId must match*");
+ }
+
+ [Fact]
+ public void Validate_ShouldAcceptOmittedMemberId()
+ {
+ // Empty memberId is allowed — the projection-layer command service
+ // generates a random one. The validator must not reject this case.
+ var act = () => StudioMemberCreateRequestValidator.Validate(
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow));
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Validate_ShouldAcceptValidSlugMemberId()
+ {
+ var act = () => StudioMemberCreateRequestValidator.Validate(
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ MemberId: "m-good_1"));
+ act.Should().NotThrow();
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberCurrentStateProjectorTests.cs b/test/Aevatar.Studio.Tests/StudioMemberCurrentStateProjectorTests.cs
new file mode 100644
index 000000000..718a7492a
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberCurrentStateProjectorTests.cs
@@ -0,0 +1,240 @@
+using Aevatar.CQRS.Projection.Core.Abstractions;
+using Aevatar.CQRS.Projection.Runtime.Abstractions;
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Projection.Orchestration;
+using Aevatar.Studio.Projection.Projectors;
+using Aevatar.Studio.Projection.ReadModels;
+using FluentAssertions;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Locks in the projector contract: committed StudioMember state events are
+/// materialized into with the
+/// denormalized roster fields populated, and unrelated payloads are
+/// silently skipped (no spurious upserts).
+///
+public sealed class StudioMemberCurrentStateProjectorTests
+{
+ private const string RootActorId = "studio-member:scope-1:m-1";
+
+ [Fact]
+ public async Task ProjectAsync_ShouldUpsertDocument_WhenCommittedStateEventArrives()
+ {
+ var dispatcher = new RecordingWriteDispatcher();
+ var clock = new FixedProjectionClock(DateTimeOffset.Parse("2026-04-27T00:00:00Z"));
+ var projector = new StudioMemberCurrentStateProjector(dispatcher, clock);
+
+ var state = new StudioMemberState
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ DisplayName = "Test Member",
+ Description = "desc",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ PublishedServiceId = "member-m-1",
+ LifecycleStage = StudioMemberLifecycleStage.BindReady,
+ CreatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-1)),
+ UpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
+ ImplementationRef = new StudioMemberImplementationRef
+ {
+ Workflow = new StudioMemberWorkflowRef
+ {
+ WorkflowId = "wf-1",
+ WorkflowRevision = "rev-9",
+ },
+ },
+ LastBinding = new StudioMemberBindingContract
+ {
+ PublishedServiceId = "member-m-1",
+ RevisionId = "rev-9",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ BoundAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
+ },
+ };
+
+ var envelope = WrapCommitted(
+ payload: new StudioMemberBoundEvent { RevisionId = "rev-9" },
+ state: state,
+ version: 5,
+ eventId: "evt-9");
+
+ await projector.ProjectAsync(NewContext(), envelope);
+
+ dispatcher.Upserts.Should().ContainSingle();
+ var written = dispatcher.Upserts[0];
+
+ // Document identity follows the actor-id convention.
+ written.Id.Should().Be(RootActorId);
+ written.ActorId.Should().Be(RootActorId);
+ written.StateVersion.Should().Be(5);
+ written.LastEventId.Should().Be("evt-9");
+
+ // Denormalized roster fields are written so the query port doesn't
+ // have to unpack state_root for ListAsync.
+ written.MemberId.Should().Be("m-1");
+ written.ScopeId.Should().Be("scope-1");
+ written.DisplayName.Should().Be("Test Member");
+ written.PublishedServiceId.Should().Be("member-m-1");
+ written.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow);
+ written.LifecycleStage.Should().Be(MemberLifecycleStageNames.BindReady);
+
+ // implementation_ref denormalized — no Any-pack of internal state.
+ written.ImplementationWorkflowId.Should().Be("wf-1");
+ written.ImplementationWorkflowRevision.Should().Be("rev-9");
+ written.ImplementationScriptId.Should().BeEmpty();
+ written.ImplementationActorTypeName.Should().BeEmpty();
+
+ // last_binding denormalized.
+ written.LastBoundPublishedServiceId.Should().Be("member-m-1");
+ written.LastBoundRevisionId.Should().Be("rev-9");
+ written.LastBoundImplementationKind.Should().Be(MemberImplementationKindNames.Workflow);
+ written.LastBoundAt.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task ProjectAsync_ShouldDenormalizeScriptImplementation()
+ {
+ var dispatcher = new RecordingWriteDispatcher();
+ var projector = new StudioMemberCurrentStateProjector(
+ dispatcher, new FixedProjectionClock(DateTimeOffset.UtcNow));
+
+ var state = new StudioMemberState
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ DisplayName = "Script Member",
+ ImplementationKind = StudioMemberImplementationKind.Script,
+ PublishedServiceId = "member-m-1",
+ LifecycleStage = StudioMemberLifecycleStage.BuildReady,
+ CreatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
+ UpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
+ ImplementationRef = new StudioMemberImplementationRef
+ {
+ Script = new StudioMemberScriptRef { ScriptId = "s-1", ScriptRevision = "v3" },
+ },
+ };
+
+ await projector.ProjectAsync(
+ NewContext(),
+ WrapCommitted(new StudioMemberImplementationUpdatedEvent(), state, 1, "evt-1"));
+
+ var written = dispatcher.Upserts[0];
+ written.ImplementationKind.Should().Be(MemberImplementationKindNames.Script);
+ written.ImplementationScriptId.Should().Be("s-1");
+ written.ImplementationScriptRevision.Should().Be("v3");
+ written.ImplementationWorkflowId.Should().BeEmpty();
+ written.LastBoundPublishedServiceId.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ProjectAsync_ShouldNoOp_WhenPayloadIsNotCommittedStateEvent()
+ {
+ var dispatcher = new RecordingWriteDispatcher();
+ var clock = new FixedProjectionClock(DateTimeOffset.UtcNow);
+ var projector = new StudioMemberCurrentStateProjector(dispatcher, clock);
+
+ // A bare event envelope without the CommittedStateEventPublished
+ // wrapper must not produce a write — the projector is downstream of
+ // committed events only.
+ var envelope = new EventEnvelope
+ {
+ Id = "raw",
+ Timestamp = Timestamp.FromDateTime(DateTime.UtcNow),
+ Payload = Any.Pack(new StudioMemberCreatedEvent { MemberId = "m-1" }),
+ };
+
+ await projector.ProjectAsync(NewContext(), envelope);
+
+ dispatcher.Upserts.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ProjectAsync_ShouldRejectNullArguments()
+ {
+ var dispatcher = new RecordingWriteDispatcher();
+ var clock = new FixedProjectionClock(DateTimeOffset.UtcNow);
+ var projector = new StudioMemberCurrentStateProjector(dispatcher, clock);
+
+ await FluentActions
+ .Awaiting(() => projector.ProjectAsync(null!, new EventEnvelope()).AsTask())
+ .Should().ThrowAsync();
+ await FluentActions
+ .Awaiting(() => projector.ProjectAsync(NewContext(), null!).AsTask())
+ .Should().ThrowAsync();
+ }
+
+ [Fact]
+ public void Constructor_ShouldRejectNullDependencies()
+ {
+ var dispatcher = new RecordingWriteDispatcher();
+ var clock = new FixedProjectionClock(DateTimeOffset.UtcNow);
+
+ FluentActions
+ .Invoking(() => new StudioMemberCurrentStateProjector(null!, clock))
+ .Should().Throw();
+ FluentActions
+ .Invoking(() => new StudioMemberCurrentStateProjector(dispatcher, null!))
+ .Should().Throw();
+ }
+
+ private static StudioMaterializationContext NewContext() => new()
+ {
+ RootActorId = RootActorId,
+ ProjectionKind = "studio-current-state",
+ };
+
+ private static EventEnvelope WrapCommitted(
+ IMessage payload,
+ StudioMemberState state,
+ long version,
+ string eventId)
+ {
+ return new EventEnvelope
+ {
+ Id = eventId,
+ Timestamp = Timestamp.FromDateTime(DateTime.UtcNow),
+ Route = EnvelopeRouteSemantics.CreateObserverPublication(RootActorId),
+ Payload = Any.Pack(new CommittedStateEventPublished
+ {
+ StateEvent = new StateEvent
+ {
+ EventId = eventId,
+ Version = version,
+ EventData = Any.Pack(payload),
+ Timestamp = Timestamp.FromDateTime(DateTime.UtcNow),
+ },
+ StateRoot = Any.Pack(state),
+ }),
+ };
+ }
+
+ private sealed class RecordingWriteDispatcher : IProjectionWriteDispatcher
+ where TReadModel : class, IProjectionReadModel
+ {
+ public List Upserts { get; } = [];
+
+ public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ Upserts.Add(readModel);
+ return Task.FromResult(ProjectionWriteResult.Applied());
+ }
+
+ public Task DeleteAsync(string id, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult(ProjectionWriteResult.Applied());
+ }
+ }
+
+ private sealed class FixedProjectionClock(DateTimeOffset utcNow) : IProjectionClock
+ {
+ public DateTimeOffset UtcNow { get; } = utcNow;
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs
new file mode 100644
index 000000000..a64f21091
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs
@@ -0,0 +1,364 @@
+using System.Reflection;
+using System.Security.Claims;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Hosting.Endpoints;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Locks in the HTTP-handler invariants for member-first endpoints:
+///
+/// - Each handler defers to only.
+/// - Scope-access guard short-circuits with 403 before any service call.
+/// - Domain validation failures from the service map to 400 with a stable
+/// error code Studio's frontend can switch on.
+/// - GET endpoints map "no document" to 404 (not 200 with a null body).
+///
+public sealed class StudioMemberEndpointsTests
+{
+ private const string ScopeId = "scope-1";
+
+ [Fact]
+ public async Task HandleCreateAsync_ShouldReturnCreated_OnSuccess()
+ {
+ var service = new RecordingMemberService
+ {
+ CreateResponse = NewSummary(),
+ };
+
+ var result = await InvokeHandle(
+ "HandleCreateAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow),
+ service,
+ CancellationToken.None);
+
+ result.Should().BeOfType>()
+ .Which.Location.Should().Be($"/api/scopes/{ScopeId}/members/{NewSummary().MemberId}");
+ service.CreateInvoked.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task HandleCreateAsync_ShouldReturnBadRequest_OnDomainError()
+ {
+ var service = new RecordingMemberService
+ {
+ CreateException = new InvalidOperationException("displayName is required."),
+ };
+
+ var result = await InvokeHandle(
+ "HandleCreateAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ new CreateStudioMemberRequest(
+ DisplayName: string.Empty,
+ ImplementationKind: MemberImplementationKindNames.Workflow),
+ service,
+ CancellationToken.None);
+
+ // BadRequest — the anonymous type is internal, so we
+ // assert via the open generic shape rather than nailing the closed type.
+ result.GetType().Name.Should().StartWith("BadRequest");
+ }
+
+ [Fact]
+ public async Task HandleCreateAsync_ShouldReturnForbidden_WhenScopeAccessDenied()
+ {
+ var service = new RecordingMemberService();
+
+ var result = await InvokeHandle(
+ "HandleCreateAsync",
+ CreateAuthenticatedContext("other-scope"),
+ ScopeId,
+ new CreateStudioMemberRequest(
+ DisplayName: "Alpha",
+ ImplementationKind: MemberImplementationKindNames.Workflow),
+ service,
+ CancellationToken.None);
+
+ // Service must not be touched after the guard short-circuits.
+ service.CreateInvoked.Should().BeFalse();
+ // The denied result is JSON with statusCode 403; assertion via shape.
+ AssertIsJsonStatus(result, expectedStatus: StatusCodes.Status403Forbidden);
+ }
+
+ [Fact]
+ public async Task HandleListAsync_ShouldReturnOk_OnSuccess()
+ {
+ var service = new RecordingMemberService
+ {
+ ListResponse = new StudioMemberRosterResponse(ScopeId, [NewSummary()]),
+ };
+
+ var result = await InvokeHandle(
+ "HandleListAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ service,
+ (int?)null,
+ (string?)null,
+ CancellationToken.None);
+
+ result.Should().BeOfType>()
+ .Which.Value!.Members.Should().ContainSingle();
+ }
+
+ [Fact]
+ public async Task HandleGetAsync_ShouldReturnTyped404_WhenMemberMissing()
+ {
+ // GetAsync now throws StudioMemberNotFoundException for missing
+ // members; the endpoint returns the same typed 404 body that
+ // bind / get-binding do — three endpoints, one 404 shape.
+ var service = new RecordingMemberService
+ {
+ GetException = new StudioMemberNotFoundException(ScopeId, "m-missing"),
+ };
+
+ var result = await InvokeHandle(
+ "HandleGetAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ "m-missing",
+ service,
+ CancellationToken.None);
+
+ var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?;
+ statusCode.Should().Be(StatusCodes.Status404NotFound);
+ }
+
+ [Fact]
+ public async Task HandleGetAsync_ShouldReturnOk_WhenServiceReturnsDetail()
+ {
+ var detail = new StudioMemberDetailResponse(NewSummary(), null, null);
+ var service = new RecordingMemberService
+ {
+ GetResponse = detail,
+ };
+
+ var result = await InvokeHandle(
+ "HandleGetAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ "m-1",
+ service,
+ CancellationToken.None);
+
+ result.Should().BeOfType>()
+ .Which.Value.Should().BeSameAs(detail);
+ }
+
+ [Fact]
+ public async Task HandleBindAsync_ShouldReturnOk_OnSuccess()
+ {
+ var binding = new StudioMemberBindingResponse(
+ MemberId: "m-1",
+ PublishedServiceId: "member-m-1",
+ RevisionId: "rev-1",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ ScopeId: ScopeId,
+ ExpectedActorId: "actor");
+ var service = new RecordingMemberService
+ {
+ BindResponse = binding,
+ };
+
+ var result = await InvokeHandle(
+ "HandleBindAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ "m-1",
+ new UpdateStudioMemberBindingRequest(
+ Workflow: new StudioMemberWorkflowBindingSpec(["w:"])),
+ service,
+ CancellationToken.None);
+
+ result.Should().BeOfType>()
+ .Which.Value.Should().BeSameAs(binding);
+ }
+
+ [Fact]
+ public async Task HandleBindAsync_ShouldReturnBadRequest_OnDomainError()
+ {
+ var service = new RecordingMemberService
+ {
+ BindException = new InvalidOperationException("workflow yamls are required."),
+ };
+
+ var result = await InvokeHandle(
+ "HandleBindAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ "m-1",
+ new UpdateStudioMemberBindingRequest(),
+ service,
+ CancellationToken.None);
+
+ // BadRequest — the anonymous type is internal, so we
+ // assert via the open generic shape rather than nailing the closed type.
+ result.GetType().Name.Should().StartWith("BadRequest");
+ }
+
+ [Fact]
+ public async Task HandleGetBindingAsync_ShouldReturnOk_WithNullBinding_WhenMemberExistsButNeverBound()
+ {
+ // Disambiguates the prior 404 shape: a member that exists but has
+ // never been bound is NOT missing (which has its own typed 404).
+ // It's a member with a null binding — surface as 200 with the
+ // wrapper and let the frontend dispatch on `lastBinding === null`.
+ var service = new RecordingMemberService
+ {
+ GetBindingResponse = null,
+ };
+
+ var result = await InvokeHandle(
+ "HandleGetBindingAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ "m-1",
+ service,
+ CancellationToken.None);
+
+ result.Should().BeOfType>()
+ .Which.Value!.LastBinding.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task HandleGetBindingAsync_ShouldReturnOk_WhenServiceReturnsBinding()
+ {
+ var contract = new StudioMemberBindingContractResponse(
+ "member-m-1", "rev-1", MemberImplementationKindNames.Workflow, DateTimeOffset.UtcNow);
+ var service = new RecordingMemberService
+ {
+ GetBindingResponse = contract,
+ };
+
+ var result = await InvokeHandle(
+ "HandleGetBindingAsync",
+ CreateAuthenticatedContext(ScopeId),
+ ScopeId,
+ "m-1",
+ service,
+ CancellationToken.None);
+
+ result.Should().BeOfType>()
+ .Which.Value!.LastBinding.Should().BeSameAs(contract);
+ }
+
+ private static StudioMemberSummaryResponse NewSummary() => new(
+ MemberId: "m-1",
+ ScopeId: ScopeId,
+ DisplayName: "Alpha",
+ Description: string.Empty,
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ LifecycleStage: MemberLifecycleStageNames.Created,
+ PublishedServiceId: "member-m-1",
+ LastBoundRevisionId: null,
+ CreatedAt: DateTimeOffset.UtcNow,
+ UpdatedAt: DateTimeOffset.UtcNow);
+
+ private static HttpContext CreateAuthenticatedContext(string claimedScopeId)
+ {
+ var identity = new ClaimsIdentity(
+ [new Claim("scope_id", claimedScopeId)],
+ "test");
+ var services = new ServiceCollection()
+ .AddSingleton(new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Aevatar:Authentication:Enabled"] = "true",
+ })
+ .Build())
+ .AddSingleton(new TestHostEnvironment())
+ .BuildServiceProvider();
+ return new DefaultHttpContext
+ {
+ User = new ClaimsPrincipal(identity),
+ RequestServices = services,
+ };
+ }
+
+ private static void AssertIsJsonStatus(IResult result, int expectedStatus)
+ {
+ // ASP.NET Core's Results.Json yields a JsonHttpResult whose
+ // StatusCode property exposes the configured status. We check by
+ // reflection so this test stays decoupled from the precise generic.
+ var statusCodeProperty = result.GetType().GetProperty("StatusCode");
+ var statusCode = statusCodeProperty?.GetValue(result) as int?;
+ statusCode.Should().Be(expectedStatus,
+ because: $"expected JSON result with status {expectedStatus} but got {result.GetType().Name}");
+ }
+
+ private static async Task InvokeHandle(string methodName, params object?[] args)
+ {
+ var method = typeof(StudioMemberEndpoints)
+ .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException($"Method {methodName} not found.");
+ var task = (Task)method.Invoke(null, args)!;
+ return (TResult)(object)await task;
+ }
+
+ private sealed class RecordingMemberService : IStudioMemberService
+ {
+ public StudioMemberSummaryResponse? CreateResponse { get; set; }
+ public Exception? CreateException { get; set; }
+ public bool CreateInvoked { get; private set; }
+
+ public StudioMemberRosterResponse? ListResponse { get; set; }
+ public StudioMemberDetailResponse? GetResponse { get; set; }
+ public Exception? GetException { get; set; }
+ public StudioMemberBindingResponse? BindResponse { get; set; }
+ public Exception? BindException { get; set; }
+ public StudioMemberBindingContractResponse? GetBindingResponse { get; set; }
+
+ public Task CreateAsync(
+ string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default)
+ {
+ CreateInvoked = true;
+ if (CreateException != null) throw CreateException;
+ return Task.FromResult(CreateResponse!);
+ }
+
+ public Task ListAsync(
+ string scopeId,
+ StudioMemberRosterPageRequest? page = null,
+ CancellationToken ct = default)
+ => Task.FromResult(ListResponse ?? new StudioMemberRosterResponse(scopeId, []));
+
+ public Task GetAsync(
+ string scopeId, string memberId, CancellationToken ct = default)
+ {
+ if (GetException != null) throw GetException;
+ return Task.FromResult(
+ GetResponse ?? throw new StudioMemberNotFoundException(scopeId, memberId));
+ }
+
+ public Task BindAsync(
+ string scopeId, string memberId, UpdateStudioMemberBindingRequest request, CancellationToken ct = default)
+ {
+ if (BindException != null) throw BindException;
+ return Task.FromResult(BindResponse!);
+ }
+
+ public Task GetBindingAsync(
+ string scopeId, string memberId, CancellationToken ct = default)
+ => Task.FromResult(GetBindingResponse);
+ }
+
+ private sealed class TestHostEnvironment : IHostEnvironment
+ {
+ public string EnvironmentName { get; set; } = Environments.Production;
+ public string ApplicationName { get; set; } = "Aevatar.Studio.Tests";
+ public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
+ public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberGAgentStateTests.cs b/test/Aevatar.Studio.Tests/StudioMemberGAgentStateTests.cs
new file mode 100644
index 000000000..ad713fb0c
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberGAgentStateTests.cs
@@ -0,0 +1,146 @@
+using System.Reflection;
+using Aevatar.GAgents.StudioMember;
+using FluentAssertions;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Tests the StudioMember state machine in isolation by feeding events
+/// directly into the GAgent's TransitionState. Reflection bridges to
+/// the protected method so we can lock in the rename-safe publishedServiceId
+/// invariant from the issue without standing up the full actor runtime.
+///
+public sealed class StudioMemberGAgentStateTests
+{
+ private readonly StudioMemberStateApplier _agent = new();
+
+ [Fact]
+ public void Created_ShouldPersistPublishedServiceId()
+ {
+ var initial = new StudioMemberState();
+ var createdAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow);
+
+ var afterCreate = _agent.Apply(initial, new StudioMemberCreatedEvent
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ DisplayName = "Original",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ PublishedServiceId = "member-m-1",
+ CreatedAtUtc = createdAt,
+ });
+
+ afterCreate.MemberId.Should().Be("m-1");
+ afterCreate.PublishedServiceId.Should().Be("member-m-1");
+ afterCreate.LifecycleStage.Should().Be(StudioMemberLifecycleStage.Created);
+ }
+
+ [Fact]
+ public void Renamed_ShouldNotChangePublishedServiceId()
+ {
+ var created = _agent.Apply(new StudioMemberState(), new StudioMemberCreatedEvent
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ DisplayName = "Original",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ PublishedServiceId = "member-m-1",
+ CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ });
+
+ var renamed = _agent.Apply(created, new StudioMemberRenamedEvent
+ {
+ DisplayName = "Renamed Member",
+ Description = "Now with different name",
+ UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddSeconds(1)),
+ });
+
+ // Acceptance criterion from issue #325:
+ // "publishedServiceId is backend-generated, stable, and rename-safe"
+ renamed.PublishedServiceId.Should().Be(created.PublishedServiceId);
+ renamed.MemberId.Should().Be(created.MemberId);
+ renamed.DisplayName.Should().Be("Renamed Member");
+ renamed.Description.Should().Be("Now with different name");
+ }
+
+ [Fact]
+ public void ImplementationUpdated_ShouldAdvanceLifecycleToBuildReady()
+ {
+ var created = _agent.Apply(new StudioMemberState(), new StudioMemberCreatedEvent
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ DisplayName = "Original",
+ ImplementationKind = StudioMemberImplementationKind.Script,
+ PublishedServiceId = "member-m-1",
+ CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ });
+ created.LifecycleStage.Should().Be(StudioMemberLifecycleStage.Created);
+
+ var withImpl = _agent.Apply(created, new StudioMemberImplementationUpdatedEvent
+ {
+ ImplementationKind = StudioMemberImplementationKind.Script,
+ ImplementationRef = new StudioMemberImplementationRef
+ {
+ Script = new StudioMemberScriptRef
+ {
+ ScriptId = "s-1",
+ ScriptRevision = "v1",
+ },
+ },
+ UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddSeconds(1)),
+ });
+
+ withImpl.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BuildReady);
+ withImpl.ImplementationRef.Should().NotBeNull();
+ withImpl.ImplementationRef.Script.ScriptId.Should().Be("s-1");
+ }
+
+ [Fact]
+ public void Bound_ShouldCaptureLastBindingAndAdvanceLifecycle()
+ {
+ var withImpl = _agent.Apply(new StudioMemberState(), new StudioMemberCreatedEvent
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ DisplayName = "Original",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ PublishedServiceId = "member-m-1",
+ CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ });
+
+ var bound = _agent.Apply(withImpl, new StudioMemberBoundEvent
+ {
+ PublishedServiceId = "member-m-1",
+ RevisionId = "rev-7",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ BoundAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddSeconds(2)),
+ });
+
+ bound.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BindReady);
+ bound.LastBinding.Should().NotBeNull();
+ bound.LastBinding.PublishedServiceId.Should().Be("member-m-1");
+ bound.LastBinding.RevisionId.Should().Be("rev-7");
+ bound.PublishedServiceId.Should().Be("member-m-1");
+ }
+
+ private sealed class StudioMemberStateApplier
+ {
+ private static readonly MethodInfo TransitionStateMethod =
+ typeof(StudioMemberGAgent).GetMethod(
+ "TransitionState",
+ BindingFlags.Instance | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException("TransitionState method not found.");
+
+ private readonly StudioMemberGAgent _agent = new();
+
+ public StudioMemberState Apply(StudioMemberState current, IMessage evt)
+ {
+ var result = TransitionStateMethod.Invoke(_agent, [current, evt])
+ ?? throw new InvalidOperationException("TransitionState returned null.");
+ return (StudioMemberState)result;
+ }
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberImplementationKindMapperTests.cs b/test/Aevatar.Studio.Tests/StudioMemberImplementationKindMapperTests.cs
new file mode 100644
index 000000000..7d5c5cda9
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberImplementationKindMapperTests.cs
@@ -0,0 +1,68 @@
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Projection.Mapping;
+using FluentAssertions;
+
+namespace Aevatar.Studio.Tests;
+
+public sealed class StudioMemberImplementationKindMapperTests
+{
+ [Theory]
+ [InlineData("workflow", StudioMemberImplementationKind.Workflow)]
+ [InlineData("WORKFLOW", StudioMemberImplementationKind.Workflow)]
+ [InlineData("script", StudioMemberImplementationKind.Script)]
+ [InlineData("gagent", StudioMemberImplementationKind.Gagent)]
+ public void Parse_ShouldMapKnownKindsCaseInsensitive(string wire, StudioMemberImplementationKind expected)
+ {
+ MemberImplementationKindMapper.Parse(wire).Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Parse_ShouldRejectMissingValue(string? wire)
+ {
+ var act = () => MemberImplementationKindMapper.Parse(wire);
+ act.Should().Throw()
+ .WithMessage("*implementationKind is required*");
+ }
+
+ [Fact]
+ public void Parse_ShouldRejectUnknownValue()
+ {
+ var act = () => MemberImplementationKindMapper.Parse("worker");
+ act.Should().Throw()
+ .WithMessage("*Unknown implementationKind*");
+ }
+
+ [Theory]
+ [InlineData(StudioMemberImplementationKind.Workflow, "workflow")]
+ [InlineData(StudioMemberImplementationKind.Script, "script")]
+ [InlineData(StudioMemberImplementationKind.Gagent, "gagent")]
+ public void ToWireName_ImplementationKind_ShouldMapBack(
+ StudioMemberImplementationKind kind, string expected)
+ {
+ MemberImplementationKindMapper.ToWireName(kind).Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData(StudioMemberLifecycleStage.Created, "created")]
+ [InlineData(StudioMemberLifecycleStage.BuildReady, "build_ready")]
+ [InlineData(StudioMemberLifecycleStage.BindReady, "bind_ready")]
+ public void ToWireName_LifecycleStage_ShouldMapToWireName(
+ StudioMemberLifecycleStage stage, string expected)
+ {
+ MemberImplementationKindMapper.ToWireName(stage).Should().Be(expected);
+ }
+
+ [Fact]
+ public void ToWireName_LifecycleStage_ShouldReturnEmpty_ForUnspecified()
+ {
+ // An Unspecified value indicates a malformed projection; previously
+ // this silently mapped to "created", which lied to callers.
+ MemberImplementationKindMapper
+ .ToWireName(StudioMemberLifecycleStage.Unspecified)
+ .Should().BeEmpty();
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs b/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs
new file mode 100644
index 000000000..4a2bf4657
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs
@@ -0,0 +1,272 @@
+using System.Reflection;
+using System.Security.Claims;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Hosting.Endpoints;
+using FluentAssertions;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Regression tests for the PR-review fix-ups:
+/// - lifecycle downgrade on out-of-band implementation update post-bind
+/// - ApplyCreated re-derives publishedServiceId from convention
+/// - HandleCreated rejects re-create with mismatched non-identity fields
+/// - input validation: length caps + slug pattern on memberId
+/// - HTTP 404 for missing member (not 400)
+/// - StudioMemberRosterResponse carries NextPageToken
+///
+public sealed class StudioMemberPRReviewFixesTests
+{
+ private static readonly MethodInfo TransitionStateMethod =
+ typeof(StudioMemberGAgent).GetMethod(
+ "TransitionState", BindingFlags.Instance | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException("TransitionState method not found.");
+
+ [Fact]
+ public void ApplyCreated_ShouldRederivePublishedServiceId_IgnoringEventValue()
+ {
+ // The dispatcher today builds publishedServiceId via the same
+ // convention; rebuilding inside the actor protects against a stale
+ // / hand-rolled event whose derivation rule drifted.
+ var agent = new StudioMemberGAgent();
+ var current = new StudioMemberState();
+ var evt = new StudioMemberCreatedEvent
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ DisplayName = "Original",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ // Adversarial: event payload claims a different publishedServiceId.
+ PublishedServiceId = "evil-service-id",
+ CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ };
+
+ var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [current, evt])!;
+
+ next.PublishedServiceId.Should().Be(StudioMemberConventions.BuildPublishedServiceId("m-1"));
+ next.PublishedServiceId.Should().NotBe("evil-service-id");
+ }
+
+ [Fact]
+ public void ApplyImplementationUpdated_ShouldDowngradeBindReadyToBuildReady()
+ {
+ var agent = new StudioMemberGAgent();
+ var bound = new StudioMemberState
+ {
+ MemberId = "m-1",
+ ScopeId = "scope-1",
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ PublishedServiceId = "member-m-1",
+ LifecycleStage = StudioMemberLifecycleStage.BindReady,
+ ImplementationRef = new StudioMemberImplementationRef
+ {
+ Workflow = new StudioMemberWorkflowRef { WorkflowId = "wf-1", WorkflowRevision = "v1" },
+ },
+ };
+
+ var implUpdate = new StudioMemberImplementationUpdatedEvent
+ {
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ ImplementationRef = new StudioMemberImplementationRef
+ {
+ Workflow = new StudioMemberWorkflowRef { WorkflowId = "wf-1", WorkflowRevision = "v2" },
+ },
+ UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ };
+
+ var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [bound, implUpdate])!;
+
+ next.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BuildReady,
+ because: "the published revision is now stale until the next bind");
+ next.ImplementationRef.Workflow.WorkflowRevision.Should().Be("v2");
+ }
+
+ [Fact]
+ public void ApplyImplementationUpdated_ShouldUpgradeCreatedToBuildReady()
+ {
+ var agent = new StudioMemberGAgent();
+ var created = new StudioMemberState
+ {
+ MemberId = "m-1",
+ LifecycleStage = StudioMemberLifecycleStage.Created,
+ };
+
+ var implUpdate = new StudioMemberImplementationUpdatedEvent
+ {
+ ImplementationKind = StudioMemberImplementationKind.Script,
+ ImplementationRef = new StudioMemberImplementationRef
+ {
+ Script = new StudioMemberScriptRef { ScriptId = "s-1", ScriptRevision = "v1" },
+ },
+ UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ };
+
+ var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [created, implUpdate])!;
+
+ next.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BuildReady);
+ }
+
+ // The HandleCreated conflict-detection branch is exercised via the
+ // actor runtime (StateGuard requires an EventHandler scope to mutate
+ // state). The unit-level state-machine view is locked in by
+ // StudioMemberGAgentStateTests; a future host-level test covers the
+ // full HandleCreated flow.
+
+ [Fact]
+ public void ApplyImplementationUpdated_ShouldNotMutateImplementationKind()
+ {
+ // ImplementationKind is locked at create. Even on a replayed or
+ // hand-rolled event whose ImplementationKind disagrees with the
+ // existing state, the apply step must preserve State.ImplementationKind
+ // so a Script member can never be silently mutated into a Workflow
+ // member through the implementation-updated event path.
+ var agent = new StudioMemberGAgent();
+ var existing = new StudioMemberState
+ {
+ MemberId = "m-1",
+ ImplementationKind = StudioMemberImplementationKind.Script,
+ LifecycleStage = StudioMemberLifecycleStage.BuildReady,
+ };
+
+ var driftEvent = new StudioMemberImplementationUpdatedEvent
+ {
+ // Adversarial: event tries to switch the kind to Workflow.
+ ImplementationKind = StudioMemberImplementationKind.Workflow,
+ ImplementationRef = new StudioMemberImplementationRef
+ {
+ Workflow = new StudioMemberWorkflowRef { WorkflowId = "wf-1" },
+ },
+ UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ };
+
+ var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [existing, driftEvent])!;
+
+ next.ImplementationKind.Should().Be(StudioMemberImplementationKind.Script,
+ because: "ImplementationKind is locked at create; the apply step must not let it drift");
+ }
+
+ [Fact]
+ public void StudioMemberInputLimits_ShouldRejectLongDisplayName()
+ {
+ // Sanity: the constants are reasonable and the regex rejects ':' / spaces.
+ StudioMemberInputLimits.MaxDisplayNameLength.Should().BeGreaterThan(64);
+ StudioMemberInputLimits.MemberIdPattern.IsMatch("m-good_1").Should().BeTrue();
+ StudioMemberInputLimits.MemberIdPattern.IsMatch("m:nested").Should().BeFalse();
+ StudioMemberInputLimits.MemberIdPattern.IsMatch(" leadingSpace").Should().BeFalse();
+ StudioMemberInputLimits.MemberIdPattern.IsMatch(new string('a', 65)).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task HandleBindAsync_ShouldReturn404_WhenMemberNotFoundExceptionThrown()
+ {
+ var service = new ThrowingService(new StudioMemberNotFoundException("scope-1", "m-missing"));
+
+ var result = await InvokeHandle(
+ "HandleBindAsync",
+ CreateAuthenticatedContext("scope-1"),
+ "scope-1",
+ "m-missing",
+ new UpdateStudioMemberBindingRequest(),
+ service,
+ CancellationToken.None);
+
+ var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?;
+ statusCode.Should().Be(StatusCodes.Status404NotFound);
+ }
+
+ [Fact]
+ public async Task HandleGetBindingAsync_ShouldReturn404_WhenMemberNotFoundExceptionThrown()
+ {
+ var service = new ThrowingService(new StudioMemberNotFoundException("scope-1", "m-missing"));
+
+ var result = await InvokeHandle(
+ "HandleGetBindingAsync",
+ CreateAuthenticatedContext("scope-1"),
+ "scope-1",
+ "m-missing",
+ service,
+ CancellationToken.None);
+
+ var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?;
+ statusCode.Should().Be(StatusCodes.Status404NotFound);
+ }
+
+ [Fact]
+ public void StudioMemberRosterResponse_ShouldCarryNextPageToken()
+ {
+ var roster = new StudioMemberRosterResponse(
+ ScopeId: "scope-1",
+ Members: [],
+ NextPageToken: "cursor-1");
+
+ roster.NextPageToken.Should().Be("cursor-1");
+ }
+
+ private static HttpContext CreateAuthenticatedContext(string scopeId)
+ {
+ var identity = new ClaimsIdentity([new Claim("scope_id", scopeId)], "test");
+ var services = new ServiceCollection()
+ .AddSingleton(new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Aevatar:Authentication:Enabled"] = "true",
+ })
+ .Build())
+ .AddSingleton(new TestHostEnvironment())
+ .BuildServiceProvider();
+ return new DefaultHttpContext
+ {
+ User = new ClaimsPrincipal(identity),
+ RequestServices = services,
+ };
+ }
+
+ private static async Task InvokeHandle(string methodName, params object?[] args)
+ {
+ var method = typeof(StudioMemberEndpoints).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException($"Method {methodName} not found.");
+ var task = (Task)method.Invoke(null, args)!;
+ return await task;
+ }
+
+ private sealed class ThrowingService : IStudioMemberService
+ {
+ private readonly Exception _ex;
+
+ public ThrowingService(Exception ex)
+ {
+ _ex = ex;
+ }
+
+ public Task CreateAsync(
+ string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default) => throw _ex;
+
+ public Task ListAsync(
+ string scopeId, StudioMemberRosterPageRequest? page = null, CancellationToken ct = default) => throw _ex;
+
+ public Task GetAsync(
+ string scopeId, string memberId, CancellationToken ct = default) => throw _ex;
+
+ public Task BindAsync(
+ string scopeId, string memberId, UpdateStudioMemberBindingRequest request, CancellationToken ct = default) => throw _ex;
+
+ public Task GetBindingAsync(
+ string scopeId, string memberId, CancellationToken ct = default) => throw _ex;
+ }
+
+ private sealed class TestHostEnvironment : IHostEnvironment
+ {
+ public string EnvironmentName { get; set; } = Environments.Production;
+ public string ApplicationName { get; set; } = "Aevatar.Studio.Tests";
+ public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
+ public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberQueryPortTests.cs b/test/Aevatar.Studio.Tests/StudioMemberQueryPortTests.cs
new file mode 100644
index 000000000..165344467
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberQueryPortTests.cs
@@ -0,0 +1,298 @@
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Projection.QueryPorts;
+using Aevatar.Studio.Projection.ReadModels;
+using FluentAssertions;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Locks in the read-side invariants for the StudioMember query port:
+///
+/// - GetAsync uses the canonical actor-id key and is scope-pinned (a member
+/// from another scope must not leak).
+/// - ListAsync filters by scope_id and surfaces the denormalized roster
+/// fields the projector wrote (publishedServiceId, lifecycle, etc.).
+/// - Detail unpacks the typed implementation_ref and last_binding from the
+/// state_root rather than re-deriving them.
+///
+public sealed class ProjectionStudioMemberQueryPortTests
+{
+ private const string ScopeId = "scope-1";
+
+ [Fact]
+ public async Task GetAsync_ShouldReturnDetail_WhenDocumentExists()
+ {
+ var document = NewDocument(
+ scopeId: ScopeId,
+ memberId: "m-1",
+ implementationKind: StudioMemberImplementationKind.Workflow,
+ lifecycle: StudioMemberLifecycleStage.BuildReady,
+ includeImplementationRef: true,
+ includeLastBinding: true);
+
+ var reader = new StubDocumentReader([document]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var detail = await port.GetAsync(ScopeId, "m-1");
+
+ detail.Should().NotBeNull();
+ detail!.Summary.MemberId.Should().Be("m-1");
+ detail.Summary.PublishedServiceId.Should().Be("member-m-1");
+ detail.Summary.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow);
+ detail.Summary.LifecycleStage.Should().Be(MemberLifecycleStageNames.BuildReady);
+ detail.ImplementationRef.Should().NotBeNull();
+ detail.ImplementationRef!.WorkflowId.Should().Be("wf-1");
+ detail.ImplementationRef.WorkflowRevision.Should().Be("v2");
+ detail.LastBinding.Should().NotBeNull();
+ detail.LastBinding!.RevisionId.Should().Be("rev-bind");
+ }
+
+ [Fact]
+ public async Task GetAsync_ShouldReturnNull_WhenDocumentMissing()
+ {
+ var reader = new StubDocumentReader([]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var detail = await port.GetAsync(ScopeId, "m-missing");
+
+ detail.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetAsync_ShouldReturnNull_WhenDocumentExistsInDifferentScope()
+ {
+ // The document with this id exists, but its scope_id is different.
+ // Read port must reject so callers cannot probe across scopes by
+ // guessing the actor-id layout.
+ var foreign = NewDocument(scopeId: "scope-other", memberId: "m-1");
+ // Stub reader lookups by id, so the lookup will succeed here but the
+ // port should still filter by the scope_id field.
+ var reader = new StubDocumentReader([foreign]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var detail = await port.GetAsync(ScopeId, "m-1");
+
+ detail.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task ListAsync_ShouldReturnOnlyMembersInScope()
+ {
+ var inScopeA = NewDocument(scopeId: ScopeId, memberId: "m-1");
+ var inScopeB = NewDocument(scopeId: ScopeId, memberId: "m-2");
+ var inOtherScope = NewDocument(scopeId: "scope-other", memberId: "m-3");
+
+ var reader = new StubDocumentReader([inScopeA, inScopeB, inOtherScope]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var roster = await port.ListAsync(ScopeId);
+
+ roster.ScopeId.Should().Be(ScopeId);
+ roster.Members.Select(m => m.MemberId).Should().BeEquivalentTo("m-1", "m-2");
+ }
+
+ [Fact]
+ public async Task ListAsync_ShouldReturnEmpty_WhenScopeHasNoMembers()
+ {
+ var reader = new StubDocumentReader([]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var roster = await port.ListAsync(ScopeId);
+
+ roster.ScopeId.Should().Be(ScopeId);
+ roster.Members.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetAsync_ShouldSurfaceScriptImplementationRef()
+ {
+ var document = NewDocumentWithImplementation(
+ implementationKind: StudioMemberImplementationKind.Script,
+ implementationRef: new StudioMemberImplementationRef
+ {
+ Script = new StudioMemberScriptRef
+ {
+ ScriptId = "s-1",
+ ScriptRevision = "v9",
+ },
+ });
+
+ var reader = new StubDocumentReader([document]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var detail = await port.GetAsync(ScopeId, "m-1");
+
+ detail!.ImplementationRef!.ScriptId.Should().Be("s-1");
+ detail.ImplementationRef.ScriptRevision.Should().Be("v9");
+ detail.ImplementationRef.ImplementationKind.Should().Be(MemberImplementationKindNames.Script);
+ }
+
+ [Fact]
+ public async Task GetAsync_ShouldSurfaceGAgentImplementationRef()
+ {
+ var document = NewDocumentWithImplementation(
+ implementationKind: StudioMemberImplementationKind.Gagent,
+ implementationRef: new StudioMemberImplementationRef
+ {
+ Gagent = new StudioMemberGAgentRef
+ {
+ ActorTypeName = "MyActor",
+ },
+ });
+
+ var reader = new StubDocumentReader([document]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var detail = await port.GetAsync(ScopeId, "m-1");
+
+ detail!.ImplementationRef!.ActorTypeName.Should().Be("MyActor");
+ detail.ImplementationRef.ImplementationKind.Should().Be(MemberImplementationKindNames.GAgent);
+ }
+
+ [Fact]
+ public async Task GetAsync_ShouldReturnNullImplementationRef_WhenMissing()
+ {
+ var document = NewDocument(
+ scopeId: ScopeId,
+ memberId: "m-1",
+ includeImplementationRef: false,
+ includeLastBinding: false);
+
+ var reader = new StubDocumentReader([document]);
+ var port = new ProjectionStudioMemberQueryPort(reader);
+
+ var detail = await port.GetAsync(ScopeId, "m-1");
+
+ detail!.ImplementationRef.Should().BeNull();
+ detail.LastBinding.Should().BeNull();
+ }
+
+ private static StudioMemberCurrentStateDocument NewDocument(
+ string scopeId,
+ string memberId,
+ StudioMemberImplementationKind implementationKind = StudioMemberImplementationKind.Workflow,
+ StudioMemberLifecycleStage lifecycle = StudioMemberLifecycleStage.Created,
+ bool includeImplementationRef = false,
+ bool includeLastBinding = false)
+ {
+ var actorId = StudioMemberConventions.BuildActorId(scopeId, memberId);
+ var publishedServiceId = StudioMemberConventions.BuildPublishedServiceId(memberId);
+ var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow);
+
+ var doc = new StudioMemberCurrentStateDocument
+ {
+ Id = actorId,
+ ActorId = actorId,
+ StateVersion = 1,
+ LastEventId = "evt-1",
+ UpdatedAt = now,
+ MemberId = memberId,
+ ScopeId = scopeId,
+ DisplayName = $"Member {memberId}",
+ Description = string.Empty,
+ ImplementationKind = ToWireKind(implementationKind),
+ LifecycleStage = ToWireStage(lifecycle),
+ PublishedServiceId = publishedServiceId,
+ CreatedAt = now,
+ };
+
+ if (includeImplementationRef)
+ {
+ doc.ImplementationWorkflowId = "wf-1";
+ doc.ImplementationWorkflowRevision = "v2";
+ }
+
+ if (includeLastBinding)
+ {
+ doc.LastBoundPublishedServiceId = publishedServiceId;
+ doc.LastBoundRevisionId = "rev-bind";
+ doc.LastBoundImplementationKind = ToWireKind(implementationKind);
+ doc.LastBoundAt = now;
+ }
+
+ return doc;
+ }
+
+ private static StudioMemberCurrentStateDocument NewDocumentWithImplementation(
+ StudioMemberImplementationKind implementationKind,
+ StudioMemberImplementationRef implementationRef)
+ {
+ var doc = NewDocument(ScopeId, "m-1", implementationKind);
+ // Reset implementation_ref fields and apply the supplied one.
+ doc.ImplementationWorkflowId = string.Empty;
+ doc.ImplementationWorkflowRevision = string.Empty;
+ doc.ImplementationScriptId = string.Empty;
+ doc.ImplementationScriptRevision = string.Empty;
+ doc.ImplementationActorTypeName = string.Empty;
+ if (implementationRef.Workflow != null)
+ {
+ doc.ImplementationWorkflowId = implementationRef.Workflow.WorkflowId;
+ doc.ImplementationWorkflowRevision = implementationRef.Workflow.WorkflowRevision;
+ }
+ if (implementationRef.Script != null)
+ {
+ doc.ImplementationScriptId = implementationRef.Script.ScriptId;
+ doc.ImplementationScriptRevision = implementationRef.Script.ScriptRevision;
+ }
+ if (implementationRef.Gagent != null)
+ {
+ doc.ImplementationActorTypeName = implementationRef.Gagent.ActorTypeName;
+ }
+ return doc;
+ }
+
+ private static string ToWireKind(StudioMemberImplementationKind kind) => kind switch
+ {
+ StudioMemberImplementationKind.Workflow => MemberImplementationKindNames.Workflow,
+ StudioMemberImplementationKind.Script => MemberImplementationKindNames.Script,
+ StudioMemberImplementationKind.Gagent => MemberImplementationKindNames.GAgent,
+ _ => string.Empty,
+ };
+
+ private static string ToWireStage(StudioMemberLifecycleStage stage) => stage switch
+ {
+ StudioMemberLifecycleStage.Created => MemberLifecycleStageNames.Created,
+ StudioMemberLifecycleStage.BuildReady => MemberLifecycleStageNames.BuildReady,
+ StudioMemberLifecycleStage.BindReady => MemberLifecycleStageNames.BindReady,
+ _ => string.Empty,
+ };
+
+ private sealed class StubDocumentReader
+ : IProjectionDocumentReader
+ {
+ private readonly Dictionary _byId;
+
+ public StubDocumentReader(IReadOnlyList documents)
+ {
+ _byId = documents.ToDictionary(d => d.Id, StringComparer.Ordinal);
+ }
+
+ public Task GetAsync(
+ string key, CancellationToken ct = default)
+ {
+ return Task.FromResult(_byId.TryGetValue(key, out var doc) ? doc : null);
+ }
+
+ public Task> QueryAsync(
+ ProjectionDocumentQuery query, CancellationToken ct = default)
+ {
+ // Honor the scope_id filter the query port issues.
+ var scopeFilter = query.Filters.FirstOrDefault(
+ f => string.Equals(f.FieldPath, "scope_id", StringComparison.Ordinal));
+
+ IEnumerable items = _byId.Values;
+ if (scopeFilter != null && scopeFilter.Value.RawValue is string scope)
+ {
+ items = items.Where(d => string.Equals(d.ScopeId, scope, StringComparison.Ordinal));
+ }
+
+ return Task.FromResult(new ProjectionDocumentQueryResult
+ {
+ Items = items.Take(query.Take).ToList(),
+ });
+ }
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberReadModelMetadataTests.cs b/test/Aevatar.Studio.Tests/StudioMemberReadModelMetadataTests.cs
new file mode 100644
index 000000000..c84d34ed2
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberReadModelMetadataTests.cs
@@ -0,0 +1,55 @@
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+using Aevatar.Studio.Projection.Metadata;
+using Aevatar.Studio.Projection.ReadModels;
+using FluentAssertions;
+using Google.Protobuf.WellKnownTypes;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Surface tests for the StudioMember projection metadata + read-model
+/// adapter. These are intentionally narrow — they only ensure the
+/// configured index name stays under studio-members and that the
+/// read-model's IProjectionReadModel surface returns the values the
+/// projector wrote.
+///
+public sealed class StudioMemberReadModelMetadataTests
+{
+ [Fact]
+ public void MetadataProvider_ShouldExposeStudioMembersIndex()
+ {
+ var provider = new StudioMemberCurrentStateDocumentMetadataProvider();
+ provider.Metadata.IndexName.Should().Be("studio-members");
+ provider.Metadata.Mappings.Should().ContainKey("dynamic");
+ }
+
+ [Fact]
+ public void ReadModel_ShouldSurfaceProjectionContractFields()
+ {
+ var updatedAt = DateTimeOffset.Parse("2026-04-27T01:02:03Z");
+ var doc = new StudioMemberCurrentStateDocument
+ {
+ Id = "studio-member:scope-1:m-1",
+ ActorId = "studio-member:scope-1:m-1",
+ StateVersion = 7,
+ LastEventId = "evt-7",
+ UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt),
+ };
+
+ IProjectionReadModel readModel = doc;
+ readModel.ActorId.Should().Be(doc.ActorId);
+ readModel.StateVersion.Should().Be(doc.StateVersion);
+ readModel.LastEventId.Should().Be(doc.LastEventId);
+ readModel.UpdatedAt.Should().Be(updatedAt);
+ }
+
+ [Fact]
+ public void ReadModel_ShouldReturnMinValueWhenUpdatedAtIsNull()
+ {
+ IProjectionReadModel readModel = new StudioMemberCurrentStateDocument
+ {
+ Id = "studio-member:scope-1:m-1",
+ };
+ readModel.UpdatedAt.Should().Be(DateTimeOffset.MinValue);
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs b/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs
new file mode 100644
index 000000000..8e79ac78e
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs
@@ -0,0 +1,347 @@
+using Aevatar.GAgentService.Abstractions;
+using Aevatar.GAgentService.Abstractions.Ports;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Application.Studio.Contracts;
+using Aevatar.Studio.Application.Studio.Services;
+using FluentAssertions;
+
+namespace Aevatar.Studio.Tests;
+
+///
+/// Locks in the most important invariants from issue #325:
+///
+/// - Member binding never falls back to the scope default service.
+/// - The ServiceId Studio sends to the underlying scope binding command is
+/// the member's stable publishedServiceId, sourced from the authority
+/// state — not derived from a frontend-supplied value.
+/// - Renaming a member does not change publishedServiceId.
+/// - workflow / script / gagent each route through the same orchestration.
+/// - The resulting revision is recorded back on the member authority.
+///
+public sealed class StudioMemberServiceBindingTests
+{
+ private const string ScopeId = "scope-1";
+ private const string MemberId = "m-bind-test";
+ private const string PublishedServiceId = "member-m-bind-test";
+
+ [Fact]
+ public async Task BindAsync_Workflow_ShouldUseMemberPublishedServiceId()
+ {
+ var detail = NewDetail(MemberImplementationKindNames.Workflow);
+ var queryPort = new InMemoryQueryPort(detail);
+ var commandPort = new RecordingCommandPort();
+ var bindingPort = new RecordingScopeBindingPort();
+
+ var service = new StudioMemberService(commandPort, queryPort, bindingPort);
+
+ var response = await service.BindAsync(
+ ScopeId,
+ MemberId,
+ new UpdateStudioMemberBindingRequest(
+ Workflow: new StudioMemberWorkflowBindingSpec(["workflow:\n name: x"])),
+ CancellationToken.None);
+
+ // The bind orchestration MUST hand the platform binding port the
+ // member's stable publishedServiceId — never null/empty (which would
+ // fall back to the scope default service).
+ bindingPort.LastRequest.Should().NotBeNull();
+ bindingPort.LastRequest!.ServiceId.Should().Be(PublishedServiceId);
+ bindingPort.LastRequest.ImplementationKind.Should().Be(ScopeBindingImplementationKind.Workflow);
+ bindingPort.LastRequest.Workflow!.WorkflowYamls.Should().ContainSingle();
+
+ // Lifecycle fix: BindAsync must persist the resolved impl_ref on the
+ // member (UpdateImplementationAsync) BEFORE recording the binding
+ // (RecordBindingAsync), so the actor walks Created → BuildReady →
+ // BindReady on every bind. Both must run, and impl_update must
+ // happen first.
+ commandPort.OperationsInOrder.Should().Equal(
+ "UpdateImplementation", "RecordBinding");
+ commandPort.RecordedImplementationUpdates.Should().ContainSingle()
+ .Which.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow);
+
+ // The orchestrator records the resulting revision back on the
+ // member authority so /members/.../binding can read it from the
+ // read model later.
+ commandPort.RecordedBindings.Should().ContainSingle()
+ .Which.PublishedServiceId.Should().Be(PublishedServiceId);
+ response.PublishedServiceId.Should().Be(PublishedServiceId);
+ response.RevisionId.Should().Be(bindingPort.IssuedRevisionId);
+ response.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow);
+ }
+
+ [Fact]
+ public async Task BindAsync_Script_ShouldRouteThroughScriptingKind()
+ {
+ var detail = NewDetail(MemberImplementationKindNames.Script);
+ var queryPort = new InMemoryQueryPort(detail);
+ var commandPort = new RecordingCommandPort();
+ var bindingPort = new RecordingScopeBindingPort();
+
+ var service = new StudioMemberService(commandPort, queryPort, bindingPort);
+
+ await service.BindAsync(
+ ScopeId,
+ MemberId,
+ new UpdateStudioMemberBindingRequest(
+ Script: new StudioMemberScriptBindingSpec(ScriptId: "s-1", ScriptRevision: "v3")),
+ CancellationToken.None);
+
+ bindingPort.LastRequest!.ServiceId.Should().Be(PublishedServiceId);
+ bindingPort.LastRequest.ImplementationKind.Should().Be(ScopeBindingImplementationKind.Scripting);
+ bindingPort.LastRequest.Script!.ScriptId.Should().Be("s-1");
+ bindingPort.LastRequest.Script.ScriptRevision.Should().Be("v3");
+
+ commandPort.OperationsInOrder.Should().Equal(
+ "UpdateImplementation", "RecordBinding");
+ commandPort.RecordedImplementationUpdates.Should().ContainSingle()
+ .Which.ScriptId.Should().Be("s-1");
+ }
+
+ [Fact]
+ public async Task BindAsync_GAgent_ShouldRouteThroughGAgentKind()
+ {
+ var detail = NewDetail(MemberImplementationKindNames.GAgent);
+ var queryPort = new InMemoryQueryPort(detail);
+ var commandPort = new RecordingCommandPort();
+ var bindingPort = new RecordingScopeBindingPort();
+
+ var service = new StudioMemberService(commandPort, queryPort, bindingPort);
+
+ await service.BindAsync(
+ ScopeId,
+ MemberId,
+ new UpdateStudioMemberBindingRequest(
+ GAgent: new StudioMemberGAgentBindingSpec(
+ ActorTypeName: "MyActor",
+ Endpoints: [
+ new StudioMemberGAgentEndpointSpec(
+ EndpointId: "chat",
+ DisplayName: "Chat",
+ Kind: "chat",
+ RequestTypeUrl: "type.googleapis.com/x.Request",
+ ResponseTypeUrl: "type.googleapis.com/x.Response")
+ ])),
+ CancellationToken.None);
+
+ bindingPort.LastRequest!.ServiceId.Should().Be(PublishedServiceId);
+ bindingPort.LastRequest.ImplementationKind.Should().Be(ScopeBindingImplementationKind.GAgent);
+ bindingPort.LastRequest.GAgent!.ActorTypeName.Should().Be("MyActor");
+ bindingPort.LastRequest.GAgent.Endpoints.Should().ContainSingle();
+ bindingPort.LastRequest.GAgent.Endpoints[0].Kind.Should().Be(ServiceEndpointKind.Chat);
+
+ commandPort.OperationsInOrder.Should().Equal(
+ "UpdateImplementation", "RecordBinding");
+ commandPort.RecordedImplementationUpdates.Should().ContainSingle()
+ .Which.ActorTypeName.Should().Be("MyActor");
+ }
+
+ [Fact]
+ public async Task BindAsync_ShouldFail_WhenMemberDoesNotExist()
+ {
+ var queryPort = new InMemoryQueryPort(detail: null);
+ var service = new StudioMemberService(
+ new RecordingCommandPort(),
+ queryPort,
+ new RecordingScopeBindingPort());
+
+ var act = () => service.BindAsync(
+ ScopeId,
+ MemberId,
+ new UpdateStudioMemberBindingRequest(
+ Workflow: new StudioMemberWorkflowBindingSpec(["workflow:"])),
+ CancellationToken.None);
+
+ // Assert the typed exception so a regression that swaps it for a
+ // plain InvalidOperationException is caught — endpoints map the
+ // typed one to 404 and untyped IOEx to 400.
+ await act.Should().ThrowAsync()
+ .WithMessage("*not found in scope*");
+ }
+
+ [Fact]
+ public async Task BindAsync_ShouldFail_WhenWorkflowYamlsAreMissing()
+ {
+ var detail = NewDetail(MemberImplementationKindNames.Workflow);
+ var service = new StudioMemberService(
+ new RecordingCommandPort(),
+ new InMemoryQueryPort(detail),
+ new RecordingScopeBindingPort());
+
+ var act = () => service.BindAsync(
+ ScopeId,
+ MemberId,
+ new UpdateStudioMemberBindingRequest(),
+ CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*workflow yamls are required*");
+ }
+
+ [Fact]
+ public async Task GetBindingAsync_ShouldReturnLastRecordedBinding()
+ {
+ var detail = NewDetail(MemberImplementationKindNames.Workflow);
+ var withBinding = detail with
+ {
+ LastBinding = new StudioMemberBindingContractResponse(
+ PublishedServiceId: PublishedServiceId,
+ RevisionId: "rev-9",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ BoundAt: DateTimeOffset.UtcNow),
+ };
+
+ var service = new StudioMemberService(
+ new RecordingCommandPort(),
+ new InMemoryQueryPort(withBinding),
+ new RecordingScopeBindingPort());
+
+ var binding = await service.GetBindingAsync(ScopeId, MemberId);
+
+ binding.Should().NotBeNull();
+ binding!.PublishedServiceId.Should().Be(PublishedServiceId);
+ binding.RevisionId.Should().Be("rev-9");
+ }
+
+ private static StudioMemberDetailResponse NewDetail(string implementationKindWire)
+ {
+ var summary = new StudioMemberSummaryResponse(
+ MemberId: MemberId,
+ ScopeId: ScopeId,
+ DisplayName: "Test Member",
+ Description: string.Empty,
+ ImplementationKind: implementationKindWire,
+ LifecycleStage: MemberLifecycleStageNames.BuildReady,
+ PublishedServiceId: PublishedServiceId,
+ LastBoundRevisionId: null,
+ CreatedAt: DateTimeOffset.UtcNow.AddDays(-1),
+ UpdatedAt: DateTimeOffset.UtcNow.AddHours(-1));
+
+ return new StudioMemberDetailResponse(
+ Summary: summary,
+ ImplementationRef: null,
+ LastBinding: null);
+ }
+
+ private sealed class InMemoryQueryPort : IStudioMemberQueryPort
+ {
+ private readonly StudioMemberDetailResponse? _detail;
+
+ public InMemoryQueryPort(StudioMemberDetailResponse? detail)
+ {
+ _detail = detail;
+ }
+
+ public Task ListAsync(
+ string scopeId,
+ StudioMemberRosterPageRequest? page = null,
+ CancellationToken ct = default)
+ {
+ return Task.FromResult(new StudioMemberRosterResponse(
+ ScopeId: scopeId,
+ Members: _detail == null ? [] : [_detail.Summary]));
+ }
+
+ public Task GetAsync(
+ string scopeId, string memberId, CancellationToken ct = default)
+ {
+ return Task.FromResult(_detail);
+ }
+ }
+
+ private sealed class RecordingCommandPort : IStudioMemberCommandPort
+ {
+ public List RecordedBindings { get; } = [];
+
+ public List RecordedImplementationUpdates { get; } = [];
+
+ public List OperationsInOrder { get; } = [];
+
+ public Task CreateAsync(
+ string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default)
+ {
+ throw new NotImplementedException("Not exercised in this test.");
+ }
+
+ public Task UpdateImplementationAsync(
+ string scopeId,
+ string memberId,
+ StudioMemberImplementationRefResponse implementation,
+ CancellationToken ct = default)
+ {
+ RecordedImplementationUpdates.Add(implementation);
+ OperationsInOrder.Add("UpdateImplementation");
+ return Task.CompletedTask;
+ }
+
+ public Task RecordBindingAsync(
+ string scopeId,
+ string memberId,
+ string publishedServiceId,
+ string revisionId,
+ string implementationKindName,
+ CancellationToken ct = default)
+ {
+ RecordedBindings.Add(new RecordedBinding(
+ scopeId, memberId, publishedServiceId, revisionId, implementationKindName));
+ OperationsInOrder.Add("RecordBinding");
+ return Task.CompletedTask;
+ }
+
+ public sealed record RecordedBinding(
+ string ScopeId,
+ string MemberId,
+ string PublishedServiceId,
+ string RevisionId,
+ string ImplementationKindName);
+ }
+
+ private sealed class RecordingScopeBindingPort : IScopeBindingCommandPort
+ {
+ public string IssuedRevisionId { get; } = "rev-test";
+
+ public ScopeBindingUpsertRequest? LastRequest { get; private set; }
+
+ public Task UpsertAsync(
+ ScopeBindingUpsertRequest request, CancellationToken ct = default)
+ {
+ LastRequest = request;
+ // Mirror the production binding ports: populate the kind-specific
+ // result so BindAsync can derive the resolved implementation_ref
+ // and call UpdateImplementationAsync. Leaving these null skips
+ // the lifecycle wiring entirely and silently passes any test
+ // that doesn't assert on the call ordering.
+ ScopeBindingWorkflowResult? workflowResult = null;
+ ScopeBindingScriptResult? scriptResult = null;
+ ScopeBindingGAgentResult? gagentResult = null;
+ switch (request.ImplementationKind)
+ {
+ case ScopeBindingImplementationKind.Workflow:
+ workflowResult = new ScopeBindingWorkflowResult(
+ WorkflowName: $"wf-{request.ServiceId}",
+ DefinitionActorIdPrefix: $"def-{request.ServiceId}");
+ break;
+ case ScopeBindingImplementationKind.Scripting:
+ scriptResult = new ScopeBindingScriptResult(
+ ScriptId: request.Script?.ScriptId ?? string.Empty,
+ ScriptRevision: request.Script?.ScriptRevision ?? IssuedRevisionId,
+ DefinitionActorId: $"def-{request.ServiceId}");
+ break;
+ case ScopeBindingImplementationKind.GAgent:
+ gagentResult = new ScopeBindingGAgentResult(
+ ActorTypeName: request.GAgent?.ActorTypeName ?? string.Empty);
+ break;
+ }
+
+ return Task.FromResult(new ScopeBindingUpsertResult(
+ ScopeId: request.ScopeId,
+ ServiceId: request.ServiceId ?? string.Empty,
+ DisplayName: request.DisplayName ?? string.Empty,
+ RevisionId: IssuedRevisionId,
+ ImplementationKind: request.ImplementationKind,
+ ExpectedActorId: $"actor-{request.ServiceId}-{IssuedRevisionId}",
+ Workflow: workflowResult,
+ Script: scriptResult,
+ GAgent: gagentResult));
+ }
+ }
+}