From 9109f42f3464a9762f8d6c27771633ed04377ebf Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Tue, 19 May 2026 18:34:56 +0800 Subject: [PATCH 1/2] Make Studio team command ACK responses honest Return accepted receipts for team update/archive so write responses no longer imply readmodel freshness, and document the new contract for clients. Co-Authored-By: Claude Opus 4.6 --- .../src/pages/teams/detail.test.tsx | 30 +- .../src/shared/studio/api.test.ts | 40 +- .../src/shared/studio/api.ts | 43 +- .../src/shared/studio/models.ts | 8 + .../0024-studio-team-command-ack-semantics.md | 62 +++ ...ssue-547-studio-team-command-ack-design.md | 440 ++++++++++++++++++ .../Abstractions/IStudioTeamCommandPort.cs | 4 +- .../Studio/Abstractions/IStudioTeamService.cs | 9 +- .../Studio/Contracts/TeamContracts.cs | 12 + .../Studio/Services/StudioTeamService.cs | 10 +- .../Endpoints/StudioTeamEndpoints.cs | 10 +- .../ActorDispatchStudioTeamCommandService.cs | 40 +- ...orDispatchStudioTeamCommandServiceTests.cs | 32 +- .../StudioTeamEndpointTests.cs | 143 +++++- .../StudioTeamEndpointsRouteBindingTests.cs | 8 +- .../StudioTeamServiceTests.cs | 43 +- 16 files changed, 842 insertions(+), 92 deletions(-) create mode 100644 docs/adr/0024-studio-team-command-ack-semantics.md create mode 100644 docs/history/2026-05/2026-05-19-issue-547-studio-team-command-ack-design.md diff --git a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx index 985b88971..c57aac806 100644 --- a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx +++ b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx @@ -666,13 +666,18 @@ jest.mock("@/shared/studio/api", () => ({ listMembers: jest.fn(async () => mockCreateMembersCatalog()), getTeam: jest.fn(async () => mockCreateTeamSummary()), updateTeam: jest.fn(async () => ({ - ...mockCreateTeamSummary(), - displayName: "Alpha Ops Team", - description: "", + scopeId: "scope-1", + teamId: "t-alpha", + commandId: "cmd-update", + ackStage: "accepted", + acceptedAtUtc: "2026-05-01T08:06:00Z", })), archiveTeam: jest.fn(async () => ({ - ...mockCreateTeamSummary(), - lifecycleStage: "archived", + scopeId: "scope-1", + teamId: "t-alpha", + commandId: "cmd-archive", + ackStage: "accepted", + acceptedAtUtc: "2026-05-01T08:07:00Z", })), listTeamMembers: jest.fn(async () => mockCreateTeamMembersCatalog()), parseYaml: jest.fn(async () => ({ @@ -737,14 +742,19 @@ describe("TeamDetailPage", () => { ); (studioApi.updateTeam as jest.Mock).mockReset(); (studioApi.updateTeam as jest.Mock).mockImplementation(async () => ({ - ...mockCreateTeamSummary(), - displayName: "Alpha Ops Team", - description: "", + scopeId: "scope-1", + teamId: "t-alpha", + commandId: "cmd-update", + ackStage: "accepted", + acceptedAtUtc: "2026-05-01T08:06:00Z", })); (studioApi.archiveTeam as jest.Mock).mockReset(); (studioApi.archiveTeam as jest.Mock).mockImplementation(async () => ({ - ...mockCreateTeamSummary(), - lifecycleStage: "archived", + scopeId: "scope-1", + teamId: "t-alpha", + commandId: "cmd-archive", + ackStage: "accepted", + acceptedAtUtc: "2026-05-01T08:07:00Z", })); (studioApi.listTeamMembers as jest.Mock).mockReset(); (studioApi.listTeamMembers as jest.Mock).mockImplementation( diff --git a/apps/aevatar-console-web/src/shared/studio/api.test.ts b/apps/aevatar-console-web/src/shared/studio/api.test.ts index 90bebef09..ccfb4b176 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.test.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.test.ts @@ -1226,6 +1226,20 @@ describe('studioApi host-session requests', () => { createdAt: '2026-05-01T08:00:00Z', updatedAt: '2026-05-01T08:05:00Z', }; + const updateAccepted = { + scopeId: 'scope-1', + teamId: 't-alpha', + commandId: 'cmd-update', + ackStage: 'accepted', + acceptedAtUtc: '2026-05-01T08:06:00Z', + }; + const archiveAccepted = { + scopeId: 'scope-1', + teamId: 't-alpha', + commandId: 'cmd-archive', + ackStage: 'accepted', + acceptedAtUtc: '2026-05-01T08:07:00Z', + }; const fetchMock = jest .fn() .mockResolvedValueOnce({ @@ -1235,20 +1249,13 @@ describe('studioApi host-session requests', () => { } as Response) .mockResolvedValueOnce({ ok: true, - status: 200, - json: async () => ({ - ...teamResponse, - displayName: 'Alpha Ops', - description: '', - }), + status: 202, + json: async () => updateAccepted, } as Response) .mockResolvedValueOnce({ ok: true, - status: 200, - json: async () => ({ - ...teamResponse, - lifecycleStage: 'archived', - }), + status: 202, + json: async () => archiveAccepted, } as Response) .mockResolvedValueOnce({ ok: true, @@ -1290,15 +1297,8 @@ describe('studioApi host-session requests', () => { displayName: 'Alpha Ops', description: null, }), - ).resolves.toEqual({ - ...teamResponse, - displayName: 'Alpha Ops', - description: '', - }); - await expect(studioApi.archiveTeam('scope-1', 't-alpha')).resolves.toEqual({ - ...teamResponse, - lifecycleStage: 'archived', - }); + ).resolves.toEqual(updateAccepted); + await expect(studioApi.archiveTeam('scope-1', 't-alpha')).resolves.toEqual(archiveAccepted); await expect(studioApi.listTeamMembers('scope-1', 't-alpha')).resolves.toEqual({ scopeId: 'scope-1', members: [ diff --git a/apps/aevatar-console-web/src/shared/studio/api.ts b/apps/aevatar-console-web/src/shared/studio/api.ts index 8aafbaf3b..6965119b7 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.ts @@ -43,6 +43,7 @@ import type { StudioSerializeYamlResult, StudioSettings, StudioStartExecutionInput, + StudioTeamCommandAcceptedResponse, StudioTeamCreateInput, StudioTeamLifecycleStage, StudioTeamRoster, @@ -1097,6 +1098,40 @@ function decodeStudioTeamSummary(value: unknown): StudioTeamSummary { }; } +function decodeStudioTeamCommandAcceptedResponse( + value: unknown +): StudioTeamCommandAcceptedResponse { + const record = expectRecord(value, "StudioTeamCommandAcceptedResponse"); + return { + scopeId: readString( + record, + ["scopeId", "ScopeId"], + "StudioTeamCommandAcceptedResponse.scopeId" + ), + teamId: readString( + record, + ["teamId", "TeamId"], + "StudioTeamCommandAcceptedResponse.teamId" + ), + commandId: + readNullableString( + record, + ["commandId", "CommandId"], + "StudioTeamCommandAcceptedResponse.commandId" + ) ?? null, + ackStage: readString( + record, + ["ackStage", "AckStage"], + "StudioTeamCommandAcceptedResponse.ackStage" + ), + acceptedAtUtc: readString( + record, + ["acceptedAtUtc", "AcceptedAtUtc"], + "StudioTeamCommandAcceptedResponse.acceptedAtUtc" + ), + }; +} + function decodeStudioTeamRoster(value: unknown): StudioTeamRoster { const record = expectRecord(value, "StudioTeamRoster"); return { @@ -1431,10 +1466,10 @@ export const studioApi = { ); }, - updateTeam(input: StudioTeamUpdateInput): Promise { + updateTeam(input: StudioTeamUpdateInput): Promise { return requestDecodedJson( `/api/scopes/${encodeURIComponent(input.scopeId.trim())}/teams/${encodeURIComponent(input.teamId.trim())}`, - decodeStudioTeamSummary, + decodeStudioTeamCommandAcceptedResponse, { method: "PATCH", headers: JSON_HEADERS, @@ -1450,10 +1485,10 @@ export const studioApi = { ); }, - archiveTeam(scopeId: string, teamId: string): Promise { + archiveTeam(scopeId: string, teamId: string): Promise { return requestDecodedJson( `/api/scopes/${encodeURIComponent(scopeId.trim())}/teams/${encodeURIComponent(teamId.trim())}/archive`, - decodeStudioTeamSummary, + decodeStudioTeamCommandAcceptedResponse, { method: "POST", headers: JSON_HEADERS, diff --git a/apps/aevatar-console-web/src/shared/studio/models.ts b/apps/aevatar-console-web/src/shared/studio/models.ts index 3608b5af9..341269b27 100644 --- a/apps/aevatar-console-web/src/shared/studio/models.ts +++ b/apps/aevatar-console-web/src/shared/studio/models.ts @@ -552,6 +552,14 @@ export interface StudioTeamSummary { readonly updatedAt: string; } +export interface StudioTeamCommandAcceptedResponse { + readonly scopeId: string; + readonly teamId: string; + readonly commandId?: string | null; + readonly ackStage: string; + readonly acceptedAtUtc: string; +} + export interface StudioTeamRoster { readonly scopeId: string; readonly teams: readonly StudioTeamSummary[]; diff --git a/docs/adr/0024-studio-team-command-ack-semantics.md b/docs/adr/0024-studio-team-command-ack-semantics.md new file mode 100644 index 000000000..9e7c015d5 --- /dev/null +++ b/docs/adr/0024-studio-team-command-ack-semantics.md @@ -0,0 +1,62 @@ +--- +title: 0024 — Studio team command ACK semantics +status: accepted +owner: liyingpei +--- + +# 0024 — Studio team command ACK semantics + +## Status + +Accepted. Supersedes ADR [0017](0017-studio-team-first-class-aggregate.md) for the synchronous success response of Studio team update/archive commands. + +## Context + +ADR-0017 defines Studio team as a first-class aggregate and lists the HTTP surfaces: + +- `PATCH /api/scopes/{scopeId}/teams/{teamId}` +- `POST /api/scopes/{scopeId}/teams/{teamId}/archive` + +The previous application flow dispatched a team command and immediately reread the eventually consistent team readmodel, returning `200 OK + StudioTeamSummaryResponse`. That mixed three different stages: + +1. command intent accepted/dispatched, +2. actor-authoritative state transition, +3. readmodel materialization. + +A successful dispatch does not guarantee that the readmodel has observed the transition. Returning a team summary from the write response could therefore expose stale post-state or map readmodel lag to a false not-found. + +## Decision + +Studio team update/archive endpoints return an honest command ACK receipt instead of a post-state summary: + +| Endpoint | Synchronous success response | +|---|---| +| `PATCH /api/scopes/{scopeId}/teams/{teamId}` | `202 Accepted + StudioTeamCommandAcceptedResponse` | +| `POST /api/scopes/{scopeId}/teams/{teamId}/archive` | `202 Accepted + StudioTeamCommandAcceptedResponse` | + +`StudioTeamCommandAcceptedResponse` contains: + +- `scopeId` — normalized scope id for the command target. +- `teamId` — normalized team id for the command target. +- `commandId` — the dispatched `EventEnvelope.Id` for state-changing command paths; `null` for no-op PATCH that dispatches no envelope. +- `ackStage` — currently only `accepted`. This is an ACK-stage literal, not a command lifecycle/status resource. +- `acceptedAtUtc` — acceptance/envelope creation timestamp. It does not imply actor commit or readmodel materialization. + +The HTTP `Location` header points to the existing team readmodel query URI: + +```text +/api/scopes/{escaped scopeId}/teams/{escaped teamId} +``` + +That URI remains an eventually consistent readmodel read. Clients that need post-state must explicitly issue the GET and must treat it as readmodel-current, not as a commit/readiness guarantee. + +The write response does not include readmodel post-state fields such as `displayName`, `description`, `lifecycleStage`, `memberCount`, `createdAt`, or `updatedAt`. + +No command status endpoint, readiness endpoint, bounded polling, actor reply/result protocol, or generic async operation framework is introduced by this decision. + +## Consequences + +- PATCH/archive no longer use stale or missing readmodels to determine write success. +- API clients must migrate from `200 OK + StudioTeamSummaryResponse` to `202 Accepted + StudioTeamCommandAcceptedResponse` for team update/archive. +- Synchronous not-found for update/archive is only valid if it comes from an authoritative command path. It must not be inferred from post-dispatch readmodel lag. +- `GET /api/scopes/{scopeId}/teams/{teamId}` remains the explicit readmodel query for materialized team state. diff --git a/docs/history/2026-05/2026-05-19-issue-547-studio-team-command-ack-design.md b/docs/history/2026-05/2026-05-19-issue-547-studio-team-command-ack-design.md new file mode 100644 index 000000000..37ca0c336 --- /dev/null +++ b/docs/history/2026-05/2026-05-19-issue-547-studio-team-command-ack-design.md @@ -0,0 +1,440 @@ +--- +title: "Issue 547:Studio team command ACK 语义诚实化设计" +status: design +owner: liyingpei +--- + +# Issue 547:Studio team command ACK 语义诚实化设计 + +> 本文面向 [Issue 547](https://github.com/aevatarAI/aevatar/issues/547):`StudioTeamService.UpdateAsync` / `ArchiveAsync` 在 dispatch command 后立即读取 eventually consistent team readmodel,并把读取结果作为 HTTP 返回。这会让 endpoint 隐含“command 已提交且 readmodel 已观察”的强语义,但当前实现只能保证 command dispatch/accepted。 + +## 1. 背景 + +当前 Studio team HTTP surface: + +- `PATCH /api/scopes/{scopeId}/teams/{teamId}` +- `POST /api/scopes/{scopeId}/teams/{teamId}/archive` + +在 application service 中走: + +```csharp +await _commandPort.UpdateAsync(scopeId, teamId, request, ct); +return await GetAsync(scopeId, teamId, ct); +``` + +```csharp +await _commandPort.ArchiveAsync(scopeId, teamId, ct); +return await GetAsync(scopeId, teamId, ct); +``` + +其中: + +- `IStudioTeamCommandPort` 只负责向 `StudioTeamGAgent` dispatch command/event。 +- `GetAsync` 读取 projection readmodel。 +- projection 是 eventually consistent,dispatch 成功不等价于 readmodel 已 materialized。 + +这导致 endpoint 返回 `200 OK + StudioTeamSummaryResponse` 时,调用方可能以为返回的是 post-state snapshot,但实际可能是旧 readmodel,甚至可能因为 readmodel 尚未追上而误报 not found。 + +## 2. 问题判断 + +当前代码混合了三个阶段: + +| 阶段 | 当前承载位置 | 问题 | +|---|---|---| +| command accepted / dispatched | `IStudioTeamCommandPort.UpdateAsync/ArchiveAsync` | 合理 | +| actor authoritative transition | `StudioTeamGAgent` | endpoint 当前没有同步观察这个阶段 | +| readmodel materialized | dispatch 后立即 `GetAsync` | 不应由 weak ACK 后的即时查询暗示强一致 | + +对应 `CLAUDE.md` 约束: + +- ACK 诚实:同步返回只承诺已达到阶段;`committed` / `read-model observed` 等强保证须通过独立契约或异步观察获取。 +- 查询诚实:readmodel 可最终一致,但不能在弱读结果上暗示强一致。 +- Command / Query 分离:write side 不应通过 projection freshness 塑造 command ACK 语义。 + +## 3. 目标 + +本设计目标: + +1. `UpdateAsync` / `ArchiveAsync` dispatch 后不再立即读取 team readmodel。 +2. PATCH / archive endpoint 返回诚实的 accepted receipt,只表达 command intent 已 accepted;state-changing path 才额外表达 envelope 已 dispatched。 +3. stale / missing readmodel 不会让成功 dispatch 被误报为 404 或返回旧 post-state。 +4. 保持 `GET /teams/{teamId}` 作为读取 materialized team readmodel 的入口。 +5. 不引入 bounded polling、observed-version readiness 或 generic async operation framework。 + +非目标: + +- 不重做 StudioTeam actor 协议。 +- 不新增 public readiness endpoint。 +- 不引入 team command run / async operation resource。 +- 不解决 team roster fanout reliable outbox(#544)。 +- 不解决 member patch intent 下沉(#545)。 +- 不改变 create path,除非 implementation review 发现必须跟随接口收敛;#547 最小范围只覆盖 update/archive。 + +## 4. 设计原则 + +### 4.1 Command ACK 必须诚实 + +PATCH / archive 的同步响应只能承诺 command intent 已通过当前 command port accepted;只有实际 dispatch 了 envelope 的 path 才能承诺 dispatched。它不能承诺 actor state transition 已完成或 readmodel 已可见。 + +### 4.2 Post-state 读取走显式 query + +调用方需要最新 team state 时,应继续使用: + +```text +GET /api/scopes/{scopeId}/teams/{teamId} +``` + +该 query 返回的是 readmodel 当前可见状态,而不是 command response 的一部分。 + +### 4.3 不用 readmodel 判定 write success + +dispatch 后立即读取 projection 并把 missing 映射为 404,是把 readmodel freshness 当成 write result。#547 移除这个模式。 + +### 4.4 不扩大为通用 async operation framework + +#547 只把 Studio team update/archive 的 ACK 改诚实;不引入 operation status endpoint、run actor 或全局 observed-version contract。 + +## 5. 新增 response contract + +建议在: + +```text +src/Aevatar.Studio.Application/Studio/Contracts/TeamContracts.cs +``` + +新增: + +```csharp +public static class StudioTeamCommandAckStageNames +{ + public const string Accepted = "accepted"; +} + +public sealed record StudioTeamCommandAcceptedResponse( + string ScopeId, + string TeamId, + string? CommandId, + string AckStage, + DateTimeOffset AcceptedAtUtc); +``` + +说明: + +- `CommandId` 对 dispatch path 使用 `EventEnvelope.Id`,便于日志/追踪;对 no-op patch 为 `null`,避免伪造不存在的 envelope id。 +- `AcceptedAtUtc` 使用 acceptance / envelope creation timestamp;它只表示当前 application/adapter accepted intent 的时间,不表示 actor 已 commit,也不表示 readmodel 已 materialized。 +- `AckStage` 是同步 ACK 阶段字面量,当前只允许 `accepted`;它不是 operation lifecycle/status,不承诺未来会出现 `committed` / `failed` / `observed` / terminal 状态。 +- response 不包含 `DisplayName` / `Description` / `LifecycleStage` / `MemberCount`,因为这些是 readmodel post-state。 + +## 6. 修改 application / command port contract + +当前: + +```csharp +Task UpdateAsync(string scopeId, string teamId, UpdateStudioTeamRequest request, CancellationToken ct = default); +Task ArchiveAsync(string scopeId, string teamId, CancellationToken ct = default); +``` + +改为: + +```csharp +Task UpdateAsync( + string scopeId, + string teamId, + UpdateStudioTeamRequest request, + CancellationToken ct = default); + +Task ArchiveAsync( + string scopeId, + string teamId, + CancellationToken ct = default); +``` + +`IStudioTeamService` 同步改为返回 `StudioTeamCommandAcceptedResponse`。 + +`StudioTeamService.UpdateAsync`: + +```csharp +ValidatePatch(request); +return await _commandPort.UpdateAsync(scopeId, teamId, request, ct); +``` + +`StudioTeamService.ArchiveAsync`: + +```csharp +return await _commandPort.ArchiveAsync(scopeId, teamId, ct); +``` + +删除 dispatch 后 `GetAsync`。 + +## 7. 修改 ActorDispatchStudioTeamCommandService + +`UpdateAsync` / `ArchiveAsync` dispatch 后返回 accepted receipt。 + +建议把 `DispatchAsync` helper 改为返回 receipt: + +```csharp +private async Task DispatchAsync( + string scopeId, + string teamId, + IMessage payload, + CancellationToken ct) +{ + var actorId = StudioTeamConventions.BuildActorId(scopeId, teamId); + var actor = await _bootstrap.EnsureAsync(actorId, ct); + var commandId = Guid.NewGuid().ToString("N"); + var acceptedAtUtc = DateTimeOffset.UtcNow; + + var envelope = new EventEnvelope + { + Id = commandId, + Timestamp = Timestamp.FromDateTimeOffset(acceptedAtUtc), + Payload = Any.Pack(payload), + Route = EnvelopeRouteSemantics.CreateDirect(DirectRoute, actor.Id), + }; + + await _dispatchPort.DispatchAsync(actor.Id, envelope, ct); + + return new StudioTeamCommandAcceptedResponse( + scopeId, + teamId, + commandId, + StudioTeamCommandAckStageNames.Accepted, + acceptedAtUtc); +} +``` + +### 7.1 No-op patch + +当前 no-op patch 不 dispatch: + +```csharp +if (!request.DisplayName.HasValue && !request.Description.HasValue) + return; +``` + +改成仍返回 accepted receipt,但不 dispatch event,且 `CommandId = null`: + +```csharp +if (!request.DisplayName.HasValue && !request.Description.HasValue) + return BuildAcceptedResponse(normalizedScopeId, normalizedTeamId, commandId: null, acceptedAtUtc: DateTimeOffset.UtcNow); +``` + +这比伪造 command id 更诚实:同步 response 表示 PATCH intent 已被 accepted,且判定为无需产生 state-changing command;由于没有 envelope dispatch,就没有 dispatched `EventEnvelope.Id` 可返回。 + +不新增 `noop` ACK stage,避免扩大 wire state machine。含义是:patch intent 已被 accepted,且无需产生 state-changing event。 + +如果后续产品需要区分 no-op,可另开契约扩展;#547 最小范围不做。 + +## 8. 修改 endpoint + +当前 PATCH: + +```csharp +var detail = await teamService.UpdateAsync(...); +return Results.Ok(detail); +``` + +改为: + +```csharp +var accepted = await teamService.UpdateAsync(...); +var location = BuildTeamLocation(accepted.ScopeId, accepted.TeamId); +return Results.Accepted(location, accepted); +``` + +当前 archive: + +```csharp +return Results.Ok(await teamService.ArchiveAsync(scopeId, teamId, ct)); +``` + +改为: + +```csharp +var accepted = await teamService.ArchiveAsync(scopeId, teamId, ct); +var location = BuildTeamLocation(accepted.ScopeId, accepted.TeamId); +return Results.Accepted(location, accepted); +``` + +`BuildTeamLocation` 必须使用 normalized receipt values,并对 path segment 做 URI escaping: + +```csharp +private static string BuildTeamLocation(string scopeId, string teamId) => + $"/api/scopes/{Uri.EscapeDataString(scopeId)}/teams/{Uri.EscapeDataString(teamId)}"; +``` + +`Location` 指向已有 `GET /api/scopes/{scopeId}/teams/{teamId}` readmodel query URI,不是新的 command status/readiness resource。 + +保留 exception mapping: + +- `InvalidOperationException` -> 400 +- `StudioTeamNotFoundException` -> 404 + +但 implementation 不再通过 post-dispatch readmodel lookup 产生 not-found。若未来 actor-owned command contract 能同步返回 authoritative not-found,endpoint mapping 可继续复用。 + +## 9. API / client migration note + +#547 会改变 PATCH / archive 成功响应 wire contract: + +| Endpoint | 当前成功响应 | 新成功响应 | +|---|---|---| +| `PATCH /api/scopes/{scopeId}/teams/{teamId}` | `200 OK + StudioTeamSummaryResponse` | `202 Accepted + StudioTeamCommandAcceptedResponse` | +| `POST /api/scopes/{scopeId}/teams/{teamId}/archive` | `200 OK + StudioTeamSummaryResponse` | `202 Accepted + StudioTeamCommandAcceptedResponse` | + +调用方不能再从 write response 读取 post-state 字段: + +- `DisplayName` +- `Description` +- `LifecycleStage` +- `MemberCount` +- `CreatedAt` +- `UpdatedAt` + +需要 post-state 时,调用方必须显式读取 `Location` 指向的 team GET URI。该 GET 仍是 eventually consistent readmodel query,不能把 write response 的 `202 Accepted` 理解为 readmodel 已更新。 + +## 10. Not-found 语义 + +#547 最小实现后: + +- PATCH / archive 不再用 stale projection 判断 not found。 +- 对不存在 team 的 command 是否最终 rejected,由 `StudioTeamGAgent` 的 authoritative state/command handling 决定。 +- endpoint 的 202 response 只表示 command intent accepted;state-changing path 也表示 command envelope 已 dispatched;不表示 team 一定存在或 update/archive 已 materialized。 + +如果产品要求 PATCH / archive 对不存在 team 同步返回 404,需要引入 actor-owned reply/continuation 或 observed contract。那是更强 command result 语义,不属于 #547 最小范围。 + +## 11. 测试计划 + +### 11.1 StudioTeamServiceTests + +修改: + +```text +test/Aevatar.Studio.Tests/StudioTeamServiceTests.cs +``` + +覆盖: + +1. `UpdateAsync` validates patch and delegates command port。 +2. `UpdateAsync` returns accepted receipt without calling query port。 +3. `ArchiveAsync` returns accepted receipt without calling query port。 +4. stale/missing query fake should not affect update/archive result。 + +现有: + +```text +UpdateAsync_ShouldDelegateAndReRead +ArchiveAsync_ShouldDelegateAndReRead +``` + +改名为: + +```text +UpdateAsync_ShouldReturnAcceptedReceiptWithoutReReading +ArchiveAsync_ShouldReturnAcceptedReceiptWithoutReReading +``` + +### 11.2 StudioTeamEndpointTests + +修改: + +```text +test/Aevatar.Studio.Tests/StudioTeamEndpointTests.cs +``` + +覆盖: + +1. PATCH success -> `202 Accepted`。 +2. archive success -> `202 Accepted`。 +3. response body contains `scopeId` / `teamId` / `ackStage == accepted` / `acceptedAtUtc`。 +4. dispatch path response body contains non-empty `commandId`;no-op path response body has `commandId == null`。 +5. response body does not contain post-state fields (`displayName`, `description`, `lifecycleStage`, `memberCount`, `createdAt`, `updatedAt`)。 +6. `Location` is built from normalized receipt values and URI-escaped path segments。 +7. validation failures still -> 400。 +8. existing fake service throwing `StudioTeamNotFoundException` can still map -> 404, but this no longer represents post-dispatch readmodel missing in production implementation。 + +### 11.3 ActorDispatchStudioTeamCommandServiceTests + +修改: + +```text +test/Aevatar.Studio.Tests/ActorDispatchStudioTeamCommandServiceTests.cs +``` + +覆盖: + +1. update dispatch returns accepted receipt matching envelope id。 +2. archive dispatch returns accepted receipt matching envelope id。 +3. dispatched receipt `AcceptedAtUtc` equals envelope timestamp。 +4. no-op patch returns accepted receipt with `CommandId == null` and dispatches no event。 +5. no-op receipt `AckStage == accepted` does not imply actor commit or readmodel observation。 + +### 11.4 Route binding tests + +Run existing route binding tests to ensure endpoint signatures still bind correctly: + +```text +test/Aevatar.Studio.Tests/StudioTeamEndpointsRouteBindingTests.cs +``` + +## 12. 验证命令 + +```bash +dotnet test test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj --no-restore --nologo --filter "StudioTeamServiceTests|StudioTeamEndpointTests|ActorDispatchStudioTeamCommandServiceTests|StudioTeamEndpointsRouteBindingTests" +``` + +```bash +bash tools/ci/query_projection_priming_guard.sh +``` + +```bash +bash tools/ci/test_stability_guards.sh +``` + +```bash +git diff --check +``` + +如果测试改动涉及 wait/delay,不应使用 `Task.Delay` 做节奏;本设计不需要 delay。 + +## 13. 迁移步骤 + +1. 在 `TeamContracts.cs` 新增 accepted receipt model。 +2. 修改 `IStudioTeamCommandPort` update/archive 返回 receipt。 +3. 修改 `ActorDispatchStudioTeamCommandService` dispatch helper 返回 receipt。 +4. 修改 `IStudioTeamService` update/archive 返回 receipt。 +5. 修改 `StudioTeamService` 移除 dispatch 后 `GetAsync`。 +6. 修改 `StudioTeamEndpoints` PATCH/archive 返回 202 accepted。 +7. 更新相关 tests。 +8. 跑 Studio team tests、projection priming guard、test stability guard 与 `git diff --check`。 + +## 14. Open questions + +### 14.1 Create 是否也应返回 accepted receipt? + +暂不改。 + +理由:#547 明确点名 update/archive dispatch 后读 readmodel 的问题;create 当前 command service 自己生成 team id 并返回基于 command input 构造的 summary,不依赖 readmodel post-read。它仍可能暗示 actor commit,但不是 #547 的主要 stale readmodel 问题。为控制范围,create 保持不变。 + +若后续要统一 team command ACK,可另开 issue 或扩展 #547 scope。 + +### 14.2 PATCH / archive 对不存在 team 是否还应同步 404? + +最小实现不保证。 + +同步 404 必须来自 authoritative actor result 或明确 observed contract,不能来自 dispatch 后 readmodel lookup。#547 先移除不诚实 404;如果产品需要强 result,可设计 actor-owned command result continuation。 + +### 14.3 是否需要 command status endpoint? + +不需要。 + +#547 只要求 ACK 诚实,不要求用户可观察每个 team command 的 terminal state。引入 status endpoint 会扩大到 async operation framework。 + +## 15. 完成标准 + +- `StudioTeamService.UpdateAsync` / `ArchiveAsync` 不再 dispatch 后调用 `GetAsync`。 +- PATCH / archive endpoint 返回 `202 Accepted + StudioTeamCommandAcceptedResponse`。 +- dispatch path response `CommandId` 匹配 envelope id;no-op patch response `CommandId == null`。 +- response 不包含 team readmodel post-state 字段。 +- stale/missing readmodel 不影响 successful dispatch ACK。 +- tests 覆盖 no post-dispatch readmodel read。 +- 不引入 polling、readiness endpoint 或 generic async operation framework。 diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamCommandPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamCommandPort.cs index 965766099..ec339fcc1 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamCommandPort.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamCommandPort.cs @@ -14,7 +14,7 @@ Task CreateAsync( CreateStudioTeamRequest request, CancellationToken ct = default); - Task UpdateAsync( + Task UpdateAsync( string scopeId, string teamId, UpdateStudioTeamRequest request, @@ -24,7 +24,7 @@ Task UpdateAsync( /// Archives the team. Archive is irreversible (ADR-0017 §Locked Rule 5). /// Idempotent on already-archived teams. /// - Task ArchiveAsync( + Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default); diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamService.cs index 4fe3b3653..d56f1dcb2 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamService.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioTeamService.cs @@ -25,21 +25,22 @@ Task GetAsync( string teamId, CancellationToken ct = default); - Task UpdateAsync( + Task UpdateAsync( string scopeId, string teamId, UpdateStudioTeamRequest request, CancellationToken ct = default); - Task ArchiveAsync( + Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default); } /// -/// Thrown when a team lookup or update targets an id that has no read-model -/// document. Mirrors . +/// Thrown when a team readmodel lookup targets an id that has no materialized +/// document. Update/archive paths must only surface not-found from an +/// authoritative command contract, not from post-dispatch readmodel lag. /// public sealed class StudioTeamNotFoundException : Exception { diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/TeamContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/TeamContracts.cs index 424b2c5d7..1023285b7 100644 --- a/src/Aevatar.Studio.Application/Studio/Contracts/TeamContracts.cs +++ b/src/Aevatar.Studio.Application/Studio/Contracts/TeamContracts.cs @@ -12,6 +12,11 @@ public static class TeamLifecycleStageNames public const string Archived = "archived"; } +public static class StudioTeamCommandAckStageNames +{ + public const string Accepted = "accepted"; +} + public sealed record StudioTeamSummaryResponse( string TeamId, string ScopeId, @@ -22,6 +27,13 @@ public sealed record StudioTeamSummaryResponse( DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); +public sealed record StudioTeamCommandAcceptedResponse( + string ScopeId, + string TeamId, + string? CommandId, + string AckStage, + DateTimeOffset AcceptedAtUtc); + public sealed record StudioTeamRosterResponse( string ScopeId, IReadOnlyList Teams, diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioTeamService.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioTeamService.cs index 25a39ef14..106f40748 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/StudioTeamService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/StudioTeamService.cs @@ -56,7 +56,7 @@ public async Task GetAsync( return summary; } - public async Task UpdateAsync( + public async Task UpdateAsync( string scopeId, string teamId, UpdateStudioTeamRequest request, @@ -88,16 +88,14 @@ public async Task UpdateAsync( $"description must be at most {StudioTeamInputLimits.MaxDescriptionLength} characters."); } - await _commandPort.UpdateAsync(scopeId, teamId, request, ct); - return await GetAsync(scopeId, teamId, ct); + return await _commandPort.UpdateAsync(scopeId, teamId, request, ct); } - public async Task ArchiveAsync( + public Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default) { - await _commandPort.ArchiveAsync(scopeId, teamId, ct); - return await GetAsync(scopeId, teamId, ct); + return _commandPort.ArchiveAsync(scopeId, teamId, ct); } } diff --git a/src/Aevatar.Studio.Hosting/Endpoints/StudioTeamEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/StudioTeamEndpoints.cs index 132ba46d5..91ee216a4 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/StudioTeamEndpoints.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/StudioTeamEndpoints.cs @@ -188,12 +188,12 @@ internal static async Task HandlePatchAsync( try { - var detail = await teamService.UpdateAsync( + var accepted = await teamService.UpdateAsync( scopeId, teamId, new UpdateStudioTeamRequest(displayNamePatch, descriptionPatch), ct); - return Results.Ok(detail); + return Results.Accepted(BuildTeamLocation(accepted.ScopeId, accepted.TeamId), accepted); } catch (StudioTeamNotFoundException ex) { @@ -217,7 +217,8 @@ internal static async Task HandleArchiveAsync( try { - return Results.Ok(await teamService.ArchiveAsync(scopeId, teamId, ct)); + var accepted = await teamService.ArchiveAsync(scopeId, teamId, ct); + return Results.Accepted(BuildTeamLocation(accepted.ScopeId, accepted.TeamId), accepted); } catch (StudioTeamNotFoundException ex) { @@ -229,6 +230,9 @@ internal static async Task HandleArchiveAsync( } } + private static string BuildTeamLocation(string scopeId, string teamId) => + $"/api/scopes/{Uri.EscapeDataString(scopeId)}/teams/{Uri.EscapeDataString(teamId)}"; + /// /// Lists members assigned to a given team. Queries the member read model /// filtered by team_id (ADR-0017 §HTTP endpoints) — the team read diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioTeamCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioTeamCommandService.cs index b3a010d19..e5f99750d 100644 --- a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioTeamCommandService.cs +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioTeamCommandService.cs @@ -70,7 +70,7 @@ public async Task CreateAsync( UpdatedAt: createdAt); } - public async Task UpdateAsync( + public async Task UpdateAsync( string scopeId, string teamId, UpdateStudioTeamRequest request, @@ -83,7 +83,11 @@ public async Task UpdateAsync( // No-op if the patch payload carries no field to change. if (!request.DisplayName.HasValue && !request.Description.HasValue) - return; + return BuildAcceptedResponse( + normalizedScopeId, + normalizedTeamId, + commandId: null, + acceptedAtUtc: DateTimeOffset.UtcNow); var evt = new StudioTeamUpdatedEvent { @@ -105,10 +109,10 @@ public async Task UpdateAsync( evt.Description = request.Description.Value ?? string.Empty; } - await DispatchAsync(normalizedScopeId, normalizedTeamId, evt, ct); + return await DispatchAsync(normalizedScopeId, normalizedTeamId, evt, ct); } - public async Task ArchiveAsync( + public async Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default) @@ -123,25 +127,45 @@ public async Task ArchiveAsync( ArchivedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), }; - await DispatchAsync(normalizedScopeId, normalizedTeamId, evt, ct); + return await DispatchAsync(normalizedScopeId, normalizedTeamId, evt, ct); } - private async Task DispatchAsync(string scopeId, string teamId, IMessage payload, CancellationToken ct) + private async Task DispatchAsync( + string scopeId, + string teamId, + IMessage payload, + CancellationToken ct) { var actorId = StudioTeamConventions.BuildActorId(scopeId, teamId); var actor = await _bootstrap.EnsureAsync(actorId, ct); + var commandId = Guid.NewGuid().ToString("N"); + var acceptedAtUtc = DateTimeOffset.UtcNow; var envelope = new EventEnvelope { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Id = commandId, + Timestamp = Timestamp.FromDateTimeOffset(acceptedAtUtc), Payload = Any.Pack(payload), Route = EnvelopeRouteSemantics.CreateDirect(DirectRoute, actor.Id), }; await _dispatchPort.DispatchAsync(actor.Id, envelope, ct); + + return BuildAcceptedResponse(scopeId, teamId, commandId, acceptedAtUtc); } + private static StudioTeamCommandAcceptedResponse BuildAcceptedResponse( + string scopeId, + string teamId, + string? commandId, + DateTimeOffset acceptedAtUtc) => + new( + ScopeId: scopeId, + TeamId: teamId, + CommandId: commandId, + AckStage: StudioTeamCommandAckStageNames.Accepted, + AcceptedAtUtc: acceptedAtUtc); + private static string GenerateTeamId() { // Team ids are immutable identifiers; URL-safe and free of separators diff --git a/test/Aevatar.Studio.Tests/ActorDispatchStudioTeamCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchStudioTeamCommandServiceTests.cs index ba55de2eb..477dffbea 100644 --- a/test/Aevatar.Studio.Tests/ActorDispatchStudioTeamCommandServiceTests.cs +++ b/test/Aevatar.Studio.Tests/ActorDispatchStudioTeamCommandServiceTests.cs @@ -71,12 +71,13 @@ public async Task UpdateAsync_ShouldDispatchUpdatedEvent_WhenDisplayNameChanges( var service = new ActorDispatchStudioTeamCommandService( new RecordingBootstrap(), dispatch); - await service.UpdateAsync( + var receipt = await service.UpdateAsync( ScopeId, "t-1", new UpdateStudioTeamRequest(DisplayName: PatchValue.Of("New Name")), CancellationToken.None); dispatch.Dispatches.Should().ContainSingle(); + AssertReceiptMatchesEnvelope(receipt, dispatch.Dispatches[0].Envelope, ScopeId, "t-1"); var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack(); evt.HasDisplayName.Should().BeTrue(); evt.DisplayName.Should().Be("New Name"); @@ -90,12 +91,13 @@ public async Task UpdateAsync_ShouldDispatchUpdatedEvent_WhenDescriptionChanges( var service = new ActorDispatchStudioTeamCommandService( new RecordingBootstrap(), dispatch); - await service.UpdateAsync( + var receipt = await service.UpdateAsync( ScopeId, "t-1", new UpdateStudioTeamRequest(Description: PatchValue.Of("new desc")), CancellationToken.None); dispatch.Dispatches.Should().ContainSingle(); + receipt.CommandId.Should().Be(dispatch.Dispatches[0].Envelope.Id); var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack(); evt.HasDescription.Should().BeTrue(); evt.Description.Should().Be("new desc"); @@ -109,12 +111,17 @@ public async Task UpdateAsync_ShouldNoOp_WhenNothingToChange() var service = new ActorDispatchStudioTeamCommandService( new RecordingBootstrap(), dispatch); - await service.UpdateAsync( + var receipt = await service.UpdateAsync( ScopeId, "t-1", new UpdateStudioTeamRequest(), CancellationToken.None); dispatch.Dispatches.Should().BeEmpty(); + receipt.ScopeId.Should().Be(ScopeId); + receipt.TeamId.Should().Be("t-1"); + receipt.CommandId.Should().BeNull(); + receipt.AckStage.Should().Be(StudioTeamCommandAckStageNames.Accepted); + receipt.AcceptedAtUtc.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); } [Fact] @@ -124,7 +131,7 @@ public async Task UpdateAsync_ShouldDispatchBothFields_WhenBothPresent() var service = new ActorDispatchStudioTeamCommandService( new RecordingBootstrap(), dispatch); - await service.UpdateAsync( + var receipt = await service.UpdateAsync( ScopeId, "t-1", new UpdateStudioTeamRequest( DisplayName: PatchValue.Of("X"), @@ -132,6 +139,7 @@ await service.UpdateAsync( CancellationToken.None); dispatch.Dispatches.Should().ContainSingle(); + receipt.CommandId.Should().Be(dispatch.Dispatches[0].Envelope.Id); var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack(); evt.HasDisplayName.Should().BeTrue(); evt.DisplayName.Should().Be("X"); @@ -146,11 +154,12 @@ public async Task ArchiveAsync_ShouldDispatchArchivedEvent() var dispatch = new RecordingDispatchPort(); var service = new ActorDispatchStudioTeamCommandService(bootstrap, dispatch); - await service.ArchiveAsync(ScopeId, "t-1", CancellationToken.None); + var receipt = await service.ArchiveAsync(ScopeId, "t-1", CancellationToken.None); bootstrap.EnsuredActorIds.Should().ContainSingle() .Which.Should().Be("studio-team:scope-1:t-1"); dispatch.Dispatches.Should().ContainSingle(); + AssertReceiptMatchesEnvelope(receipt, dispatch.Dispatches[0].Envelope, ScopeId, "t-1"); var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack(); evt.TeamId.Should().Be("t-1"); @@ -168,6 +177,19 @@ public void Constructor_ShouldRejectNullDependencies() .Should().Throw(); } + private static void AssertReceiptMatchesEnvelope( + StudioTeamCommandAcceptedResponse receipt, + EventEnvelope envelope, + string scopeId, + string teamId) + { + receipt.ScopeId.Should().Be(scopeId); + receipt.TeamId.Should().Be(teamId); + receipt.CommandId.Should().Be(envelope.Id); + receipt.AckStage.Should().Be(StudioTeamCommandAckStageNames.Accepted); + receipt.AcceptedAtUtc.Should().Be(envelope.Timestamp.ToDateTimeOffset()); + } + private sealed class RecordingBootstrap : IStudioActorBootstrap { public List EnsuredActorIds { get; } = []; diff --git a/test/Aevatar.Studio.Tests/StudioTeamEndpointTests.cs b/test/Aevatar.Studio.Tests/StudioTeamEndpointTests.cs index f1b90b064..b7f6e15a5 100644 --- a/test/Aevatar.Studio.Tests/StudioTeamEndpointTests.cs +++ b/test/Aevatar.Studio.Tests/StudioTeamEndpointTests.cs @@ -93,7 +93,7 @@ public async Task HandleGetAsync_ShouldReturn404_WhenTeamMissing() } [Fact] - public async Task HandlePatchAsync_ShouldReturn200_WhenUpdateSucceeds() + public async Task HandlePatchAsync_ShouldReturn202Accepted_WhenUpdateSucceeds() { var service = new InMemoryTeamService(NewSummary()); var body = new StudioTeamEndpoints.StudioTeamPatchBody @@ -109,7 +109,16 @@ public async Task HandlePatchAsync_ShouldReturn200_WhenUpdateSucceeds() service, CancellationToken.None); - GetStatusCode(result).Should().Be(StatusCodes.Status200OK); + GetStatusCode(result).Should().Be(StatusCodes.Status202Accepted); + GetLocation(result).Should().Be($"/api/scopes/{ScopeId}/teams/{TeamId}"); + + var accepted = GetValue(result); + accepted.ScopeId.Should().Be(ScopeId); + accepted.TeamId.Should().Be(TeamId); + accepted.CommandId.Should().Be("cmd-update"); + accepted.AckStage.Should().Be(StudioTeamCommandAckStageNames.Accepted); + accepted.AcceptedAtUtc.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + AssertDoesNotContainPostStateFields(SerializeCamelCase(accepted)); } [Fact] @@ -209,7 +218,7 @@ public async Task HandlePatchAsync_ShouldAllowNullDescription() service, CancellationToken.None); - GetStatusCode(result).Should().Be(StatusCodes.Status200OK); + GetStatusCode(result).Should().Be(StatusCodes.Status202Accepted); } [Fact] @@ -229,11 +238,53 @@ public async Task HandlePatchAsync_ShouldAllowStringDescription() service, CancellationToken.None); - GetStatusCode(result).Should().Be(StatusCodes.Status200OK); + GetStatusCode(result).Should().Be(StatusCodes.Status202Accepted); + } + + [Fact] + public async Task HandlePatchAsync_ShouldReturnAcceptedReceiptWithNullCommandId_WhenNoop() + { + var service = new InMemoryTeamService(NewSummary()); + var result = await InvokeTeamHandle( + "HandlePatchAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + TeamId, + new StudioTeamEndpoints.StudioTeamPatchBody(), + service, + CancellationToken.None); + + GetStatusCode(result).Should().Be(StatusCodes.Status202Accepted); + var accepted = GetValue(result); + accepted.CommandId.Should().BeNull(); + accepted.AckStage.Should().Be(StudioTeamCommandAckStageNames.Accepted); + } + + [Fact] + public async Task HandlePatchAsync_ShouldUseEscapedLocationFromAcceptedReceipt() + { + var service = new InMemoryTeamService( + NewSummary(), + updateReceipt: NewAcceptedReceipt("scope with/slash", "team with/slash", "cmd-update")); + var body = new StudioTeamEndpoints.StudioTeamPatchBody + { + DisplayName = System.Text.Json.JsonSerializer.Deserialize("\"Beta\""), + }; + var result = await InvokeTeamHandle( + "HandlePatchAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + TeamId, + body, + service, + CancellationToken.None); + + GetStatusCode(result).Should().Be(StatusCodes.Status202Accepted); + GetLocation(result).Should().Be("/api/scopes/scope%20with%2Fslash/teams/team%20with%2Fslash"); } [Fact] - public async Task HandleArchiveAsync_ShouldReturn200_WhenSuccessful() + public async Task HandleArchiveAsync_ShouldReturn202Accepted_WhenSuccessful() { var service = new InMemoryTeamService(NewSummary()); var result = await InvokeTeamHandle( @@ -244,7 +295,15 @@ public async Task HandleArchiveAsync_ShouldReturn200_WhenSuccessful() service, CancellationToken.None); - GetStatusCode(result).Should().Be(StatusCodes.Status200OK); + GetStatusCode(result).Should().Be(StatusCodes.Status202Accepted); + GetLocation(result).Should().Be($"/api/scopes/{ScopeId}/teams/{TeamId}"); + + var accepted = GetValue(result); + accepted.ScopeId.Should().Be(ScopeId); + accepted.TeamId.Should().Be(TeamId); + accepted.CommandId.Should().Be("cmd-archive"); + accepted.AckStage.Should().Be(StudioTeamCommandAckStageNames.Accepted); + AssertDoesNotContainPostStateFields(SerializeCamelCase(accepted)); } [Fact] @@ -345,11 +404,52 @@ private static async Task InvokeTeamHandle(string methodName, params ob return result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; } + private static string? GetLocation(IResult result) => + result.GetType().GetProperty("Location")?.GetValue(result) as string; + + private static T GetValue(IResult result) where T : class => + result.GetType().GetProperty("Value")?.GetValue(result) as T + ?? throw new InvalidOperationException($"Result does not carry {typeof(T).Name}."); + + private static string SerializeCamelCase(T value) => + System.Text.Json.JsonSerializer.Serialize( + value, + new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + }); + + private static void AssertDoesNotContainPostStateFields(string json) + { + foreach (var field in new[] + { + "displayName", + "description", + "lifecycleStage", + "memberCount", + "createdAt", + "updatedAt", + }) + { + json.Should().NotContain(field); + } + } + private sealed class InMemoryTeamService : IStudioTeamService { private readonly StudioTeamSummaryResponse _summary; + private readonly StudioTeamCommandAcceptedResponse _updateReceipt; + private readonly StudioTeamCommandAcceptedResponse _archiveReceipt; - public InMemoryTeamService(StudioTeamSummaryResponse summary) => _summary = summary; + public InMemoryTeamService( + StudioTeamSummaryResponse summary, + StudioTeamCommandAcceptedResponse? updateReceipt = null, + StudioTeamCommandAcceptedResponse? archiveReceipt = null) + { + _summary = summary; + _updateReceipt = updateReceipt ?? NewAcceptedReceipt(summary.ScopeId, summary.TeamId, "cmd-update"); + _archiveReceipt = archiveReceipt ?? NewAcceptedReceipt(summary.ScopeId, summary.TeamId, "cmd-archive"); + } public Task CreateAsync( string scopeId, CreateStudioTeamRequest request, CancellationToken ct = default) => @@ -363,13 +463,17 @@ public Task GetAsync( string scopeId, string teamId, CancellationToken ct = default) => Task.FromResult(_summary); - public Task UpdateAsync( - string scopeId, string teamId, UpdateStudioTeamRequest request, CancellationToken ct = default) => - Task.FromResult(_summary); + public Task UpdateAsync( + string scopeId, string teamId, UpdateStudioTeamRequest request, CancellationToken ct = default) + { + if (!request.DisplayName.HasValue && !request.Description.HasValue) + return Task.FromResult(_updateReceipt with { CommandId = null }); + return Task.FromResult(_updateReceipt); + } - public Task ArchiveAsync( + public Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default) => - Task.FromResult(_summary); + Task.FromResult(_archiveReceipt); } private sealed class ThrowingTeamService : IStudioTeamService @@ -383,12 +487,23 @@ public Task ListAsync( string scopeId, StudioTeamRosterPageRequest? page = null, CancellationToken ct = default) => throw _ex; public Task GetAsync( string scopeId, string teamId, CancellationToken ct = default) => throw _ex; - public Task UpdateAsync( + public Task UpdateAsync( string scopeId, string teamId, UpdateStudioTeamRequest request, CancellationToken ct = default) => throw _ex; - public Task ArchiveAsync( + public Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default) => throw _ex; } + private static StudioTeamCommandAcceptedResponse NewAcceptedReceipt( + string scopeId, + string teamId, + string? commandId) => + new( + ScopeId: scopeId, + TeamId: teamId, + CommandId: commandId, + AckStage: StudioTeamCommandAckStageNames.Accepted, + AcceptedAtUtc: DateTimeOffset.UtcNow); + private sealed class InMemoryMemberService : IStudioMemberService { private readonly string? _teamId; diff --git a/test/Aevatar.Studio.Tests/StudioTeamEndpointsRouteBindingTests.cs b/test/Aevatar.Studio.Tests/StudioTeamEndpointsRouteBindingTests.cs index f63f59a41..e189e8e5e 100644 --- a/test/Aevatar.Studio.Tests/StudioTeamEndpointsRouteBindingTests.cs +++ b/test/Aevatar.Studio.Tests/StudioTeamEndpointsRouteBindingTests.cs @@ -50,13 +50,13 @@ public Task GetAsync( string scopeId, string teamId, CancellationToken ct = default) => Task.FromException(new NotImplementedException()); - public Task UpdateAsync( + public Task UpdateAsync( string scopeId, string teamId, UpdateStudioTeamRequest request, CancellationToken ct = default) => - Task.FromException(new NotImplementedException()); + Task.FromException(new NotImplementedException()); - public Task ArchiveAsync( + public Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default) => - Task.FromException(new NotImplementedException()); + Task.FromException(new NotImplementedException()); } private sealed class NoOpMemberServiceForTeam : IStudioMemberService diff --git a/test/Aevatar.Studio.Tests/StudioTeamServiceTests.cs b/test/Aevatar.Studio.Tests/StudioTeamServiceTests.cs index 58fd90b50..12abe891e 100644 --- a/test/Aevatar.Studio.Tests/StudioTeamServiceTests.cs +++ b/test/Aevatar.Studio.Tests/StudioTeamServiceTests.cs @@ -108,31 +108,29 @@ await act.Should().ThrowAsync() } [Fact] - public async Task UpdateAsync_ShouldDelegateAndReRead() + public async Task UpdateAsync_ShouldReturnAcceptedReceiptWithoutReReading() { var commandPort = new RecordingCommandPort(); - var summary = NewSummary(); - var service = new StudioTeamService(commandPort, new InMemoryQueryPort(summary)); + var service = new StudioTeamService(commandPort, new ThrowingQueryPort()); var result = await service.UpdateAsync( ScopeId, TeamId, new UpdateStudioTeamRequest(DisplayName: PatchValue.Of("Beta"))); commandPort.UpdateCalls.Should().Be(1); - result.Should().NotBeNull(); + result.Should().Be(commandPort.UpdateReceipt); } [Fact] - public async Task ArchiveAsync_ShouldDelegateAndReRead() + public async Task ArchiveAsync_ShouldReturnAcceptedReceiptWithoutReReading() { var commandPort = new RecordingCommandPort(); - var summary = NewSummary(); - var service = new StudioTeamService(commandPort, new InMemoryQueryPort(summary)); + var service = new StudioTeamService(commandPort, new ThrowingQueryPort()); var result = await service.ArchiveAsync(ScopeId, TeamId); commandPort.ArchiveCalls.Should().Be(1); - result.Should().NotBeNull(); + result.Should().Be(commandPort.ArchiveReceipt); } [Fact] @@ -174,11 +172,24 @@ public Task ListAsync( Task.FromResult(_summary); } + private sealed class ThrowingQueryPort : IStudioTeamQueryPort + { + public Task ListAsync( + string scopeId, StudioTeamRosterPageRequest? page = null, CancellationToken ct = default) => + throw new InvalidOperationException("query port should not be called"); + + public Task GetAsync( + string scopeId, string teamId, CancellationToken ct = default) => + throw new InvalidOperationException("query port should not be called"); + } + private sealed class RecordingCommandPort : IStudioTeamCommandPort { public int CreateCalls { get; private set; } public int UpdateCalls { get; private set; } public int ArchiveCalls { get; private set; } + public StudioTeamCommandAcceptedResponse UpdateReceipt { get; } = NewReceipt("cmd-update"); + public StudioTeamCommandAcceptedResponse ArchiveReceipt { get; } = NewReceipt("cmd-archive"); public Task CreateAsync( string scopeId, CreateStudioTeamRequest request, CancellationToken ct = default) @@ -195,18 +206,26 @@ public Task CreateAsync( UpdatedAt: DateTimeOffset.UtcNow)); } - public Task UpdateAsync( + public Task UpdateAsync( string scopeId, string teamId, UpdateStudioTeamRequest request, CancellationToken ct = default) { UpdateCalls++; - return Task.CompletedTask; + return Task.FromResult(UpdateReceipt); } - public Task ArchiveAsync( + public Task ArchiveAsync( string scopeId, string teamId, CancellationToken ct = default) { ArchiveCalls++; - return Task.CompletedTask; + return Task.FromResult(ArchiveReceipt); } + + private static StudioTeamCommandAcceptedResponse NewReceipt(string commandId) => + new( + ScopeId: ScopeId, + TeamId: TeamId, + CommandId: commandId, + AckStage: StudioTeamCommandAckStageNames.Accepted, + AcceptedAtUtc: DateTimeOffset.UtcNow); } } From 65fad42592b1833db96070781fa0f31bc36df42e Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Thu, 21 May 2026 14:34:55 +0800 Subject: [PATCH 2/2] Tighten Studio team ACK client semantics Clarify accepted-command UI messaging and reject unexpected ACK stages so the frontend does not imply read-model freshness beyond the backend contract. Co-Authored-By: Claude Opus 4.6 --- .../src/pages/teams/detail.test.tsx | 4 +-- .../src/pages/teams/detail.tsx | 4 +-- .../src/shared/studio/api.test.ts | 34 +++++++++++++++++++ .../src/shared/studio/api.ts | 20 ++++++++--- .../src/shared/studio/models.ts | 4 ++- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx index c57aac806..7b08774dd 100644 --- a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx +++ b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx @@ -1167,7 +1167,7 @@ describe("TeamDetailPage", () => { description: null, }); }); - expect(message.success).toHaveBeenCalledWith("Team updated."); + expect(message.success).toHaveBeenCalledWith("Team update accepted."); await waitFor(() => { expect(studioApi.getTeam).toHaveBeenCalledTimes(2); }); @@ -1228,7 +1228,7 @@ describe("TeamDetailPage", () => { await waitFor(() => { expect(studioApi.archiveTeam).toHaveBeenCalledWith("scope-1", "t-alpha"); }); - expect(message.success).toHaveBeenCalledWith("Team archived."); + expect(message.success).toHaveBeenCalledWith("Team archive accepted."); expect(screen.getByRole("button", { name: "Edit Team" })).toBeEnabled(); expect(screen.queryByRole("button", { name: "Archive Team" })).toBeNull(); }); diff --git a/apps/aevatar-console-web/src/pages/teams/detail.tsx b/apps/aevatar-console-web/src/pages/teams/detail.tsx index b63750fb0..e3bff1ea7 100644 --- a/apps/aevatar-console-web/src/pages/teams/detail.tsx +++ b/apps/aevatar-console-web/src/pages/teams/detail.tsx @@ -3208,7 +3208,7 @@ const TeamDetailPage: React.FC = () => { displayName, description: teamEditorDescription.trim() || null, }); - void message.success("Team updated."); + void message.success("Team update accepted."); setTeamEditorOpen(false); await refreshTeamAuthority(); } catch (error) { @@ -3252,7 +3252,7 @@ const TeamDetailPage: React.FC = () => { setTeamArchiving(true); try { await studioApi.archiveTeam(scopeId, selectedTeamId); - void message.success("Team archived."); + void message.success("Team archive accepted."); setTeamArchiveOpen(false); await refreshTeamAuthority(); } catch (error) { diff --git a/apps/aevatar-console-web/src/shared/studio/api.test.ts b/apps/aevatar-console-web/src/shared/studio/api.test.ts index ccfb4b176..a11cba4bf 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.test.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.test.ts @@ -1359,6 +1359,40 @@ describe('studioApi host-session requests', () => { ); }); + it('rejects unknown studio team command ACK stages', async () => { + persistAuthSession({ + tokens: { + accessToken: 'access-token', + tokenType: 'Bearer', + expiresIn: 3600, + expiresAt: Date.now() + 3_600_000, + }, + user: { + sub: 'user-1', + }, + }); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 202, + json: async () => ({ + scopeId: 'scope-1', + teamId: 't-alpha', + commandId: 'cmd-update', + ackStage: 'committed', + acceptedAtUtc: '2026-05-01T08:06:00Z', + }), + } as Response) as typeof global.fetch; + + await expect( + studioApi.updateTeam({ + scopeId: 'scope-1', + teamId: 't-alpha', + displayName: 'Alpha Ops', + }), + ).rejects.toThrow('StudioTeamCommandAcceptedResponse.ackStage must be accepted.'); + }); + it('gets a studio member detail from the member authority endpoint', async () => { persistAuthSession({ tokens: { diff --git a/apps/aevatar-console-web/src/shared/studio/api.ts b/apps/aevatar-console-web/src/shared/studio/api.ts index 6965119b7..19777fd18 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.ts @@ -44,6 +44,7 @@ import type { StudioSettings, StudioStartExecutionInput, StudioTeamCommandAcceptedResponse, + StudioTeamCommandAckStage, StudioTeamCreateInput, StudioTeamLifecycleStage, StudioTeamRoster, @@ -1098,6 +1099,19 @@ function decodeStudioTeamSummary(value: unknown): StudioTeamSummary { }; } +function readStudioTeamCommandAckStage(record: Record): StudioTeamCommandAckStage { + const value = readString( + record, + ["ackStage", "AckStage"], + "StudioTeamCommandAcceptedResponse.ackStage" + ); + if (value !== "accepted") { + throw new Error("StudioTeamCommandAcceptedResponse.ackStage must be accepted."); + } + + return value; +} + function decodeStudioTeamCommandAcceptedResponse( value: unknown ): StudioTeamCommandAcceptedResponse { @@ -1119,11 +1133,7 @@ function decodeStudioTeamCommandAcceptedResponse( ["commandId", "CommandId"], "StudioTeamCommandAcceptedResponse.commandId" ) ?? null, - ackStage: readString( - record, - ["ackStage", "AckStage"], - "StudioTeamCommandAcceptedResponse.ackStage" - ), + ackStage: readStudioTeamCommandAckStage(record), acceptedAtUtc: readString( record, ["acceptedAtUtc", "AcceptedAtUtc"], diff --git a/apps/aevatar-console-web/src/shared/studio/models.ts b/apps/aevatar-console-web/src/shared/studio/models.ts index 341269b27..2538211c7 100644 --- a/apps/aevatar-console-web/src/shared/studio/models.ts +++ b/apps/aevatar-console-web/src/shared/studio/models.ts @@ -552,11 +552,13 @@ export interface StudioTeamSummary { readonly updatedAt: string; } +export type StudioTeamCommandAckStage = 'accepted'; + export interface StudioTeamCommandAcceptedResponse { readonly scopeId: string; readonly teamId: string; readonly commandId?: string | null; - readonly ackStage: string; + readonly ackStage: StudioTeamCommandAckStage; readonly acceptedAtUtc: string; }