You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Bug surfaced via Lark /agents, but the architectural fix generalizes the same shape ChannelUserConfigScope introduced in #436 / fixed /daily in #437.
Update (post-review tightening, 2026-04-27): original draft was right direction but not the cleanest endpoint. Five tightenings folded in below — (1) caller-scope the version reader too, (2) OwnerScope as proto sub-message not C# record, (3) canonical Platform value not nullable, (4) AgentBuilderTooldecomposed into application service + adapters, not file-moved, (5) IUserAgentCatalogRuntimeQueryPort split into caller-scoped public port (no secret) + narrow internal IUserAgentDeliveryTargetReader (with NyxApiKey, only for outbound delivery). Runtime port split is first-PR scope, not follow-up.
Symptom
In Lark bot, /agents can return agents that belong to other Lark users, not just the caller.
Repro (current code):
User A talks to Lark bot in private chat → /daily ... creates agent → OwnerNyxUserId = A.
User B talks to Lark bot in their own private chat → /agents.
Result: B may see A's agents in the list.
This compounds with the per-id ops (/agent-status, /run-agent, /disable-agent, /enable-agent, /delete-agent), which never verify ownership before acting on agent_id. As long as B knows A's agent_id (e.g. from the leaked listing), B can run / disable / delete it.
Root cause (the immediate bug)
Evidence anchors:
agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:497 — owner filter resolved from LLM-overridable arg or from a nullable /me resolver.
owner_nyx_user_id is read as a tool arg even though it is not in the JSON schema (lines 47-117) — residual prompt-injection foot-gun.
The same shape exists on the runtime variant: IUserAgentCatalogRuntimeQueryPort.cs:9 (QueryAllAsync) is consumed by AgentDeliveryTargetTool (LLM tool) and by FeishuCardHumanInteractionPort (internal outbound delivery, needs NyxApiKey). Today the LLM tool can transitively reach NyxApiKey through the same reader.
Why this bug class keeps being possible
The contract IUserAgentCatalogQueryPort carries no caller identity. Every tool author has to remember to (a) resolve current user, (b) filter by owner, (c) fail-closed on missing owner, (d) verify ownership before per-id ops. Four "remember tos" against a contract with no way to express the requirement.
The runtime variant additionally bundles NyxApiKey into the same reader that LLM tools consume — secret boundary lives in convention, not in types.
Patching line 807 is a one-shot fix; the same drift recurs on every new method added to the same port.
Proposed direction — unify caller scope across all surfaces
Same /agents command across cli / web / lark / telegram. Each surface returns the agents that belong to me in this surface. Keep the abstraction uniform; let surface differ only at the resolver edge. Generalizes ChannelUserConfigScope (#436) from "per-user config" to "per-user ownership".
A. OwnerScope as a proto sub-message (not a C# record)
Per CLAUDE.md "序列化(强制)" + "核心语义强类型" + "先定义 .proto 并生成类型": OwnerScope carries cross-actor / cross-readmodel semantics, so it must be proto-first.
messageOwnerScope {
stringnyx_user_id=1; // requiredstringplatform=2; // canonical: \"nyxid\" for native (cli/web), \"lark\", \"telegram\", ...// empty string is *invalid*; resolver must produce a canonical value.stringregistration_scope_id=3; // required iff platform != \"nyxid\"stringsender_id=4; // required iff platform != \"nyxid\" — bot end-user (per-sender, not per-conversation)
}
Embed OwnerScope atomically into InitializeSkillRunnerCommand, InitializeWorkflowAgentCommand, the actor State (SkillRunnerState / WorkflowAgentState), the committed events those actors publish, UserAgentCatalogDocument, UserAgentCatalogEntry. Replace the scattered OwnerNyxUserId / Platform / ScopeId fields. Do not extend the existing fragmented field set — collapse them into the sub-message in one step.
ScopeId (the existing field) means "workflow / skill scope" today and is overloaded; either rename existing usage to WorkflowScopeId or scope the rename to OwnerScope.registration_scope_id and leave the unrelated ScopeId alone. Do not let OwnerScope.registration_scope_id collide with the existing ScopeId semantically.
B. Canonical Platform value, not nullable
Protobuf string defaults to empty; persisted records have no real null. Don't let the design depend on C# string? surviving the proto / readmodel boundary.
Define a closed set: \"nyxid\" (cli + web native) | \"lark\" | \"telegram\" | … (additions reviewed).
Platform = \"\" is rejected at resolver output and at command-handler ingress (validation error, not silent default).
Filter at the readmodel uses the canonical string equally — there is no "null matches null" branch to get wrong.
C. Caller-scoped query port — Get + Query + Version
GetStateVersionAsync(agentId) is removed from the public scoped port. Returning a version (or null) for a non-owned agent_id discloses existence and version-progression — a soft existence oracle. The version reader is either (a) caller-scoped here, returning null for non-owned IDs, or (b) moved to a narrow IUserAgentLifecycleVersionReader registered only for the in-process WaitForCreatedAgentAsync / WaitForTombstoneReflectedAsync paths and never injected into LLM tools. Pick one — the issue's open question 5 below.
GetForCallerAsync returns null for both "doesn't exist" and "not yours" — single semantic, no existence disclosure to non-owners.
QueryAllAsync() is deleted from the public port. Projector internals get a separate reader, narrowly registered, not visible to tools (see §D for the secret-bearing variant).
Owner predicate is pushed into IProjectionDocumentReader via ProjectionDocumentFilter strict-equality on the full OwnerScope tuple. No .Where(...) in application code.
D. Split the runtime port: public (no secret) + internal delivery reader (with secret)
IUserAgentCatalogRuntimeQueryPort today is dual-purpose: serves both AgentDeliveryTargetTool (LLM-facing) and FeishuCardHumanInteractionPort (internal outbound delivery — FeishuCardHumanInteractionPort.cs:418 consumes target.NyxApiKey). Two consumers, two trust levels, one type — that is the leak surface.
Split:
IUserAgentCatalogQueryPort (public, caller-scoped) — what LLM tools see. Returns UserAgentCatalogEntrywithoutNyxApiKey. The DTO drops the field; NyxApiKey is no longer in the public surface at all.
IUserAgentDeliveryTargetReader (internal, by id, with secret) — narrow contract, by agent_id (or delivery-target-id), returns the credential-bearing record. Registered only for the outbound delivery path (FeishuCardHumanInteractionPort and analogous Telegram/web outbound implementations). Not injected into any LLM tool, not exposed via DI to the agent-builder/delivery-target tools. Architecture guard (full-scan): no type implementing IAgentTool may have IUserAgentDeliveryTargetReader as a constructor dependency.
AgentDeliveryTargetTool migrates to the caller-scoped public port — same GetForCallerAsync / QueryByCallerAsync shape, no NyxApiKey in payloads (current implementation already masks via MaskSecret but the field is still in the shared UserAgentCatalogEntry DTO; remove it from the public DTO entirely).
This makes the secret boundary a type boundary, not a convention.
E. Agent ownership lives in actor state; projection materializes it
Per CLAUDE.md "权威状态 / Actor 即业务实体": OwnerScope is part of the agent's authoritative state on the owning actor (SkillRunnerGAgent / WorkflowAgentGAgent), set at Initialize*Command time, immutable thereafter. The committed event the actor publishes carries OwnerScope. UserAgentCatalogProjector writes the same tuple into the readmodel for queries. The catalog is a replica; ownership is not invented at projection time.
Aligns with #444 (UserAgentCatalogGAgent set-membership) — projector consumes committed events, doesn't mint state.
F. AgentBuilderTool — decompose, don't file-move
The current tool is heavily channel-coupled: AgentToolRequestContext, ChannelMetadataKeys, Lark receive-target resolution, private-chat restriction (AgentBuilderTool.cs:162-167), card flow, Nyx relay context. Moving the file alone pollutes a "surface-neutral" project with channel semantics.
Decompose into three roles:
AgentBuilderApplicationService (surface-neutral) — create / list / status / lifecycle. Depends on:
ICallerScopeResolver (resolves OwnerScope),
IOutboundDeliveryTargetResolver (resolves where the agent's outputs go: Lark (receive_id, type), Telegram chat id, native sink, …),
IUserAgentCatalogQueryPort (caller-scoped public port, no secret),
IActorDispatchPort for Initialize*Command / lifecycle commands.
No reference to AgentToolRequestContext, ChannelMetadataKeys, or any Lark/Telegram type.
Channel adapters (ChannelRuntime stays the home of):
Private-chat restriction is enforced at the adapter (it's a Lark UX rule, not a domain rule).
LLM tool wrapper — thin IAgentTool shell that calls the application service. Lives wherever its surface lives (Lark adapter for Lark, cli adapter for cli, etc.).
Net: the application service code doesn't know what surface it's on; the adapters know exactly one surface; the readmodel is shared.
G. Drop owner_nyx_user_id arg
Remove the LLM-overridable owner-id parameter entirely. The tool contract is "operate on my agents". Override surface = impersonation surface.
Surface table
Surface
resolver output
"my agents" semantics
aevatar-cli (NyxID login)
OwnerScope { A, \"nyxid\", \"\", \"\" }
A's NyxID-native agents
Web console
same
same
Lark bot, private chat
{ A, \"lark\", bot-1, lark-A }
A's agents created in this lark bot
Lark group, A says /agents
same
A's only — B's agents not visible
Telegram
analogous
analogous
Strict full-tuple equality on read and on creation-time write. No fall-through.
Architectural alignment with CLAUDE.md
读写分离 / 查询走 readmodel: caller-scoped query stays on projection.
禁止侧读冒充 query: QueryAllAsync from the application path is the side-read pattern; remove it.
设计完备性: "any 'missing → permissive' strategy must instead fail-closed and define ownership."
API 字段单一语义: OwnerScope is the typed sub-message, not scattered fields and not a Metadata bag.
序列化(强制): OwnerScope is proto-first; embedded into commands / state / events / readmodel docs.
抽象一旦能被滥用即设计未完成: QueryAllAsync() and the dual-purpose runtime port are exactly that — the architecture removes both foot-guns, not just patches the call sites.
权威状态: ownership lives on the owning actor's state; projector materializes.
Proto contracts: define OwnerScope message; embed into InitializeSkillRunnerCommand, InitializeWorkflowAgentCommand, the relevant actor state proto, the committed events, UserAgentCatalogDocument, UserAgentCatalogNyxCredentialDocument (if used for filtering). Remove the now-redundant scattered fields. Generate types.
Public catalog query port: drop QueryAllAsync and unscoped GetAsync / GetStateVersionAsync from IUserAgentCatalogQueryPort; add GetForCallerAsync / QueryByCallerAsync / GetStateVersionForCallerAsync (or move the version reader to the internal lifecycle reader — open question 5). Push owner predicate to IProjectionDocumentReader filters as full-tuple Eq. Public DTO UserAgentCatalogEntryno longer carries NyxApiKey.
Runtime port split: replace IUserAgentCatalogRuntimeQueryPort with:
the same caller-scoped public port (AgentDeliveryTargetTool migrates to it, drops owner_nyx_user_id arg),
new IUserAgentDeliveryTargetReader (internal, by id, returns the credential-bearing record). Registered only for outbound delivery (FeishuCardHumanInteractionPort, analogous Telegram outbound). Not visible to LLM tools.
architecture guard (full-scan): no IAgentTool impl may take IUserAgentDeliveryTargetReader as a ctor dep.
AgentBuilderTool decomposition:
AgentBuilderApplicationService (surface-neutral, depends on caller-scope + delivery-target resolvers + caller-scoped catalog port + dispatch port).
IOutboundDeliveryTargetResolver interface; Lark/Telegram impls in the channel adapters.
LLM tool wrapper (AgentBuilderTool) becomes a thin IAgentTool over the application service.
Card flow / private-chat restriction stays in ChannelRuntime (Lark-specific UX).
Drop owner_nyx_user_id from ParametersSchema / arg parsing.
per-id ops on non-owned agent_id return "not found" (no existence disclosure, no version disclosure),
architecture guard (full-scan): no application-layer code calls QueryAllAsync on the agent-catalog reader; no IAgentTool impl depends on IUserAgentDeliveryTargetReader; UserAgentCatalogEntry does not carry NyxApiKey.
Open questions
cli/web semantics: when an aevatar-cli user runs /agents, do they see only Platform=\"nyxid\" agents (symmetric surface), or all NyxID-owned agents including lark/telegram (admin view)?
Recommendation: symmetric surface. cli/web/lark/telegram fully equivalent. Cross-surface visibility becomes an explicit --all-surfaces flag, not the default.
Recommendation: (c) for the lark surface (small footprint, agent UX is recreate-cheap), (a) for Platform=\"nyxid\" (cli/web — fewer rows, can backfill from OwnerNyxUserId alone since the scope is (NyxUserId, \"nyxid\", \"\", \"\")).
ScopeId field collision: existing UserAgentCatalogDocument.ScopeId is workflow/skill scope, not registration scope. Either rename existing field (preferred — proto-first refactor) or namespace OwnerScope.registration_scope_id differently in the readmodel.
State-version reader placement: keep on the caller-scoped public port (GetStateVersionForCallerAsync) or move to a narrow internal IUserAgentLifecycleVersionReader for the in-process WaitFor* paths only?
Recommendation: caller-scoped on the public port.WaitFor* runs inside the application service which already has the caller's OwnerScope; passing it through is free. Internal-reader option is cleaner in theory but adds a third reader interface for one method. Reconsider if a future caller of WaitFor* doesn't have a OwnerScope (none today).
Acceptance
No application-layer code can call a method that returns un-scoped agent data — enforced by IUserAgentCatalogQueryPort shape and an architecture-guard (full-scan).
No IAgentTool impl has IUserAgentDeliveryTargetReader (the secret-bearing reader) as a constructor dependency — full-scan guard.
UserAgentCatalogEntry (public DTO) does not carry NyxApiKey — full-scan guard on the field name.
OwnerScope is a proto sub-message embedded in commands / state / events / readmodel; no scattered OwnerNyxUserId + Platform + RegistrationScopeId fields remain on the public surface.
Platform is a canonical non-empty string at the resolver output and at command-handler ingress; empty rejected.
/agents in lark bot returns only the caller's (NyxUserId, \"lark\", RegistrationScopeId, SenderId)-scoped agents.
/agents in cli/web returns only (NyxUserId, \"nyxid\", \"\", \"\")-scoped agents.
/agent-status / /run-agent / /disable-agent / /enable-agent / /delete-agent return "not found" for non-owned agent_id (no existence disclosure, no version disclosure).
State-version reader (in whichever placement) does not return null vs not-null differently for owned vs non-owned agent_id.
Caller-resolver failure (NyxID /me 5xx, expired token, etc.) fails closed with an actionable error — never falls through to "all agents".
AgentBuilderApplicationService has no reference to AgentToolRequestContext / ChannelMetadataKeys / Lark / Telegram types.
AgentDeliveryTargetTool migrates to the caller-scoped public port; owner_nyx_user_id arg removed; tool no longer transitively reaches NyxApiKey.
Symptom
In Lark bot,
/agentscan return agents that belong to other Lark users, not just the caller.Repro (current code):
/daily ...creates agent →OwnerNyxUserId = A./agents.This compounds with the per-id ops (
/agent-status,/run-agent,/disable-agent,/enable-agent,/delete-agent), which never verify ownership before acting onagent_id. As long as B knows A'sagent_id(e.g. from the leaked listing), B can run / disable / delete it.Root cause (the immediate bug)
Evidence anchors:
agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:497— owner filter resolved from LLM-overridable arg or from a nullable/meresolver.agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:805—QueryAllAsync()+ permissive.Where(...)fall-through.agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:512(representative ofagent_status/run_agent/disable_agent/enable_agent/delete_agent) — per-id ops never verify ownership.agents/Aevatar.GAgents.ChannelRuntime/IUserAgentCatalogQueryPort.cs:5(GetAsync) and:9(QueryAllAsync) — root contract problem: neither carries caller identity.AgentBuilderTool.cs:805-807:Permissive on missing owner — when
ownerFilterisnull/"", every catalog row passes.ownerFilterbecomesnullwheneverResolveCurrentUserIdAsync(AgentBuilderTool.cs:1016) silently returnsnull: NyxID/meerror envelope (token expired, NyxID 5xx, network blip), malformed JSON, orid/user_id/submissing.AgentBuilderTool.cs:497:owner_nyx_user_idis read as a tool arg even though it is not in the JSON schema (lines 47-117) — residual prompt-injection foot-gun.The same shape exists on the runtime variant:
IUserAgentCatalogRuntimeQueryPort.cs:9(QueryAllAsync) is consumed byAgentDeliveryTargetTool(LLM tool) and byFeishuCardHumanInteractionPort(internal outbound delivery, needsNyxApiKey). Today the LLM tool can transitively reachNyxApiKeythrough the same reader.Why this bug class keeps being possible
IUserAgentCatalogQueryPortcarries no caller identity. Every tool author has to remember to (a) resolve current user, (b) filter by owner, (c) fail-closed on missing owner, (d) verify ownership before per-id ops. Four "remember tos" against a contract with no way to express the requirement.NyxApiKeyinto the same reader that LLM tools consume — secret boundary lives in convention, not in types.Proposed direction — unify caller scope across all surfaces
Same
/agentscommand across cli / web / lark / telegram. Each surface returns the agents that belong to me in this surface. Keep the abstraction uniform; let surface differ only at the resolver edge. GeneralizesChannelUserConfigScope(#436) from "per-user config" to "per-user ownership".A.
OwnerScopeas a proto sub-message (not a C# record)Per CLAUDE.md "序列化(强制)" + "核心语义强类型" + "先定义 .proto 并生成类型":
OwnerScopecarries cross-actor / cross-readmodel semantics, so it must be proto-first.OwnerScopeatomically intoInitializeSkillRunnerCommand,InitializeWorkflowAgentCommand, the actorState(SkillRunnerState/WorkflowAgentState), the committed events those actors publish,UserAgentCatalogDocument,UserAgentCatalogEntry. Replace the scatteredOwnerNyxUserId/Platform/ScopeIdfields. Do not extend the existing fragmented field set — collapse them into the sub-message in one step.ScopeId(the existing field) means "workflow / skill scope" today and is overloaded; either rename existing usage toWorkflowScopeIdor scope the rename toOwnerScope.registration_scope_idand leave the unrelatedScopeIdalone. Do not letOwnerScope.registration_scope_idcollide with the existingScopeIdsemantically.B. Canonical
Platformvalue, not nullableProtobuf
stringdefaults to empty; persisted records have no realnull. Don't let the design depend on C#string?surviving the proto / readmodel boundary.\"nyxid\"(cli + web native) |\"lark\"|\"telegram\"| … (additions reviewed).Platform = \"\"is rejected at resolver output and at command-handler ingress (validation error, not silent default).C. Caller-scoped query port —
Get+Query+VersionGetStateVersionAsync(agentId)is removed from the public scoped port. Returning a version (ornull) for a non-ownedagent_iddiscloses existence and version-progression — a soft existence oracle. The version reader is either (a) caller-scoped here, returningnullfor non-owned IDs, or (b) moved to a narrowIUserAgentLifecycleVersionReaderregistered only for the in-processWaitForCreatedAgentAsync/WaitForTombstoneReflectedAsyncpaths and never injected into LLM tools. Pick one — the issue's open question 5 below.GetForCallerAsyncreturnsnullfor both "doesn't exist" and "not yours" — single semantic, no existence disclosure to non-owners.QueryAllAsync()is deleted from the public port. Projector internals get a separate reader, narrowly registered, not visible to tools (see §D for the secret-bearing variant).IProjectionDocumentReaderviaProjectionDocumentFilterstrict-equality on the fullOwnerScopetuple. No.Where(...)in application code.D. Split the runtime port: public (no secret) + internal delivery reader (with secret)
IUserAgentCatalogRuntimeQueryPorttoday is dual-purpose: serves bothAgentDeliveryTargetTool(LLM-facing) andFeishuCardHumanInteractionPort(internal outbound delivery —FeishuCardHumanInteractionPort.cs:418consumestarget.NyxApiKey). Two consumers, two trust levels, one type — that is the leak surface.Split:
IUserAgentCatalogQueryPort(public, caller-scoped) — what LLM tools see. ReturnsUserAgentCatalogEntrywithoutNyxApiKey. The DTO drops the field;NyxApiKeyis no longer in the public surface at all.IUserAgentDeliveryTargetReader(internal, by id, with secret) — narrow contract, byagent_id(or delivery-target-id), returns the credential-bearing record. Registered only for the outbound delivery path (FeishuCardHumanInteractionPortand analogous Telegram/web outbound implementations). Not injected into any LLM tool, not exposed via DI to the agent-builder/delivery-target tools. Architecture guard (full-scan): no type implementingIAgentToolmay haveIUserAgentDeliveryTargetReaderas a constructor dependency.AgentDeliveryTargetToolmigrates to the caller-scoped public port — sameGetForCallerAsync/QueryByCallerAsyncshape, noNyxApiKeyin payloads (current implementation already masks viaMaskSecretbut the field is still in the sharedUserAgentCatalogEntryDTO; remove it from the public DTO entirely).This makes the secret boundary a type boundary, not a convention.
E. Agent ownership lives in actor state; projection materializes it
Per CLAUDE.md "权威状态 / Actor 即业务实体":
OwnerScopeis part of the agent's authoritative state on the owning actor (SkillRunnerGAgent/WorkflowAgentGAgent), set atInitialize*Commandtime, immutable thereafter. The committed event the actor publishes carriesOwnerScope.UserAgentCatalogProjectorwrites the same tuple into the readmodel for queries. The catalog is a replica; ownership is not invented at projection time.Aligns with #444 (
UserAgentCatalogGAgentset-membership) — projector consumes committed events, doesn't mint state.F.
AgentBuilderTool— decompose, don't file-moveThe current tool is heavily channel-coupled:
AgentToolRequestContext,ChannelMetadataKeys, Lark receive-target resolution, private-chat restriction (AgentBuilderTool.cs:162-167), card flow, Nyx relay context. Moving the file alone pollutes a "surface-neutral" project with channel semantics.Decompose into three roles:
AgentBuilderApplicationService(surface-neutral) — create / list / status / lifecycle. Depends on:ICallerScopeResolver(resolvesOwnerScope),IOutboundDeliveryTargetResolver(resolves where the agent's outputs go: Lark(receive_id, type), Telegram chat id, native sink, …),IUserAgentCatalogQueryPort(caller-scoped public port, no secret),IActorDispatchPortforInitialize*Command/ lifecycle commands.No reference to
AgentToolRequestContext,ChannelMetadataKeys, or any Lark/Telegram type.ChannelRuntimestays the home of):LarkChannelCallerScopeResolver— readsAgentToolRequestContext+ChannelMetadataKeys.SenderId+ registration scope.LarkOutboundDeliveryTargetResolver— currentResolveDeliveryTargetlogic.AgentBuilderCardFlow) stays here.IAgentToolshell that calls the application service. Lives wherever its surface lives (Lark adapter for Lark, cli adapter for cli, etc.).Net: the application service code doesn't know what surface it's on; the adapters know exactly one surface; the readmodel is shared.
G. Drop
owner_nyx_user_idargRemove the LLM-overridable owner-id parameter entirely. The tool contract is "operate on my agents". Override surface = impersonation surface.
Surface table
OwnerScope { A, \"nyxid\", \"\", \"\" }{ A, \"lark\", bot-1, lark-A }Strict full-tuple equality on read and on creation-time write. No fall-through.
Architectural alignment with CLAUDE.md
QueryAllAsyncfrom the application path is the side-read pattern; remove it.OwnerScopeis the typed sub-message, not scattered fields and not aMetadatabag.OwnerScopeis proto-first; embedded into commands / state / events / readmodel docs.QueryAllAsync()and the dual-purpose runtime port are exactly that — the architecture removes both foot-guns, not just patches the call sites.ChannelUserConfigScope=(RegistrationScopeId, Platform, SenderId)):OwnerScopeextends to(NyxUserId, Platform, RegistrationScopeId, SenderId)and applies to ownership instead of just config.Implementation scope (first PR; no follow-ups)
OwnerScopemessage; embed intoInitializeSkillRunnerCommand,InitializeWorkflowAgentCommand, the relevant actor state proto, the committed events,UserAgentCatalogDocument,UserAgentCatalogNyxCredentialDocument(if used for filtering). Remove the now-redundant scattered fields. Generate types.OwnerScoperesolver layer:ICallerScopeResolverinterface (returnsOwnerScope, throwsCallerScopeUnavailableExceptionon failure — never returns "anonymous" / "everyone"). Implementations:LarkChannelCallerScopeResolver,TelegramChannelCallerScopeResolver,NyxIdNativeCallerScopeResolver.QueryAllAsyncand unscopedGetAsync/GetStateVersionAsyncfromIUserAgentCatalogQueryPort; addGetForCallerAsync/QueryByCallerAsync/GetStateVersionForCallerAsync(or move the version reader to the internal lifecycle reader — open question 5). Push owner predicate toIProjectionDocumentReaderfilters as full-tupleEq. Public DTOUserAgentCatalogEntryno longer carriesNyxApiKey.IUserAgentCatalogRuntimeQueryPortwith:AgentDeliveryTargetToolmigrates to it, dropsowner_nyx_user_idarg),IUserAgentDeliveryTargetReader(internal, by id, returns the credential-bearing record). Registered only for outbound delivery (FeishuCardHumanInteractionPort, analogous Telegram outbound). Not visible to LLM tools.IAgentToolimpl may takeIUserAgentDeliveryTargetReaderas a ctor dep.AgentBuilderTooldecomposition:AgentBuilderApplicationService(surface-neutral, depends on caller-scope + delivery-target resolvers + caller-scoped catalog port + dispatch port).IOutboundDeliveryTargetResolverinterface; Lark/Telegram impls in the channel adapters.AgentBuilderTool) becomes a thinIAgentToolover the application service.ChannelRuntime(Lark-specific UX).owner_nyx_user_idfromParametersSchema/ arg parsing.agent_status/run_agent/disable_agent/enable_agent/delete_agent(andRequireManagedAgentAsync) to useGetForCallerAsync. Non-ownedagent_id→ "not found".agent_idreturn "not found" (no existence disclosure, no version disclosure),QueryAllAsyncon the agent-catalog reader; noIAgentToolimpl depends onIUserAgentDeliveryTargetReader;UserAgentCatalogEntrydoes not carryNyxApiKey.Open questions
/agents, do they see onlyPlatform=\"nyxid\"agents (symmetric surface), or all NyxID-owned agents including lark/telegram (admin view)?--all-surfacesflag, not the default.ChannelUserConfigScope(per-sender), per bug(daily): GitHub username binding shared across all Lark users of one bot — last writer wins #436. ✅UserAgentCatalogDocumentrows have onlyOwnerNyxUserId+Platform+ScopeId, noSenderId. Pre-bug(daily): GitHub username binding shared across all Lark users of one bot — last writer wins #436 lark agents likewise. Choose between (a) lazy backfill on first read, (b) explicit reprovision, (c) deprecate-and-recreate.Platform=\"nyxid\"(cli/web — fewer rows, can backfill fromOwnerNyxUserIdalone since the scope is(NyxUserId, \"nyxid\", \"\", \"\")).ScopeIdfield collision: existingUserAgentCatalogDocument.ScopeIdis workflow/skill scope, not registration scope. Either rename existing field (preferred — proto-first refactor) or namespaceOwnerScope.registration_scope_iddifferently in the readmodel.GetStateVersionForCallerAsync) or move to a narrow internalIUserAgentLifecycleVersionReaderfor the in-processWaitFor*paths only?WaitFor*runs inside the application service which already has the caller'sOwnerScope; passing it through is free. Internal-reader option is cleaner in theory but adds a third reader interface for one method. Reconsider if a future caller ofWaitFor*doesn't have aOwnerScope(none today).Acceptance
IUserAgentCatalogQueryPortshape and an architecture-guard (full-scan).IAgentToolimpl hasIUserAgentDeliveryTargetReader(the secret-bearing reader) as a constructor dependency — full-scan guard.UserAgentCatalogEntry(public DTO) does not carryNyxApiKey— full-scan guard on the field name.OwnerScopeis a proto sub-message embedded in commands / state / events / readmodel; no scatteredOwnerNyxUserId+Platform+RegistrationScopeIdfields remain on the public surface.Platformis a canonical non-empty string at the resolver output and at command-handler ingress; empty rejected./agentsin lark bot returns only the caller's(NyxUserId, \"lark\", RegistrationScopeId, SenderId)-scoped agents./agentsin cli/web returns only(NyxUserId, \"nyxid\", \"\", \"\")-scoped agents./agent-status//run-agent//disable-agent//enable-agent//delete-agentreturn "not found" for non-ownedagent_id(no existence disclosure, no version disclosure).nullvsnot-nulldifferently for owned vs non-ownedagent_id./me5xx, expired token, etc.) fails closed with an actionable error — never falls through to "all agents".AgentBuilderApplicationServicehas no reference toAgentToolRequestContext/ChannelMetadataKeys/ Lark / Telegram types.AgentDeliveryTargetToolmigrates to the caller-scoped public port;owner_nyx_user_idarg removed; tool no longer transitively reachesNyxApiKey.Related
/dailybinding causes cross-user data leakage #437 —/dailycross-user data leak (user-config layer; same shape, fixed viaChannelUserConfigScope).ChannelUserConfigScopeintroduction; the precedent this issue generalizes.AgentBuilderToolgod-function decomposition (lands together — the decomposition in §F is the same direction).UserAgentCatalogGAgentset-membership refactor (consumesOwnerScopeon the projector side).