Skip to content

Clarify StreamingProxy projection lease scope semantics #377

@louis4li

Description

@louis4li

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。这个错位不是单点命名问题:通用 IProjectionPortSessionLeaseEventSinkProjectionLifecyclePortBaseProjectionSessionEventHub 都把第一维命名为 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;

违反点:ActorIdScopeId 都返回 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 只提供 RootActorIdSessionIdProjectionKind;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

  1. 通用 IProjectionPortSessionLease 的第一维应命名为 RootActorIdOwnerActorIdSessionOwnerId 还是更贴近 projection scope key 的名称?
  2. ProjectionSessionEventTransportMessage.ScopeId 是否需要 proto 字段演进为新字段,并 reserve/兼容旧字段,还是仅在 internal API 层 rename?
  3. StreamingProxy/NyxIdChat/Workflow/Scripting/GAgentService projection session leases 是否应一次性统一,避免只修 StreamingProxy 后保留同类错位?
  4. 如果部分 session event consumer 真需要业务 scope id,是否新增独立 typed field,而不是复用 transport key?
  5. 是否需要补一个 architecture guard,禁止 IProjectionPortSessionLease.ScopeId => RootEntityIdScopeId == 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⟧

Metadata

Metadata

Assignees

No one assigned

    Labels

    auto-loopCreated by codex-refactor-loop skillauto-loop-resumeSet when design decision is ready; codex-refactor-loop will resume implementphase9-auto-solveOperator opted this design issue into Phase 9 auto-solverefactor-design-neededCluster flagged requires_design by codex-refactor-loop auto audit🎉 phase:merged🚀 phase:pr-open

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions