Skip to content

架构重构:统一 channel inbound 主干,Nyx relay 降级为 transport adapter #328

@eanzhao

Description

@eanzhao

背景

当前 Lark bot 的生产路径(Lark → NyxID → Aevatar /api/webhooks/nyxid-relay)存在两条 inbound 主干做同一件事的架构问题。chat_type 丢失导致 /daily-report 在 1:1 私聊里被错误拒绝(用户报的 bug),只是这个架构问题的一个症状。

具体症状:1:1 私聊里发 /daily-report,bot 回复"请在和机器人 1:1 私聊里发送这个命令,仅支持私聊创建/运行 Day One agent,群聊里不可用。" —— 是 LLM 幻觉,不是 hardcoded 文案。

相关 PR:#323(在 legacy 路径上加确定性 slash flow,但 relay 路径根本不走这条路径,修复在生产上没生效)。

根因(架构层面)

路径 A(legacy,直接 Lark webhook,已 410 Gone):
  Lark webhook → LarkChannelAdapter.ParseMessage → ChatActivity
              → ConversationGAgent.HandleInboundActivityAsync(actor,事实源)
              → LarkConversationTurnRunner.RunInboundAsync
              → [workflow resume | slash flow | agent builder | LLM fallback]
              → IChannelOutboundPort.SendAsync

路径 B(生产,Nyx relay):
  Lark → Nyx → POST /api/webhooks/nyxid-relay
       → HandleRelayWebhookAsync(HTTP endpoint 直接承载业务编排)
       → 手搓 NyxIdChatGAgent + 手搓 subscribe + 手搓 reply 累积 + 手搓 classify
       → NyxIdApiClient.SendChannelRelayTextReplyAsync

路径 B 的违规点:

  1. HTTP endpoint 承载业务编排 → 违反 CLAUDE.md "API 仅做宿主与组合,不承载业务编排"
  2. 绕过 ConversationGAgent → 失去 dedup、conversation actor 边界、ConversationTurnCompletedEvent 投影链路、重试策略,违反 "Actor 即业务实体"、"事实源唯一"
  3. 直接选 NyxIdChatGAgent 作处理器 → 跳过 slash/workflow-resume/agent-builder 所有确定性短路
  4. 重新实现 reply 累积 + 超时 + 错误分类 → 和 ConversationPlatformReplyService / IChannelOutboundPort 两套,违反 "单一主干,插件扩展"、"删除优先"
  5. payload 不落到 ChatActivity → chat_type / mentions / scope 全丢;这就是这次的症状(LLM 不知道 chat_type,system prompt 写了"不是 p2p 就让用户去 DM",LLM 保守地假设不是 p2p 就拒绝)

NyxID relay 事实调研(~/Code/NyxID

  • 当前支持 5 个平台:Telegram / Discord / Lark / Feishu / Slack(Slack 是 2026-03 新加)
  • relay 内核是 platform-neutral 的:单一 CallbackPayload struct(所有平台共享),单一 forward_to_agent() 转发;channel_relay_service.rs:28-49, 274-362
  • conversation.type 已在 NyxID 侧标准化为 4 个枚举值:"private" / "group" / "channel" / "device",各平台 adapter 内部做映射
  • 设计文档明确支持扩展新平台(WhatsApp / LINE 等):docs/CHANNEL_BOT_RELAY.md:402-423
  • Aevatar 侧入口只有一个 endpoint,POST /api/webhooks/nyxid-relayplatform 字段在 payload 里指明来源

结论:Aevatar 侧的 relay transport 必须是 channel-neutral 的,不能和 Lark 绑死。

目标架构

Nyx relay 降级为一个 channel-neutral transport adapter,和 direct webhook 并列,汇入同一条主干:

[Lark direct webhook | Telegram direct webhook | ... | Nyx relay webhook(channel-neutral)]
      ↓ (boundary:解析 + 认证)
IChannelTransport adapter → ChatActivity(带完整 ConversationReference.Scope)
      ↓
ConversationGAgent(单一主干,按 CanonicalKey 定位,事实源)
      ↓
IConversationTurnRunner.RunInboundAsync
      ├─ workflow resume(/approve /reject /submit)
      ├─ slash command flow(/daily-report 等,确定性短路)
      ├─ agent builder card flow
      └─ IConversationReplyGenerator(LLM fallback,通过 NyxIdChatGAgent 作为实现)
      ↓
ConversationTurnCompletedEvent → Projection
      ↓
IChannelOutboundPort.SendAsync
      └─ 具体平台实现内部按 transport kind 挑:direct API / Nyx channel-relay/reply

核心不变量

  • ChatActivity 是 inbound 唯一契约,transport 怎么进来是 adapter 私事
  • ConversationGAgent 是 inbound 事实源,没有第二条绕过它的路径
  • ConversationReference.Scope 是 chat_type 的权威表达,字符串 "p2p"/"group" 只在 runner → tool metadata 翻译时出现
  • Endpoint 只做 shim:auth → adapter.parse → dispatch ChatActivity → 202

命名空间对齐(Phase 0 前置)

先说结论:现有 `Aevatar.GAgents.Channel.Lark` 命名混淆了两个正交 concern——post-refactor 后 Lark 不是 channel,是 platform。要让代码模块语义 self-evident(不靠 ADR 解释),先把命名轴理清楚。

两个正交 concern

含义 数量
Channel(传输) 消息怎么进出 Aevatar 目前 1 个:NyxID relay
Platform(平台) 具体平台的渲染 / PII / streaming 规则 5 个:Lark / Telegram / Slack / Discord / WeChat

目录 + 命名空间对齐

```
agents/
├── channels/ ← 传输层(ingress + egress)
│ └── Aevatar.GAgents.Channel.NyxIdRelay/ ← 唯一的 Channel,5 个平台共享
│ ├── NyxIdRelayTransport.cs
│ ├── NyxIdRelayOutboundPort.cs
│ ├── NyxIdRelayCallbackPayload.cs
│ ├── NyxIdRelayJwtValidator.cs
│ └── ...

├── platforms/ ← 平台渲染 / PII / streaming
│ ├── Aevatar.GAgents.Platform.Lark/ ← rename 自 Channel.Lark
│ │ ├── LarkMessageComposer.cs
│ │ ├── LarkStreamingHandle.cs
│ │ ├── LarkPayloadRedactor.cs
│ │ └── LarkOutboundMessage.cs
│ ├── Aevatar.GAgents.Platform.Telegram/ ← (future, #262 应对齐)
│ ├── Aevatar.GAgents.Platform.Slack/ ← (future)
│ └── ...
└── ...
```

具体 rename 动作

  1. `Aevatar.GAgents.Channel.Lark` → `Aevatar.GAgents.Platform.Lark`(原 PR [Channel RFC] Implement Lark channel adapter #288 刚合的 project,csproj + namespace + 所有 reference 一起改)
  2. 目录 `agents/channels/Aevatar.GAgents.Channel.Lark/` → `agents/platforms/Aevatar.GAgents.Platform.Lark/`
  3. proposed `Channel.NyxRelay` 命名改为 `Channel.NyxIdRelay`,和 `NyxIdLLMProvider` / `NyxIdApiClient` / `NyxIdChatGAgent` 一系列 `NyxId` 前缀风格一致
  4. Telegram draft PR [Channel RFC] Add Telegram channel adapter #289 ([Channel RFC] Telegram adapter migration (shim → full TelegramChannelAdapter) #262) 同步对齐:如果已经用 `Channel.Telegram` 命名,同步改成 `Platform.Telegram`——越早定越少返工
  5. solution 文件分流:`aevatar.channel.slnf` 分成 `aevatar.channels.slnf`(transport)+ `aevatar.platforms.slnf`(渲染),不再混合
  6. 类名保留 platform 前缀(`LarkMessageComposer` / `TelegramMessageComposer`),不精简——`IMessageComposer` factory 场景下 consumer 需要 disambiguate

为什么不靠 ADR 解释

如果新 contributor 需要读 ADR-0013 才理解 "Channel.Lark 其实是 Lark 渲染层",说明命名失败。正确目标:

  • 看到 `channels/Channel.NyxIdRelay/` → 立即知道这是传输通道
  • 看到 `platforms/Platform.Lark/` → 立即知道这是 Lark 平台的渲染
  • "加 Telegram 支持从哪下手?" 的答案从目录结构直接看出:复制 `platforms/Platform.Lark/` 改成 Telegram 就对了

这是 CLAUDE.md "项目名=命名空间=目录语义" 原则的直接体现。ADR-0013 只需要解释 architecture decision,不需要用文字补回命名失败的语义。

具体改动清单

A. 新增 `agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/`(channel-neutral,唯一 transport)

  • `NyxIdRelayTransport.cs` — `IChannelTransport`,解析 NyxID `CallbackPayload` → `ChatActivity`
    • `ChannelId` = `payload.platform`("lark" / "telegram" / "slack" / ...)
    • `ConversationScope` 从 `conversation.type` exhaustive map:"private" → `DirectMessage`,"group" → `Group`,"channel" → `Channel`(proto 已有此 enum 值,见 `chat_activity.proto`),"device" → 单独处理或走 `DeviceEventEndpoints` 那条线
    • `RawPayloadBlobRef` 保留 hash-only 语义(`-raw:`),不承载 reply 凭证或结构化 metadata——这类需求走下方 `TransportExtras`
  • `NyxIdRelayOutboundPort.cs` — `IChannelOutboundPort`,`SendAsync` 调 `POST /api/v1/channel-relay/reply { message_id, content }`,内部 dispatch 到 per-platform `IMessageComposer`(按 `ChatActivity.Platform`)
  • `NyxIdRelayCallbackPayload.cs` — NyxID relay 契约 DTO(和 NyxID `CallbackPayload` struct 一一对应)
  • `NyxIdRelayConversationTypeMap.cs` — 4 个枚举值 → `ConversationScope` 的 exhaustive 映射
  • `NyxIdRelayAuthValidator.cs` — 多层认证校验,从 `Aevatar.GAgents.NyxidChat` 的 `NyxRelayJwtValidator` 演化而来:
    • JWT 校验(`X-NyxID-User-Token`,现有)
    • HMAC signature 校验(`X-NyxID-Signature`)—— Nyx callback 侧已在带(`backend/src/services/channel_relay_service.rs`),Aevatar 自己的 device 通道也把 HMAC 当硬边界(`DeviceEventEndpoints.cs`)。只留 JWT 校验 = 信任边界退化
    • `X-NyxID-Message-Id` replay 检查(和 RFC §5.7 dedup pipeline 打通)
    • 共享 secret 从 Aevatar secret store 读(ADR-0012 credential boundary 内);需先跑 `channel_relay_service.rs` 确认 HMAC input 格式 + 密钥分发路径
  • `ServiceCollectionExtensions.cs`

A.1 数据层扩展(Finding 2 + 5 引入的必要支撑)

`ChannelBotRegistration` state 扩展:当前 runner 按 `activity.Bot.Value` 查 registration(`LarkConversationTurnRunner.cs` + `ChannelBotRegistrationQueryPort.cs`),但 Nyx callback 给的是 `agent.api_key_id` / `conversation.id`,不是 Aevatar registration id,没映射表统一主干一进 runner 就失联。加:

  • proto 加字段:`string nyx_channel_bot_id = N;` `string nyx_agent_api_key_id = M;`(字段号取下一个可用)
  • `ChannelBotRegistrationProjector` 加基于 nyx identifiers 的二级索引
  • 新 query port `IChannelBotRegistrationQueryByNyxIdentityPort`:`GetByNyxAgentApiKeyIdAsync(...)` / `GetByNyxChannelBotIdAsync(...)`
  • runner 解析 registration 时优先走 nyx identifiers(从 `TransportExtras` 取,见下),fallback 到 `Bot.Value`

`ChatActivity` proto 加 `TransportExtras` 字段:`RawPayloadBlobRef` 只是 string hash,proto 里没有 blob store / read path 能 dereference。reply 凭证 / 平台 identifier 这类运行时数据流要独立字段:

```protobuf
message ChatActivity {
// ... existing fields ...
string raw_payload_blob_ref = N; // 保留,仅哈希引用(forensic)
TransportExtras transport_extras = M; // 新字段
}

message TransportExtras {
string nyx_message_id = 1; // reply 关联
string nyx_agent_api_key_id = 2; // registration lookup(见上)
string nyx_platform = 3; // composer dispatch
string nyx_conversation_id = 4; // Nyx 侧会话 id(原始)
// 未来其他 transport-specific 扩展
}
```

`RawPayloadBlobRef` 职责不变(forensic hash)。未来真要做 blob store 回查,另做 `IRawPayloadBlobStore` port + backing store。

决策点 1(Finding 4 修正):`conversation.type = "channel"`(Telegram 公告频道 / Slack public channel)业务语义

  • 选 A:视为 `Group`拒绝。proto 已经有 `ConversationScope.Channel`(`chat_activity.proto`);把 "channel" 压成 `Group` 是主动丢语义
  • 选 B:使用 `ConversationScope.Channel` + 修翻译链路
    • `NyxIdRelayConversationTypeMap` 的 "channel" → `ConversationScope.Channel`
    • runner / `BuildReplyMetadata` 的 `chat_type` 派生改为 exhaustive 覆盖全部 `ConversationScope` 值(当前 `LarkConversationTurnRunner.cs` 把除 `Group` 外一切都当 `p2p`,需修)
    • AgentBuilder slash 的 "`chat_type != p2p`" 硬检查改为 `Scope in {DirectMessage}` 才允许

B. Endpoint 瘦身

`agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs`

  • `HandleRelayWebhookAsync` 改成只做 auth + adapter dispatch + dispatch ChatActivity + 202,所有业务剥离
  • 删除:`RelayReplyAccumulator`、`FinalizeRelayReplyAsync`、`TryExtractLlmError`、`BuildRelayDiagnostic`、`ClassifyError`(迁到 runner/reply generator 层,或保留在 Studio stream endpoint 那条线但不再复用给 channel relay)
  • 删除:`RelayMessage` 及子类(迁到 `NyxRelayCallbackPayload`)
  • 最终 relay 相关代码完全搬离 NyxidChat project

`agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs`

  • `HandleCallbackAsync` 已经是 410 Gone 空壳,直接删

C. 主干编排统一

`agents/Aevatar.GAgents.ChannelRuntime/LarkConversationTurnRunner.cs`

  • 改名 `ChannelConversationTurnRunner`(对多平台通用,不带 "Lark" 前缀)
  • `IConversationReplyGenerator` 的 LLM 实现指到 `NyxIdChatGAgent`(走 IActorRuntime + subscribe),以后 relay 路径上的 LLM 调用也从这里走
  • `BuildReplyMetadata` 把 `ConversationReference.Scope` 翻译成 `ChannelMetadataKeys.ChatType` 字符串,下游 tool 正确拿到 `p2p` / `group`
  • 注入 channel context system message(platform / chat_type / sender / conversation),借用 `ConnectedServicesContextMiddleware` 机制做一个 `ChannelContextMiddleware`

`agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs`

  • 不改。它已经是正确的单一主干,只是目前生产上没人调。让 relay adapter 的 inbound 也落到它这里

D. LLM reply path(Finding 1 修正后的事件驱动形态)

原方案把"`IConversationReplyGenerator` 里 subscribe `TextMessageContentEvent` / `TextMessageEndEvent` 累积 reply"的 ambient-wait 反模式从 HTTP endpoint 搬进 actor turn——仍是反模式:actor turn 不允许 block 在 cross-actor event 上。正确形状是事件驱动三段式

```
① ConversationGAgent turn(瞬时完成):
runner 路由 inbound → 判定需要 LLM fallback →
emit NeedsLlmReplyEvent { correlation_id, conversation_ref, prompt, scope,
transport_extras }
→ commit state → turn 结束返回(不等 reply)

② LlmReplyGeneratorActor(subscribe 层):
订阅 NeedsLlmReplyEvent →
内部调 NyxIdChatGAgent 生成 reply →
emit LlmReplyReadyEvent { correlation_id, content, terminal_state }

③ OutboundDispatcher(可以是 ConversationGAgent 的第二个 handler):
订阅 LlmReplyReadyEvent →
调 IChannelOutboundPort.SendAsync(凭 transport_extras 里的 reply 凭证回写)
```

Phase 1 契约加

  • `NeedsLlmReplyEvent` proto
  • `LlmReplyReadyEvent` proto
  • `IConversationReplyGenerator` 接口语义从 "sync 生成 reply 返回字符串" 改为 "发布 `NeedsLlmReplyEvent` + correlation_id"
  • outbound dispatcher 接口 / 注册机制

UX 影响:从 "turn 同步等 LLM" 变为 "turn 结束后异步 reply 到达"。但当前 HTTP endpoint 已经是异步——`HandleRelayWebhookAsync` 返 202 然后靠 ambient TCS 等 reply。新模式只是把异步边界从 HTTP layer 搬到 actor event layer,本来就该这样,且符合 RFC §5.8 durable inbox 语义。

删除的旧逻辑:endpoint 里的 `RelayReplyAccumulator` / `FinalizeRelayReplyAsync` / `TryExtractLlmError` / `ClassifyError` 逻辑全部消掉,不在 runner 层复刻。

E. 文档 + ADR + 门禁

  • 新 ADR:`docs/decisions/0013-unified-channel-inbound-backbone.md`
    • Nyx relay 降级为 channel-neutral transport adapter
    • `ConversationGAgent` 是唯一 inbound 主干
    • supersede ADR-0011 里 "relay 直接到 NyxIdChat" 的隐含设计
  • 更新 `docs/canon/aevatar-channel-architecture.md`:inbound 拓扑图换成上述
  • 新增 CI 门禁:禁止 `IActorRuntime.CreateAsync` 出现在 channel relay/webhook 代码里(只允许 runner / reply generator / Studio 对话 endpoint 用)—— 防止 "HTTP endpoint 直连业务 actor" 反模式再长

F. 测试

  • `NyxRelayTransportTests`:payload → ChatActivity,覆盖 `conversation.type` 全部 4 个取值(private / group / channel / device)
  • `NyxRelayConversationTypeMapTests`:各 type → ConversationScope 映射
  • `NyxRelayOutboundPortTests`:SendAsync → 正确的 `POST /api/v1/channel-relay/reply` 请求
  • `ChannelConversationTurnRunnerTests`:relay-originated ChatActivity 走 slash flow 短路(`/daily-report` 在 p2p 下不落 LLM)
  • 端到端:relay webhook → 202 + 异步 outbound reply 经 Nyx channel-relay/reply endpoint,覆盖 slash / agent-builder / LLM fallback
  • 删除 `NyxIdChatEndpointsCoverageTests` 中 relay 业务逻辑部分,保留 Studio stream 相关
  • `ConversationGAgent` 现有测试覆盖补齐:relay-originated inbound dedup、ConversationTurnCompletedEvent 投影

分阶段落地

每步都能独立 `build/test` 过 + 可回滚:

  1. Phase 0(命名空间对齐):执行上节"命名空间对齐"里的 rename 动作——`Channel.Lark` → `Platform.Lark`,新建 `agents/platforms/` 目录,solution 文件分流。独立 PR,纯 rename 零业务变更,为后续 phases 让出干净命名空间(`Channel.` = 传输 / `Platform.` = 渲染)。也对齐 Telegram draft PR [Channel RFC] Add Telegram channel adapter #289 避免返工。
  2. Phase 1(契约)
    • `ChatActivity` proto 加 `TransportExtras` message(nyx_message_id / nyx_agent_api_key_id / nyx_platform / nyx_conversation_id)——Finding 5
    • `ChannelBotRegistration` proto 加 `nyx_channel_bot_id` / `nyx_agent_api_key_id` 字段 + projection 二级索引 + `IChannelBotRegistrationQueryByNyxIdentityPort`——Finding 2
    • `NeedsLlmReplyEvent` / `LlmReplyReadyEvent` proto 定义——Finding 1
    • transport-neutral `OutboundDeliveryContext` 抽象
    • outbound credential 方案待定:依赖 NyxID #469(short-lived per-callback reply-token)确认可行性——Finding 3,暂不在本 issue 做决定
  3. Phase 2(adapter + outbound)
    • 实现 `Aevatar.GAgents.Channel.NyxIdRelay` project(`NyxIdRelayTransport` / `NyxIdRelayOutboundPort` / `NyxIdRelayCallbackPayload`)
    • `NyxIdRelayAuthValidator`:JWT + HMAC + message-id replay 三重校验——Finding 6
    • endpoint 暂不切,只有测试覆盖
  4. Phase 3(runner 通用化 + 事件驱动 LLM reply)
    • runner 改名 `ChannelConversationTurnRunner`;`BuildReplyMetadata` 按 exhaustive `ConversationScope` 映射 `ChatType`——Finding 4
    • 移除原 `IConversationReplyGenerator` 同步语义,改为发布 `NeedsLlmReplyEvent` + correlation——Finding 1
    • 新 `LlmReplyGeneratorActor` 订阅 `NeedsLlmReplyEvent`,内部调 `NyxIdChatGAgent`,产出 `LlmReplyReadyEvent`
    • outbound dispatcher 订阅 `LlmReplyReadyEvent` → `IChannelOutboundPort.SendAsync`
    • runner 注入 channel context system message
  5. Phase 4(切流量):`HandleRelayWebhookAsync` 改为 shim(auth → transport.parse → dispatch → 202);删除 endpoint 里累积 / classify / 直发 reply 老逻辑;生产 on
  6. Phase 5(清理):删 `ChannelCallbackEndpoints.HandleCallbackAsync`(410 壳)、relay endpoint 里的业务残留、废弃测试;删 `Platform.Lark` 内已无用的 `LarkChannelAdapter.cs` / `LarkWebhookRequest.cs` / `LarkWebhookResponse.cs` / `LarkCredentialSnapshot.cs`(post-Phase 4 已无 ingress/egress 代码);更新 ADR / canon / CI 门禁

验证

CLAUDE.md 强制
```bash
dotnet build aevatar.slnx --nologo
dotnet test aevatar.slnx --nologo
bash tools/ci/architecture_guards.sh
bash tools/ci/workflow_binding_boundary_guard.sh
bash tools/ci/query_projection_priming_guard.sh
```

手工 E2E

  • Lark p2p 发 `/daily-report alice` → 确定性 slash flow 命中 → Nyx relay reply(不落 LLM)
  • Lark 群发 `/daily-report` → `AgentBuilderTool` 拿到 `chat_type=group` → 按契约拒绝并附建议 DM
  • Telegram p2p(未来)→ 同一条主干,不需要另写 runner/endpoint

工作量估计

4–6 个 PR 分阶段落地,每个 PR 不超过一个 Phase。

Codex adversarial review 响应(2026-04-23)

经独立 Codex adversarial review 挖出 6 条 findings,本 issue 已按其修正:

# Finding 严重度 本 issue 内的修正位置 状态
1 Phase D 把 LLM fallback 改成 actor turn 内 subscribe-wait = 反模式搬家 HIGH §D 整段重写为事件驱动三段式(`NeedsLlmReplyEvent` → `LlmReplyReadyEvent` → outbound);Phase 1 加相应 proto 定义;Phase 3 加 `LlmReplyGeneratorActor` ✅ 已修正
2 Nyx callback identity(`agent.api_key_id` / `conversation.id`)→ Aevatar `ChannelBotRegistration` 无映射,runner 失联 HIGH §A.1 加 `ChannelBotRegistration` proto 扩展 + projection 二级索引 + `IChannelBotRegistrationQueryByNyxIdentityPort`;Phase 1 承接 ✅ 已修正
3 Outbound credential 设计不成立(`NyxLarkProvisioningService` 只存 api_key id 不存 full_key,Nyx reply 按 agent identity 校验) HIGH NyxID #469 提议 per-callback short-lived reply-token(Aevatar 零 secret 持久化);Phase 1 留为待定,等 NyxID 侧确认可行性 🟡 pending
4 `conversation.type = "channel"` 选 A(flatten to Group)基于过时模型,proto 已有 `ConversationScope.Channel` MED 决策点 1 改为选 B + runner / `BuildReplyMetadata` exhaustive 映射 `ConversationScope` ✅ 已修正
5 `RawPayloadBlobRef` 只是 hash 字符串,扛不起 "reply metadata 回查" 职责 MED §A.1 加独立 `TransportExtras` 字段;`RawPayloadBlobRef` 保持 hash-only ✅ 已修正
6 入口 auth 漏了 Nyx HMAC signature / `X-NyxID-Message-Id` replay,信任边界退化 MED §A `NyxIdRelayJwtValidator` 演化为 `NyxIdRelayAuthValidator`:JWT + HMAC + replay 三重校验;Phase 2 承接 ✅ 已修正

Phase 1 不开工直到 Finding 3 解决——无正确 outbound credential 整个 backbone 是 "inbound 能收,outbound 发不出"。

相关 issue / PR

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions