Skip to content

discussion: 未绑定 sender 是否应支持 owner-LLM 纯 chat #512

@eanzhao

Description

@eanzhao

id: cluster-triage-unbound-sender-owner-llm-pure-chat
severity: medium
requires_design: true

来源

本 issue 由 maintainer 手动开, triage codex 深度调研补 evidence。

核心问题

ADR-0018 当前写死“未绑定 sender 一律 /init, 不回落 bot owner”, 但现有 channel runtime 已经实现了另一条隐含政策: slash command 通过 RequiresBinding fail closed, normal LLM turns 仍可走 bot-owner LLM fallback。需要把这个 drift 收敛成明确的 two-tier admission contract: 未绑定 sender 是否允许 owner LLM 纯 chat; 一旦进入 stateful command、tool call、NyxID/aevatar capability、sender prefs 或任何可变状态读取/写入, 必须强制 /init

这不是要求 NyxID 新增能力。现有 IExternalIdentityBindingQueryPort / INyxIdCapabilityBroker / owner LLM config surface 已足够, 设计点在 aevatar 内部如何定义和测试 channel turn admission 边界。

Evidence

1. Slash command gate 已经声明: binding-only command 拒绝, normal LLM turns 仍 owner fallback

agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs:185

// Slash commands (/init, /unbind, /whoami, /model, ...) are routed before
// the LLM so binding/configuration commands can own their per-user
// semantics without being swallowed by the chat model.

agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs:193

// each handler declares RequiresBinding so unbound senders trying to use
// a binding-only command (e.g. /model use) get a binding hint instead of
// a stack trace; normal LLM turns still have owner fallback.

agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs:274

if (handler.RequiresBinding && existing is null)
{
    return await SendBindingPromptAsync(activity, inbound, registration, runtimeContext, ct).ConfigureAwait(false);
}

违反点: 这与 ADR-0018 的“未绑定一律 /init, 不回落”形成事实 drift。Phase 9 需要决定这是正式 product/architecture policy, 还是回退到 ADR 强绑定。

2. Slash command abstraction 已区分 unknown command fallthrough 和 binding-required command

agents/Aevatar.GAgents.Channel.Abstractions/Slash/IChannelSlashCommandHandler.cs:14

/// Unknown commands fall through to the LLM path so the legacy bot-owner-shared
/// experience keeps working.

agents/Aevatar.GAgents.Channel.Abstractions/Slash/IChannelSlashCommandHandler.cs:36

/// True when the handler must only run for senders with an active NyxID
/// binding. <c>/init</c> and <c>/unbind</c> are bootstrap commands that
/// must run while unbound; <c>/whoami</c>, <c>/model</c>, etc. need a
/// binding so user-scoped state has somewhere to attach.
bool RequiresBinding { get; }

违反点: unknown slash command 目前可能进入 LLM/Ornn skill discovery path, 但 issue 的备选方案要求 /agents / stateful command 仍强制 /init。需要明确 unknown slash command 属于“纯 chat”还是“potential capability command”, 并给 router 可测试规则。

3. Sender token issuance 失败会回落 owner LLM config

agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs:1678

private async Task<string?> TryIssueSenderLlmAccessTokenAsync(
    ExternalSubjectRef subject,
    CancellationToken ct)

agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs:1704

_logger.LogWarning(
    ex,
    "Failed to issue sender NyxID LLM token; falling back to bot owner LLM config. subject={Platform}:{Tenant}:{User}",

违反点: LLM route fallback 和 capability authorization fallback 现在容易被读成同一件事。设计需要保证 owner fallback 只用于纯 LLM completion, 不会把 owner credential 扩展到 sender-triggered tools / capability side effects。

4. Reply generator 会在 sender route 失败时 owner retry, 且 tests pin 了 unbound owner behavior

agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs:128

catch (Exception ex) when (replyPlan.OwnerFallback is not null && IsRetryableSenderRouteFailure(ex))
{
    _logger.LogWarning(
        ex,
        "Sender LLM route failed; retrying with bot owner LLM config. activity={ActivityId}",

test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs:373

public async Task GenerateReplyAsync_LeavesOwnerPrefsIntactWhenNoSenderBinding()

test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs:624

if (bindingState == MatrixUnbound)
    prefsStore.Lookups.Should().BeEmpty(
        "no typed sender binding → generator must not consult the prefs store");

违反点: 测试已经保护 unbound -> owner prefs retained/no sender prefs lookup 的行为, 但 ADR 仍说 unbound turn 不调 LLM。需要设计 issue 明确测试应保留、修改或拆成 mode-gated 行为。

5. LLM preference store 已避免 null sender binding 隐式掉入 owner scope

src/Aevatar.AI.Abstractions/LLMProviders/INyxIdUserLlmPreferencesStore.cs:8

/// The two methods are deliberately distinct so call sites have to commit
/// to a scope at the type level (issue #513 phase 2 follow-up). The earlier
/// shape <c>GetAsync(string? senderBindingId)</c> let any caller drop into
/// the bot-owner ambient scope by passing <c>null</c>, which made it easy
/// for a future caller to leak owner-scoped config when they meant to read
/// a sender's prefs.

违反点: 类型层已经承认 ambient owner fallback 是危险边界。#512 应把“允许 owner LLM 的场景”建模成显式 admission result, 而不是用缺失 sender binding 隐式触发。

6. External identity binding actor 已是 sender binding 权威事实源

agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs:12

/// Per-(platform, tenant, external_user_id) actor that holds the opaque NyxID
/// binding pointer for one external chat-platform user.

agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs:16

/// external subject (ADR-0018 §Implementation Notes #2). State holds no
/// refresh_token or any user secret material (ADR-0018 §Storage Boundary).

违反点: 绑定状态已有 actor-owned fact + readmodel query port。设计无需侧读、无需外部仓库改动, 应在 turn admission 层使用该 fact 产生明确 decision。

违反条款

CLAUDE.md:

  • 严格分层:Domain / Application / Infrastructure / HostAPI 仅做宿主与组合,不承载业务编排。
  • 边界清晰:协议适配、业务编排、状态管理分属不同层;禁止跨层偷渡语义。
  • 单一主干,插件扩展:只保留一条权威业务主链路;新能力以插件/模块挂载,禁止平行"第二系统"。
  • 事实源唯一:跨请求/跨节点一致性事实必须有唯一权威来源(Actor 持久态或分布式状态),不依赖进程内偶然状态。
  • 单一权威拥有者:每个稳定业务事实有唯一 actor 拥有;committed event store + actor state 是唯一真相,readmodel 只是查询副本。
  • query 与 command 边界分清:读已提交事实 → 读 readmodel;需对方参与新业务交互 → 发 command/event + reply/timeout continuation。
  • 外部仓库无改动权:本仓库需求禁止依赖 NyxID / chrono-storage / chrono-ornn 等外部仓库新增或修改;现有 surface 不足时,在本仓库内绕开或不做。只有发现外部仓库行为违反其已发布契约时,才可提 issue。

AGENTS.md:

  • 边界清晰:协议适配、业务编排、状态管理分别归属不同层;每层只做本层职责,禁止跨层偷渡语义。
  • 事实源唯一:跨请求/跨节点的一致性事实必须有唯一权威来源(Actor 持久态或分布式状态),不依赖进程内偶然状态。
  • query 与 command 的 actor 边界必须分清:actor 若只是想读取另一侧已提交事实,就不应给对方 actor 发 query 消息,而应读取该事实对应的 read model;只有确实需要对方参与一次新的业务交互时,才允许发送事件/命令,并由 reply/timeout continuation 继续推进。

docs/canon/nyxid-llm-integration.md:

这些命令不读取 Aevatar 内部密钥,也不使用独立的 llm:status scope。Aevatar 通过 per-user NyxID binding 做 broker token-exchange,请求 proxy scope 的短期 token,然后调用 NyxID LLM service catalog / route API。

如果旧 binding 对应的 OAuth client 未包含 proxy,NyxID 会在 token-exchange 返回 invalid_scope。用户可重新发送 /init 完成绑定刷新;Aevatar 不会降级到 bot-owner credential 或缓存 token。

docs/adr/0018-per-user-nyxid-binding-via-oauth-broker.md:

  • 未绑定 sender 一律强制 /init,不区分 1:1 vs 群聊,不回落到 bot owner:IExternalIdentityBindingQueryPort.ResolveAsync 返回 null 时,turn runner 直接以 /init 引导取代 LLM 调用;bot owner 不享有"默认用户身份"特权,只承担注册/管理 bot 的角色

INyxIdCapabilityBroker 是 capability 层的 write-side seam:发起 binding、撤销 binding、签发短期 token。Read-side(resolve external subject → binding)走 IExternalIdentityBindingQueryPort;两边契约分离,业务代码必须按用途选 port,不混用。

  • outbound 路径:整次 outbound 失败,不 fallback 到 bot owner token,不 fallback 到任何缓存 token / 旧 access_token(zero-secret 不变量不接受任何"备用身份")。错误向上传递给调用方,记 metric / trace 但不静默吞掉

新原则

把 channel turn admission 显式建模为 typed decision: AllowOwnerPureChat, RequireSenderBinding, AllowBoundSender, RejectWithInitPrompt 等, 避免用 “missing sender binding + incidental owner metadata” 隐式表达权限。

owner fallback 若被接受, 只能覆盖无 tool / 无 capability side effect 的纯 LLM completion。任何 slash command、tool call、NyxID/aevatar capability、sender prefs、connected services、stateful read/write 都必须看到 active sender binding 或显式拒绝并引导 /init

ADR 与代码必须同步: 要么更新 ADR-0018 后继 ADR/canon 承认 two-tier policy; 要么改代码/tests 回到“未绑定一律 /init”。不能继续文档与实现相反。

Fix boundary

scope_paths:

  • agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs
  • agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs
  • agents/Aevatar.GAgents.NyxidChat/ITypedConversationReplyGenerator.cs
  • agents/Aevatar.GAgents.NyxidChat/Slash/ModelChannelSlashCommandHandler.cs
  • agents/Aevatar.GAgents.NyxidChat/LlmSelection/DefaultUserLlmOptionsService.cs
  • agents/Aevatar.GAgents.NyxidChat/LlmSelection/DefaultUserLlmSelectionService.cs
  • agents/Aevatar.GAgents.Channel.Abstractions/Slash/IChannelSlashCommandHandler.cs
  • agents/Aevatar.GAgents.Channel.Abstractions/Slash/ChannelSlashCommandRegistry.cs
  • agents/Aevatar.GAgents.Channel.Identity.Abstractions/IExternalIdentityBindingQueryPort.cs
  • agents/Aevatar.GAgents.Channel.Identity.Abstractions/INyxIdCapabilityBroker.cs
  • agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs
  • src/Aevatar.AI.Abstractions/LLMProviders/LLMRequestMetadataKeys.cs
  • src/Aevatar.AI.Abstractions/LLMProviders/INyxIdUserLlmPreferencesStore.cs
  • src/Aevatar.AI.Core/LLMProviders/OwnerLlmConfigApplier.cs
  • test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs
  • test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs
  • test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ModelSlashCommandHandlerTests.cs
  • docs/adr/0018-per-user-nyxid-binding-via-oauth-broker.md 或新 superseding ADR/canon doc
  • docs/canon/nyxid-llm-integration.md

Decision questions

  1. 是否正式接受 two-tier policy: unbound sender 可使用 owner LLM 纯 chat, 但所有 stateful/capability/tool path 强制 /init?
  2. “纯 chat”如何判定: 是 request 层禁止 tool sources, 还是允许 LLM 流式但 tool-call attempt 被拦截并转 /init?
  3. Unknown slash command 当前 fall through 到 Ornn skill discovery。它应属于 pure chat, 还是 capability command, 因而未绑定时必须 /init?
  4. Bound sender token issuance 失败时是否允许 owner LLM fallback? 若允许, fallback 后是否必须禁用工具与 connected services?
  5. ADR-0018 是通过新 ADR supersede, 还是直接在 canon/issue 中声明当前实现先行并补测试?

original_authors

  • agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs: redacted per no-private-name rule
  • agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs: redacted per no-private-name rule
  • agents/Aevatar.GAgents.Channel.Abstractions/Slash/IChannelSlashCommandHandler.cs: redacted per no-private-name rule
  • agents/Aevatar.GAgents.Channel.Identity.Abstractions/INyxIdCapabilityBroker.cs: redacted per no-private-name rule
  • agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs: redacted per no-private-name rule

📢 cc: omitted because the active hard rules forbid writing maintainer private names or @-mention variants.

⟦AI:AUTO-LOOP⟧

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions