症状
线上 Lark -> NyxID -> Aevatar 链路已经能收到 Lark 消息,NyxID 也能成功 forward 到 Aevatar,但 Aevatar 在 relay callback 认证阶段失败,导致 bot 不回复。
典型日志:
warn: Aevatar.NyxId.Chat.Relay[0]
Relay callback authentication failed: code=invalid_signature, detail=Relay callback signature verification failed.
同时观察到 channel-bot-registration projection scope watermark 提交 OCC:
fail: Aevatar.Foundation.Core.EventSourcing.EventSourcingBehavior[0]
Event sourcing commit failed. agentId=projection.durable.scope:channel-bot-registration:channel-bot-registration-store eventType=ProjectionScopeWatermarkAdvancedEvent version=4 result=failed errorType=InvalidOperationException
System.InvalidOperationException: Optimistic concurrency conflict: expected 4, actual 5
warn: Aevatar.CQRS.Projection.Core.Orchestration.ProjectionMaterializationScopeGAgent[0]
Projection scope observation handling failed. actorId=projection.durable.scope:channel-bot-registration:channel-bot-registration-store projectionKind=channel-bot-registration
System.InvalidOperationException: Optimistic concurrency conflict: expected 4, actual 5
还出现过 relay user token 过期日志:
Microsoft.IdentityModel.Tokens.SecurityTokenExpiredException: IDX10223: Lifetime validation failed.
当前判断
这不是单纯的 Lark webhook 问题。Lark ingress 和 NyxID forward 已经能工作,失败点在 Aevatar 的 NyxID relay callback 认证与短期 token 生命周期处理。
当前实现存在两个核心问题。
-
Aevatar 仍在生产 relay 链路里依赖 IAevatarSecretsStore 保存/读取 relay signing secret。
- provisioning 从 NyxID API key
full_key 计算 hash,然后写入 secrets store。
- callback 认证时再通过
registration -> credential_ref -> secrets store 读出该值,用它校验 X-NyxID-Signature。
- 这违反架构约束:Aevatar 生产环境不应该存储任何 secrets,
IAevatarSecretStore / IAevatarSecretsStore 不应参与生产 relay 认证。
-
这个约束违规已经在多 pod 部署里变成直接运行时故障。
AevatarSecretsStore 是节点本地 store,依赖 macOS Keychain aevatar-agent-framework 或 ~/.aevatar/masterkey.bin / ~/.aevatar/secrets.json。
- provisioning 命中 pod A 时,secret 只写到 pod A 本地。
- callback 命中 pod B 时,pod B 本地 secrets store 没有这条记录,
Get 返回空或解析失败。
- Aevatar 取不到 HMAC key,最终 callback 被拒为
invalid_signature。
- 因此 HMAC fallback 在多 pod 生产环境不是可靠兜底,只能视为单 pod / 本地 / 回滚 only 的安全阀。
-
NyxID callback payload 里的 reply_token 是短期、单次使用的 reply credential,但 Aevatar 现在会把它放进 ChatActivity.OutboundDelivery.ReplyAccessToken,随后 clone 到 NeedsLlmReplyEvent,并进入 conversation actor 的 event-sourced pending state。
- 这不再是“短暂存储在内存”。
- token 过期、重放、actor 恢复后复用都会造成 reply 失败或安全边界不清。
X-NyxID-User-Token 也不适合作为 callback 主认证:bot 收到消息时用户 session 不一定在线,user access token 本来就可能很快过期。它可以继续作为“后续代表用户调用 NyxID API”的 delegation token,但不应决定 inbound callback 是否可信。
修复目标
NyxID relay callback 认证不依赖 Aevatar 持久化 secret;relay reply token 只在一次 turn 的运行态中短暂存在,不进入 event store / actor persisted state / read model。
修复方案
1. 直接切到专用 callback JWT,不再用 HMAC 作为生产主链路
让 NyxID 在 forward callback 时新发一个专用 JWT,例如通过 X-NyxID-Callback-Token 传给 Aevatar。
建议 token 语义:
aud = "channel-relay/callback"。
- TTL 约 5 分钟。
- clock skew 明确为 60 秒,和 NyxID reply token 保持一致或更紧,但不要放大。
- RS256,由 NyxID 现有
jwt_keys 签发,Aevatar 通过 JWKS 验签。
- JWT claims 只放认证与绑定所需字段:
api_key_id、message_id、platform、body_sha256、exp、iat、jti。
- 不在 JWT 里重复放
correlation_id claim。correlation_id 是 callback payload 的业务字段;NyxID 设置 payload.correlation_id = token.jti,Aevatar 验证 payload.correlation_id == token.jti。
body_sha256 必须覆盖原始 callback body bytes,避免从 HMAC 切到 JWT 后丢掉 body 防篡改能力。
Aevatar 认证应 fail-fast,按下面顺序校验并产出一一对应的错误码:
| 顺序 |
校验 |
错误码 |
| 1 |
缺少 X-NyxID-Callback-Token |
callback_jwt_missing |
| 1 |
kid 在 JWKS 中不存在,且受限 refresh 后仍不存在 |
callback_jwt_kid_not_found |
| 1 |
JWT 签名无效 |
callback_jwt_signature_invalid |
| 1 |
issuer 与 NyxID discovery 不一致 |
callback_jwt_issuer_mismatch |
| 1 |
aud != channel-relay/callback |
callback_jwt_audience_mismatch |
| 1 |
exp/iat/nbf 超出 60 秒 clock skew 容忍 |
callback_jwt_lifetime_invalid |
| 2 |
api_key_id != payload.agent.api_key_id |
callback_jwt_api_key_id_mismatch |
| 2 |
message_id != payload.message_id 或 X-NyxID-Message-Id 不一致 |
callback_jwt_message_id_mismatch |
| 2 |
platform != payload.platform |
callback_jwt_platform_mismatch |
| 2 |
payload.correlation_id 缺失或不等于 token jti |
callback_jwt_correlation_id_mismatch |
| 3 |
SHA256(raw_body_bytes) != body_sha256 |
callback_jwt_body_hash_mismatch |
| 4 |
jti 或 message_id 已处理过 |
callback_jwt_replay_detected |
JWKS refresh 必须有冷却窗口,建议 10 秒,并且并发 kid miss 要 single-flight 合流,避免伪造 kid 打出 refresh 风暴。
X-NyxID-User-Token 不再作为 callback 主认证。若存在且未过期,可以作为 downstream proxy / approvals 等 user delegation token 使用;若缺失或过期,不应导致 callback 被拒绝,只影响后续需要用户身份的工具调用。
2. body_sha256 必须按原始字节校验
JWT 在 header 里,HTTP body 在 request stream 里。Aevatar 可以先校验 JWT 的签名、aud、issuer、lifetime,再读取 raw body 做 payload 一致性与 body hash 校验;读取 body 时必须保证下游反序列化还能拿到同一份 bytes。
实现要求:
- 不允许 trim、格式化或 re-serialize JSON 后再算 hash。
- NyxID 侧必须对实际发送到 wire 的 serialized body bytes 计算
body_sha256。
- Aevatar 侧必须对收到的 raw body bytes 计算 hash。
- 如果 ASP.NET Core 下游还需要读取 body,要么在入口 pre-read 成
byte[] / MemoryStream 并复用,要么显式 EnableBuffering() 后重置 stream position。
- 同一个逻辑 JSON,只要字节不同,例如多一个空格、字段顺序不同、末尾多一个
\n,就应该在 JWT body hash 校验阶段被拒绝。
3. relay reply token 放到 actor-owned 短期运行态,不做 singleton cache
不要新增中间层 INyxRelayReplyTokenCache 之类的 singleton messageId -> token 进程内映射,这会撞中间层状态约束,也会在 actor 迁移/换节点时天然 miss。
首选方案:
- 在
ChatConversationGAgent / ConversationGAgent 内维护非持久化、actor-owned runtime state,生命周期为一次 turn。
- key 使用 NyxID callback payload 中的
correlation_id,其值必须等于 callback JWT jti。
- 运行态 value 记录
reply_token、reply_message_id、expiry。
- turn 完成、发送成功、token consumed、超时、deactivate 时清理。
- actor 运行态丢失时,LLM reply 返回明确错误
reply_token_missing_or_expired,不降级到持久化 secret 或 API key。
如果确认 actor 在一次 turn 内也可能跨节点迁移,次选方案是使用抽象化分布式短期状态服务(Redis / Orleans transient grain 等),仍然不要在 ChannelRuntime 中维护 singleton map。
4. 收紧 proto/契约,避免 secret 字段继续泄露
OutboundDeliveryContext 只保留非 secret 事实,例如 reply_message_id 和 correlation_id。
chat_activity.proto 中现有 reply_access_token = 2 应明确 reserved 2; reserved "reply_access_token";,不要简单删除后回收字段号。
- 显式新增/使用
correlation_id 字段来定位 actor runtime 中的短期 token,不要依赖 message_id 一字段兼做所有语义。
NeedsLlmReplyEvent、conversation actor persisted state、read model 都不得包含 reply_token / access token / HMAC secret。
5. provisioning 路径同步瘦身
- 从 Lark Nyx provisioning 中移除对 NyxID API key
full_key 的读取、hash 和 _secretsStore.Set(...)。
- 删除
BuildRelayCredentialRef、relay CredentialRef 写入、NyxIdRelayRegistrationCredentialResolver 等只服务 HMAC secret 查找的生产代码。
ChannelBotRegisterCommand / ChannelBotRegistrationEntry / ChannelBotRegistrationDocument 里的 credential_ref 如果只剩历史 HMAC 用途,应删除并 reserve proto field;不要保留空壳兼容。
ServiceCollectionExtensions 中 ChannelRuntime 对 IAevatarSecretsStore 的默认注册应移走。
IAevatarSecretsStore 允许使用范围应收窄为白名单:
- 允许:
tools/Aevatar.Tools.Cli 用户本地 token / 本地配置。
- 允许:本地 dev / 本地集成测试显式 opt-in。
- 禁止:任何服务端 Host / ChannelRuntime / agent service 生产 DI 默认注册。
需要新增 CI 守卫:src/Aevatar.*Host* 与 agents/**/ServiceCollectionExtensions.cs 禁止出现 AddSingleton<IAevatarSecretsStore> / TryAddSingleton<IAevatarSecretsStore>,防止后续重新漏回生产链路。
6. 过渡策略
生产里已有带 credential_ref 的旧 ChannelBotRegistration。切换时需要避免“一上线就全量失败”。
feature flag 粒度应是 per-registration:
- 新注册 bot 一律 JWT-only,不再写
credential_ref,也不再写本地 secret。
- 历史注册按 registration 级 flag 决定是否允许临时 HMAC fallback。
- 可以保留一个全局 emergency kill switch,但不能用全局 flag 代替 per-registration 灰度。
建议顺序:
- NyxID 先上线 callback JWT,并在 forward 时同时携带旧
X-NyxID-Signature 与新 X-NyxID-Callback-Token。
- Aevatar 先上线 JWT 验证路径,并通过 per-registration feature flag 控制历史注册的 HMAC fallback。
- 灰度期间优先 JWT;JWT 缺失时短期允许 HMAC fallback,但它只适用于单 pod / 本地 / 回滚场景,不是多 pod 生产的可靠兜底。
- 确认线上 callback 都带 callback JWT 后,关闭 HMAC fallback。
- 最后删除 Aevatar HMAC secret 存储、
credential_ref、resolver 和相关 DI。
7. inbound replay、日志与指标
- inbound callback 幂等由 Aevatar 负责,至少按
message_id / callback JWT jti 去重,重复 callback 不应双写 conversation event,也不应触发两次 LLM reply。
- replay guard 状态应放在 actor runtime 或分布式短期状态服务,不要新增中间层 singleton map。
- 日志错误码必须和第 1 节的验证规则一一对应,避免继续全部压成
invalid_signature。
- 建议新增指标:
callback_jwt_validation_failures_total{reason=<error_code>},灰度阶段比只看日志更容易判断失败分布。
- 继续保留
reply_token_missing_or_expired 作为 outbound reply 阶段的明确错误,不要混到 callback JWT 认证错误里。
8. Projection OCC 验证
PR #365 / commit da69364c 已引入 EventStoreOptimisticConcurrencyException 并让 projection scope 对 OCC rethrow,以便 runtime retry。需要在最新主干上验证实际端到端语义,而不是只看日志。
验收测试要具体构造:
- 构造并发两条 observation 指向同一个
channel-bot-registration projection scope。
- 预期一条先提交成功,另一条遇到
EventStoreOptimisticConcurrencyException。
- runtime 自动 reload + retry 后,两条 observation 都最终物化到 read model。
- committed version 连续递增,无 gap。
- 不出现同一个旧
_currentVersion / 未清 pending event 上无限重试。
相关 PR / 外部协同
回归测试
需要新增/调整:
- callback JWT happy path:无
IAevatarSecretsStore 注册时,Aevatar 仍可通过 JWKS 验证 callback 并返回 202。
- callback JWT
kid miss 后 refresh JWKS,再验证成功/失败分支。
- JWKS refresh 有冷却窗口与 single-flight,并发 unknown kid 不会打爆 discovery/JWKS。
- callback JWT
aud 不匹配拒绝,且不继续做 body hash 等后续工作。
- callback JWT lifetime 边界:60 秒 clock skew 内按约定通过,超过后拒绝。
- callback JWT
api_key_id/message_id/platform 与 payload 不一致拒绝。
- payload
correlation_id 缺失或不等于 token jti 时拒绝。
body_sha256 不匹配拒绝。
- 收到相同逻辑 payload 但 raw body bytes 不完全一致,例如多余空格或末尾
\n,JWT 被拒。
- callback JWT expired / not-yet-valid 拒绝。
- 重复 callback 不双写 conversation event,不触发重复 LLM reply。
- per-registration feature flag:新注册 JWT-only,历史注册才允许短期 fallback。
reply_token 不出现在 event store payload / actor persisted state / read model 中。
- actor runtime token 命中时可正常调用
/api/v1/channel-relay/reply。
- actor runtime token missing/expired 时返回
reply_token_missing_or_expired,不尝试使用持久化 secret 或 API key。
callback_jwt_validation_failures_total{reason=<error_code>} 按错误原因递增。
channel-bot-registration projection OCC 并发 observation 测试通过,最终 read model 物化到最新 committed version。
- CI 守卫禁止服务端 Host / agents production DI 默认注册
IAevatarSecretsStore。
验收标准
- Aevatar 生产 relay 链路不再通过
IAevatarSecretsStore 保存或读取 NyxID relay signing secret。
- Aevatar callback 主认证使用 NyxID 专用 callback JWT + JWKS,不依赖 HMAC secret。
- callback JWT 使用 5 分钟左右 TTL 和明确的 60 秒 clock skew 容忍。
- callback JWT 绑定 byte-exact body hash,body bytes 被篡改时拒绝。
- JWT 中不重复放
correlation_id claim;payload correlation_id 明确等于 token jti。
- callback JWT 验证顺序、错误码、日志与指标原因值一一对应。
- JWKS kid miss refresh 有冷却窗口和并发合流。
reply_access_token proto 字段被 reserved,reply_token 不进入任何持久化 state / event / read model / proto transport contract。
- feature flag 是 per-registration;新注册 JWT-only,历史注册才允许短期 fallback。
- Lark -> NyxID -> Aevatar callback 在无本地 secret 的生产配置下认证通过并返回 202。
- Aevatar 能使用 actor-owned 短期 runtime token 调用 NyxID
/api/v1/channel-relay/reply 完成回复。
- token 过期、cache miss、JWT kid/aud/body hash/message replay 等错误日志可区分。
channel-bot-registration projection OCC 不会导致 read model 长期停在旧版本。
症状
线上
Lark -> NyxID -> Aevatar链路已经能收到 Lark 消息,NyxID 也能成功 forward 到 Aevatar,但 Aevatar 在 relay callback 认证阶段失败,导致 bot 不回复。典型日志:
同时观察到
channel-bot-registrationprojection scope watermark 提交 OCC:还出现过 relay user token 过期日志:
当前判断
这不是单纯的 Lark webhook 问题。Lark ingress 和 NyxID forward 已经能工作,失败点在 Aevatar 的 NyxID relay callback 认证与短期 token 生命周期处理。
当前实现存在两个核心问题。
Aevatar 仍在生产 relay 链路里依赖
IAevatarSecretsStore保存/读取 relay signing secret。full_key计算 hash,然后写入 secrets store。registration -> credential_ref -> secrets store读出该值,用它校验X-NyxID-Signature。IAevatarSecretStore/IAevatarSecretsStore不应参与生产 relay 认证。这个约束违规已经在多 pod 部署里变成直接运行时故障。
AevatarSecretsStore是节点本地 store,依赖 macOS Keychainaevatar-agent-framework或~/.aevatar/masterkey.bin/~/.aevatar/secrets.json。Get返回空或解析失败。invalid_signature。NyxID callback payload 里的
reply_token是短期、单次使用的 reply credential,但 Aevatar 现在会把它放进ChatActivity.OutboundDelivery.ReplyAccessToken,随后 clone 到NeedsLlmReplyEvent,并进入 conversation actor 的 event-sourced pending state。X-NyxID-User-Token也不适合作为 callback 主认证:bot 收到消息时用户 session 不一定在线,user access token 本来就可能很快过期。它可以继续作为“后续代表用户调用 NyxID API”的 delegation token,但不应决定 inbound callback 是否可信。修复目标
NyxID relay callback 认证不依赖 Aevatar 持久化 secret;relay reply token 只在一次 turn 的运行态中短暂存在,不进入 event store / actor persisted state / read model。
修复方案
1. 直接切到专用 callback JWT,不再用 HMAC 作为生产主链路
让 NyxID 在 forward callback 时新发一个专用 JWT,例如通过
X-NyxID-Callback-Token传给 Aevatar。建议 token 语义:
aud = "channel-relay/callback"。jwt_keys签发,Aevatar 通过 JWKS 验签。api_key_id、message_id、platform、body_sha256、exp、iat、jti。correlation_idclaim。correlation_id是 callback payload 的业务字段;NyxID 设置payload.correlation_id = token.jti,Aevatar 验证payload.correlation_id == token.jti。body_sha256必须覆盖原始 callback body bytes,避免从 HMAC 切到 JWT 后丢掉 body 防篡改能力。Aevatar 认证应 fail-fast,按下面顺序校验并产出一一对应的错误码:
X-NyxID-Callback-Tokencallback_jwt_missingkid在 JWKS 中不存在,且受限 refresh 后仍不存在callback_jwt_kid_not_foundcallback_jwt_signature_invalidissuer与 NyxID discovery 不一致callback_jwt_issuer_mismatchaud != channel-relay/callbackcallback_jwt_audience_mismatchexp/iat/nbf超出 60 秒 clock skew 容忍callback_jwt_lifetime_invalidapi_key_id != payload.agent.api_key_idcallback_jwt_api_key_id_mismatchmessage_id != payload.message_id或X-NyxID-Message-Id不一致callback_jwt_message_id_mismatchplatform != payload.platformcallback_jwt_platform_mismatchpayload.correlation_id缺失或不等于 tokenjticallback_jwt_correlation_id_mismatchSHA256(raw_body_bytes) != body_sha256callback_jwt_body_hash_mismatchjti或message_id已处理过callback_jwt_replay_detectedJWKS refresh 必须有冷却窗口,建议 10 秒,并且并发 kid miss 要 single-flight 合流,避免伪造 kid 打出 refresh 风暴。
X-NyxID-User-Token不再作为 callback 主认证。若存在且未过期,可以作为 downstream proxy / approvals 等 user delegation token 使用;若缺失或过期,不应导致 callback 被拒绝,只影响后续需要用户身份的工具调用。2. body_sha256 必须按原始字节校验
JWT 在 header 里,HTTP body 在 request stream 里。Aevatar 可以先校验 JWT 的签名、aud、issuer、lifetime,再读取 raw body 做 payload 一致性与 body hash 校验;读取 body 时必须保证下游反序列化还能拿到同一份 bytes。
实现要求:
body_sha256。byte[]/MemoryStream并复用,要么显式EnableBuffering()后重置 stream position。\n,就应该在 JWT body hash 校验阶段被拒绝。3. relay reply token 放到 actor-owned 短期运行态,不做 singleton cache
不要新增中间层
INyxRelayReplyTokenCache之类的 singletonmessageId -> token进程内映射,这会撞中间层状态约束,也会在 actor 迁移/换节点时天然 miss。首选方案:
ChatConversationGAgent/ConversationGAgent内维护非持久化、actor-owned runtime state,生命周期为一次 turn。correlation_id,其值必须等于 callback JWTjti。reply_token、reply_message_id、expiry。reply_token_missing_or_expired,不降级到持久化 secret 或 API key。如果确认 actor 在一次 turn 内也可能跨节点迁移,次选方案是使用抽象化分布式短期状态服务(Redis / Orleans transient grain 等),仍然不要在 ChannelRuntime 中维护 singleton map。
4. 收紧 proto/契约,避免 secret 字段继续泄露
OutboundDeliveryContext只保留非 secret 事实,例如reply_message_id和correlation_id。chat_activity.proto中现有reply_access_token = 2应明确reserved 2; reserved "reply_access_token";,不要简单删除后回收字段号。correlation_id字段来定位 actor runtime 中的短期 token,不要依赖message_id一字段兼做所有语义。NeedsLlmReplyEvent、conversation actor persisted state、read model 都不得包含reply_token/ access token / HMAC secret。5. provisioning 路径同步瘦身
full_key的读取、hash 和_secretsStore.Set(...)。BuildRelayCredentialRef、relayCredentialRef写入、NyxIdRelayRegistrationCredentialResolver等只服务 HMAC secret 查找的生产代码。ChannelBotRegisterCommand/ChannelBotRegistrationEntry/ChannelBotRegistrationDocument里的credential_ref如果只剩历史 HMAC 用途,应删除并 reserve proto field;不要保留空壳兼容。ServiceCollectionExtensions中 ChannelRuntime 对IAevatarSecretsStore的默认注册应移走。IAevatarSecretsStore允许使用范围应收窄为白名单:tools/Aevatar.Tools.Cli用户本地 token / 本地配置。需要新增 CI 守卫:
src/Aevatar.*Host*与agents/**/ServiceCollectionExtensions.cs禁止出现AddSingleton<IAevatarSecretsStore>/TryAddSingleton<IAevatarSecretsStore>,防止后续重新漏回生产链路。6. 过渡策略
生产里已有带
credential_ref的旧ChannelBotRegistration。切换时需要避免“一上线就全量失败”。feature flag 粒度应是 per-registration:
credential_ref,也不再写本地 secret。建议顺序:
X-NyxID-Signature与新X-NyxID-Callback-Token。credential_ref、resolver 和相关 DI。7. inbound replay、日志与指标
message_id/ callback JWTjti去重,重复 callback 不应双写 conversation event,也不应触发两次 LLM reply。invalid_signature。callback_jwt_validation_failures_total{reason=<error_code>},灰度阶段比只看日志更容易判断失败分布。reply_token_missing_or_expired作为 outbound reply 阶段的明确错误,不要混到 callback JWT 认证错误里。8. Projection OCC 验证
PR #365 / commit
da69364c已引入EventStoreOptimisticConcurrencyException并让 projection scope 对 OCC rethrow,以便 runtime retry。需要在最新主干上验证实际端到端语义,而不是只看日志。验收测试要具体构造:
channel-bot-registrationprojection scope。EventStoreOptimisticConcurrencyException。_currentVersion/ 未清 pending event 上无限重试。相关 PR / 外部协同
aud=channel-relay/callbackcallback JWT,可复用现有jwt_keys/ JWKS 基础设施。X-NyxID-Callback-Token、CallbackPayload.correlation_id、payload.correlation_id = token.jti、60 秒 clock skew、byte-exactbody_sha256、临时双发 HMAC + callback JWT。回归测试
需要新增/调整:
IAevatarSecretsStore注册时,Aevatar 仍可通过 JWKS 验证 callback 并返回 202。kidmiss 后 refresh JWKS,再验证成功/失败分支。aud不匹配拒绝,且不继续做 body hash 等后续工作。api_key_id/message_id/platform与 payload 不一致拒绝。correlation_id缺失或不等于 tokenjti时拒绝。body_sha256不匹配拒绝。\n,JWT 被拒。reply_token不出现在 event store payload / actor persisted state / read model 中。/api/v1/channel-relay/reply。reply_token_missing_or_expired,不尝试使用持久化 secret 或 API key。callback_jwt_validation_failures_total{reason=<error_code>}按错误原因递增。channel-bot-registrationprojection OCC 并发 observation 测试通过,最终 read model 物化到最新 committed version。IAevatarSecretsStore。验收标准
IAevatarSecretsStore保存或读取 NyxID relay signing secret。correlation_idclaim;payloadcorrelation_id明确等于 tokenjti。reply_access_tokenproto 字段被 reserved,reply_token不进入任何持久化 state / event / read model / proto transport contract。/api/v1/channel-relay/reply完成回复。channel-bot-registrationprojection OCC 不会导致 read model 长期停在旧版本。