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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions apps/aevatar-console-web/src/pages/teams/detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -665,13 +665,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 () => ({
Expand Down Expand Up @@ -736,14 +741,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(
Expand Down Expand Up @@ -1190,7 +1200,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);
});
Expand Down Expand Up @@ -1252,7 +1262,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: "Team more actions" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Archive Team" })).toBeNull();
Expand Down
4 changes: 2 additions & 2 deletions apps/aevatar-console-web/src/pages/teams/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,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) {
Expand Down Expand Up @@ -940,7 +940,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) {
Expand Down
74 changes: 54 additions & 20 deletions apps/aevatar-console-web/src/shared/studio/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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: {
Expand Down
53 changes: 49 additions & 4 deletions apps/aevatar-console-web/src/shared/studio/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import type {
StudioSerializeYamlResult,
StudioSettings,
StudioStartExecutionInput,
StudioTeamCommandAcceptedResponse,
StudioTeamCommandAckStage,
StudioTeamCreateInput,
StudioTeamLifecycleStage,
StudioTeamRoster,
Expand Down Expand Up @@ -1097,6 +1099,49 @@ function decodeStudioTeamSummary(value: unknown): StudioTeamSummary {
};
}

function readStudioTeamCommandAckStage(record: Record<string, unknown>): 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 {
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: readStudioTeamCommandAckStage(record),
acceptedAtUtc: readString(
record,
["acceptedAtUtc", "AcceptedAtUtc"],
"StudioTeamCommandAcceptedResponse.acceptedAtUtc"
),
};
}

function decodeStudioTeamRoster(value: unknown): StudioTeamRoster {
const record = expectRecord(value, "StudioTeamRoster");
return {
Expand Down Expand Up @@ -1431,10 +1476,10 @@ export const studioApi = {
);
},

updateTeam(input: StudioTeamUpdateInput): Promise<StudioTeamSummary> {
updateTeam(input: StudioTeamUpdateInput): Promise<StudioTeamCommandAcceptedResponse> {
return requestDecodedJson(
`/api/scopes/${encodeURIComponent(input.scopeId.trim())}/teams/${encodeURIComponent(input.teamId.trim())}`,
decodeStudioTeamSummary,
decodeStudioTeamCommandAcceptedResponse,
{
method: "PATCH",
headers: JSON_HEADERS,
Expand All @@ -1450,10 +1495,10 @@ export const studioApi = {
);
},

archiveTeam(scopeId: string, teamId: string): Promise<StudioTeamSummary> {
archiveTeam(scopeId: string, teamId: string): Promise<StudioTeamCommandAcceptedResponse> {
return requestDecodedJson(
`/api/scopes/${encodeURIComponent(scopeId.trim())}/teams/${encodeURIComponent(teamId.trim())}/archive`,
decodeStudioTeamSummary,
decodeStudioTeamCommandAcceptedResponse,
{
method: "POST",
headers: JSON_HEADERS,
Expand Down
10 changes: 10 additions & 0 deletions apps/aevatar-console-web/src/shared/studio/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,16 @@ 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: StudioTeamCommandAckStage;
readonly acceptedAtUtc: string;
}

export interface StudioTeamRoster {
readonly scopeId: string;
readonly teams: readonly StudioTeamSummary[];
Expand Down
62 changes: 62 additions & 0 deletions docs/adr/0024-studio-team-command-ack-semantics.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading