id: cluster-triage-streamingproxy-projection-lease-scope-semantics
severity: medium
requires_design: true
来源
本 issue 由 maintainer 手动开,triage codex 深度调研补 evidence。
核心问题
StreamingProxyRoomSessionRuntimeLease.ScopeId 当前返回 RootEntityId,而 RootEntityId 是 room actor id,不是业务 scope id。这个错位不是单点命名问题:通用 IProjectionPortSessionLease、EventSinkProjectionLifecyclePortBase 和 ProjectionSessionEventHub 都把第一维命名为 ScopeId,但 StreamingProxy 的生产投影链路实际传入 actor id,导致 projection session lease/transport contract 可以被 future consumers 误用。
Evidence
1. StreamingProxy lease 把 actor id 同时暴露为 ActorId 与 ScopeId
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionRuntimeLease.cs:19
public string ActorId => RootEntityId;
public string SessionId { get; }
public StreamingProxyRoomSessionProjectionContext Context { get; }
public string ScopeId => RootEntityId;
违反点:ActorId 和 ScopeId 都返回 RootEntityId,字段名表达两个不同语义。
2. StreamingProxy session context 没有业务 scope id
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionProjectionContext.cs:5
public sealed class StreamingProxyRoomSessionProjectionContext : IProjectionSessionContext
{
public required string SessionId { get; init; }
public required string RootActorId { get; init; }
public required string ProjectionKind { get; init; }
}
违反点:context 只提供 RootActorId、SessionId、ProjectionKind;lease 却被通用 contract 要求提供 ScopeId,于是把 actor id alias 成 scope id。
3. 通用 projection session lease contract 强制使用 ScopeId 命名
src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSessionLease.cs:6
public interface IProjectionPortSessionLease
{
string ScopeId { get; }
string SessionId { get; }
}
违反点:这是通用 projection sink subscription orchestration contract,但第一维并不总是业务 scope;在 StreamingProxy、Workflow、Scripting、GAgentDraftRun 等 actor-scoped session lease 中,现有实现普遍返回 actor id。
4. Lifecycle base 将错误语义继续传给 session hub
src/Aevatar.CQRS.Projection.Core/Orchestration/EventSinkProjectionLifecyclePortBase.cs:51
return await _sessionEventHub.SubscribeAsync(
portLease.ScopeId,
portLease.SessionId,
evt => sink.PushAsync(evt, CancellationToken.None),
ct).ConfigureAwait(false);
违反点:ScopeId 被作为 session event hub 的分区 key 传播,隐藏了真实第一维是 actor/session owner key。
5. Session event hub API/transport message 也叫 ScopeId
src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs:30
public Task PublishAsync(
string scopeId,
string sessionId,
TEvent evt,
CancellationToken ct = default)
{
...
var stream = _streamProvider.GetStream(ResolveStreamId(scopeId, sessionId));
var message = new ProjectionSessionEventTransportMessage
{
ScopeId = scopeId,
SessionId = sessionId,
EventType = _codec.GetEventType(evt),
Payload = _codec.Serialize(evt),
};
违反点:transport 第一维被命名/序列化为 ScopeId,但 StreamingProxy projector 传入的是 RootActorId。
6. StreamingProxy projector 明确用 RootActorId 作为第一维
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionEventProjector.cs:30
return
[
new ProjectionSessionEventEntry<StreamingProxyRoomSessionEnvelope>(
context.RootActorId,
context.SessionId,
new StreamingProxyRoomSessionEnvelope
{
Envelope = envelope,
}),
];
违反点:投影入口本身表达的是 actor-scoped session key;下游却把这个 key 命名成 ScopeId。
7. 测试已经固化 actor id as ScopeId
test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs:2466
sessionHub.Published.Should().HaveCount(3);
sessionHub.Published[0].Event.TextMessageContent.Delta.Should().Be("delta-1");
sessionHub.Published[1].Event.EventCase.Should().Be(AGUIEvent.EventOneofCase.TextMessageEnd);
sessionHub.Published[2].Event.EventCase.Should().Be(AGUIEvent.EventOneofCase.RunFinished);
sessionHub.Published.Should().OnlyContain(x => x.ScopeId == "actor-1" && x.SessionId == "session-1");
违反点:测试层也接受了 ScopeId == actor id,后续修复必须同步改测试语义。
违反条款
AGENTS.md:
API 字段单一语义:一个字段只能表达一个含义,禁止同字段承载“名称查找 + inline 内容”等双重语义。
追踪标识与目标身份必须分离:commandId/correlationId 用于追踪一次请求,actorId 用于标识处理实体;禁止把追踪 ID 与目标身份混成同一语义,也不得假设二者天然一一对应。
命名必须跟随职责语义:接口、类型、目录命名应描述职责与边界,而不是绑定暂时实现路径;一旦底层实现可替换,命名不得泄露 runtime/stream/protocol 偶然细节。
CLAUDE.md:
API 字段单一语义:一个字段只表达一个含义,禁止双重语义(如"名称查找 + inline 内容")。
身份与事实分离:稳定 ID 只负责寻址与复用键;可变绑定必须显式建模、显式读取。
命名跟随职责:接口/类型/目录命名描述职责边界,不泄露 runtime/stream/protocol 偶然细节。
新原则
Projection session event 的第一维应按真实职责命名为 actor/session owner key(例如 RootActorId / OwnerActorId / SessionOwnerId,由 solver 统一决策),不得继续用 ScopeId 承载 actor id。若某个 projection session 确实需要业务 scope id,应作为独立 typed field 显式建模,而不是复用 transport partition key。
Fix boundary
scope_paths:
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionRuntimeLease.cs
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionProjectionContext.cs
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionEventProjector.cs
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionProjectionPort.cs
agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs
src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSessionLease.cs
src/Aevatar.CQRS.Projection.Core/Orchestration/EventSinkProjectionLifecyclePortBase.cs
src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs
src/Aevatar.CQRS.Projection.Core/projection_session_event_transport.proto
src/Aevatar.Scripting.Projection/Orchestration/ScriptExecutionRuntimeLease.cs
src/Aevatar.Scripting.Projection/Orchestration/ScriptEvolutionRuntimeLease.cs
src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cs
src/platform/Aevatar.GAgentService.Projection/Orchestration/GAgentDraftRunRuntimeLease.cs
src/platform/Aevatar.GAgentService.Projection/Orchestration/ScriptServiceAguiRuntimeLease.cs
test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs
test/Aevatar.CQRS.Projection.Core.Tests/ProjectionPortBaseCoverageTests.cs
test/Aevatar.CQRS.Projection.Core.Tests/ProjectionRuntimeRegistrationTests.cs
test/Aevatar.GAgentService.Tests/Projection/GAgentDraftRunProjectionInfrastructureTests.cs
test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionPortTests.cs
test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionReadModelCoverageTests.cs
Decision questions
- 通用
IProjectionPortSessionLease 的第一维应命名为 RootActorId、OwnerActorId、SessionOwnerId 还是更贴近 projection scope key 的名称?
ProjectionSessionEventTransportMessage.ScopeId 是否需要 proto 字段演进为新字段,并 reserve/兼容旧字段,还是仅在 internal API 层 rename?
- StreamingProxy/NyxIdChat/Workflow/Scripting/GAgentService projection session leases 是否应一次性统一,避免只修 StreamingProxy 后保留同类错位?
- 如果部分 session event consumer 真需要业务 scope id,是否新增独立 typed field,而不是复用 transport key?
- 是否需要补一个 architecture guard,禁止
IProjectionPortSessionLease.ScopeId => RootEntityId 或 ScopeId == actor-id 的回归?
original_authors
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionRuntimeLease.cs: louis.li
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionEventProjector.cs: louis.li
agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs: history shows StreamingProxy projection/session registration work around Unify AGUI streaming through projection sessions
src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSessionLease.cs: history shows event sink/projection lifecycle abstraction introduction
src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs: history shows projection session transport/codec evolution
cc
原作者信息已记录在 original_authors,本条不添加直接提及。
⟦AI:AUTO-LOOP⟧
id: cluster-triage-streamingproxy-projection-lease-scope-semantics
severity: medium
requires_design: true
来源
本 issue 由 maintainer 手动开,triage codex 深度调研补 evidence。
核心问题
StreamingProxyRoomSessionRuntimeLease.ScopeId当前返回RootEntityId,而RootEntityId是 room actor id,不是业务 scope id。这个错位不是单点命名问题:通用IProjectionPortSessionLease、EventSinkProjectionLifecyclePortBase和ProjectionSessionEventHub都把第一维命名为ScopeId,但 StreamingProxy 的生产投影链路实际传入 actor id,导致 projection session lease/transport contract 可以被 future consumers 误用。Evidence
1. StreamingProxy lease 把 actor id 同时暴露为 ActorId 与 ScopeId
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionRuntimeLease.cs:19违反点:
ActorId和ScopeId都返回RootEntityId,字段名表达两个不同语义。2. StreamingProxy session context 没有业务 scope id
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionProjectionContext.cs:5违反点:context 只提供
RootActorId、SessionId、ProjectionKind;lease 却被通用 contract 要求提供ScopeId,于是把 actor id alias 成 scope id。3. 通用 projection session lease contract 强制使用 ScopeId 命名
src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSessionLease.cs:6违反点:这是通用 projection sink subscription orchestration contract,但第一维并不总是业务 scope;在 StreamingProxy、Workflow、Scripting、GAgentDraftRun 等 actor-scoped session lease 中,现有实现普遍返回 actor id。
4. Lifecycle base 将错误语义继续传给 session hub
src/Aevatar.CQRS.Projection.Core/Orchestration/EventSinkProjectionLifecyclePortBase.cs:51违反点:
ScopeId被作为 session event hub 的分区 key 传播,隐藏了真实第一维是 actor/session owner key。5. Session event hub API/transport message 也叫 ScopeId
src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs:30违反点:transport 第一维被命名/序列化为
ScopeId,但 StreamingProxy projector 传入的是RootActorId。6. StreamingProxy projector 明确用 RootActorId 作为第一维
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionEventProjector.cs:30违反点:投影入口本身表达的是 actor-scoped session key;下游却把这个 key 命名成
ScopeId。7. 测试已经固化 actor id as ScopeId
test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs:2466违反点:测试层也接受了
ScopeId == actor id,后续修复必须同步改测试语义。违反条款
AGENTS.md:
CLAUDE.md:
新原则
Projection session event 的第一维应按真实职责命名为 actor/session owner key(例如
RootActorId/OwnerActorId/SessionOwnerId,由 solver 统一决策),不得继续用ScopeId承载 actor id。若某个 projection session 确实需要业务 scope id,应作为独立 typed field 显式建模,而不是复用 transport partition key。Fix boundary
scope_paths:
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionRuntimeLease.csagents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionProjectionContext.csagents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionEventProjector.csagents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionProjectionPort.csagents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cssrc/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSessionLease.cssrc/Aevatar.CQRS.Projection.Core/Orchestration/EventSinkProjectionLifecyclePortBase.cssrc/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cssrc/Aevatar.CQRS.Projection.Core/projection_session_event_transport.protosrc/Aevatar.Scripting.Projection/Orchestration/ScriptExecutionRuntimeLease.cssrc/Aevatar.Scripting.Projection/Orchestration/ScriptEvolutionRuntimeLease.cssrc/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cssrc/platform/Aevatar.GAgentService.Projection/Orchestration/GAgentDraftRunRuntimeLease.cssrc/platform/Aevatar.GAgentService.Projection/Orchestration/ScriptServiceAguiRuntimeLease.cstest/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cstest/Aevatar.CQRS.Projection.Core.Tests/ProjectionPortBaseCoverageTests.cstest/Aevatar.CQRS.Projection.Core.Tests/ProjectionRuntimeRegistrationTests.cstest/Aevatar.GAgentService.Tests/Projection/GAgentDraftRunProjectionInfrastructureTests.cstest/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionPortTests.cstest/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionReadModelCoverageTests.csDecision questions
IProjectionPortSessionLease的第一维应命名为RootActorId、OwnerActorId、SessionOwnerId还是更贴近 projection scope key 的名称?ProjectionSessionEventTransportMessage.ScopeId是否需要 proto 字段演进为新字段,并 reserve/兼容旧字段,还是仅在 internal API 层 rename?IProjectionPortSessionLease.ScopeId => RootEntityId或ScopeId == actor-id的回归?original_authors
agents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionRuntimeLease.cs:louis.liagents/Aevatar.GAgents.StreamingProxy/StreamingProxyRoomSessionEventProjector.cs:louis.liagents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs: history shows StreamingProxy projection/session registration work aroundUnify AGUI streaming through projection sessionssrc/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSessionLease.cs: history shows event sink/projection lifecycle abstraction introductionsrc/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs: history shows projection session transport/codec evolutioncc
原作者信息已记录在
original_authors,本条不添加直接提及。⟦AI:AUTO-LOOP⟧