Follow-up to the daily-digest "self-opportunity" investigation. The guardrails (defense-in-depth for the self-match / wrong-caller data classes) shipped in #910. This issue tracks the actual root-cause fix and related hardening, which are operational / out-of-scope for that hotfix.
Background
A user (Seren) saw a daily-digest opportunity (list_opportunities MCP call) that appeared to match her with herself. Clicking the connect URL opened a conversation seeded with an intro written in someone else's first-person voice:
"Hi Seren, I came across your profile … Given my involvement with Edge City … I'd love to connect …"
Confirmed mechanism
The greeting is always built from getProfile(viewerId) (opportunity.presenter.ts:608,750), and the digest mints the connect link with viewerId = context.userId (opportunity.tools.ts). The message being addressed to Seren but written in the Edge City user's voice means the card was rendered with viewerId = the Edge City user, counterpart = Seren.
AgentVillage's Edge — daily digest cron runs list_opportunities using the single INDEX_API_KEY baked into that village's config.yaml → mcp_servers.index.headers["x-api-key"] (packages/agentvillage/install/install_index.ts:64). MCP resolves that key to one user via apikeys.referenceId ?? apikeys.userId (backend/src/controllers/mcp.controller.ts:387).
Conclusion: Seren's village agent authenticates to Index as the Edge City user — her morning brief is (partly) that user's opportunity feed, so a connection that user has toward Seren surfaces in Seren's brief as "an opportunity with herself," with the other person's identity + greeting on the connect link.
Ruled out (verified in code)
- Discovery self-match — a true
[Seren, Seren] opp is skipped in digest mode (opportunity.tools.ts:1514).
- Connect-link reuse / delivery-card cache leak — keyed by
(opp, userId, kind) and viewerId respectively.
- MCP request-context concurrency clobber —
AsyncLocalStorage, per-request userId.
Tasks
1. Verify the binding (do first)
2. Immediate rebind (one resident)
3. Systemic fix (the cohort)
4. Hardening (optional, separate PRs)
Done (for reference)
https://claude.ai/code/session_0157jBxKSVKaJG43XAWJNsGU
Follow-up to the daily-digest "self-opportunity" investigation. The guardrails (defense-in-depth for the self-match / wrong-caller data classes) shipped in #910. This issue tracks the actual root-cause fix and related hardening, which are operational / out-of-scope for that hotfix.
Background
A user (Seren) saw a daily-digest opportunity (
list_opportunitiesMCP call) that appeared to match her with herself. Clicking the connect URL opened a conversation seeded with an intro written in someone else's first-person voice:Confirmed mechanism
The greeting is always built from
getProfile(viewerId)(opportunity.presenter.ts:608,750), and the digest mints the connect link withviewerId = context.userId(opportunity.tools.ts). The message being addressed to Seren but written in the Edge City user's voice means the card was rendered withviewerId = the Edge City user, counterpart = Seren.AgentVillage's
Edge — daily digestcron runslist_opportunitiesusing the singleINDEX_API_KEYbaked into that village'sconfig.yaml→mcp_servers.index.headers["x-api-key"](packages/agentvillage/install/install_index.ts:64). MCP resolves that key to one user viaapikeys.referenceId ?? apikeys.userId(backend/src/controllers/mcp.controller.ts:387).Conclusion: Seren's village agent authenticates to Index as the Edge City user — her morning brief is (partly) that user's opportunity feed, so a connection that user has toward Seren surfaces in Seren's brief as "an opportunity with herself," with the other person's identity + greeting on the connect link.
Ruled out (verified in code)
[Seren, Seren]opp is skipped in digest mode (opportunity.tools.ts:1514).(opp, userId, kind)andviewerIdrespectively.AsyncLocalStorage, per-requestuserId.Tasks
1. Verify the binding (do first)
connect_linksrow for the clicked code → confirmuserIdis the Edge City user (not Seren) and the opportunityactorsare a legit[EdgeCity, Seren]pair (not a degenerate self set).x-api-keyfrom Seren's villageconfig.yaml(SHA-256 → base64url) andSELECT userId, referenceId FROM apikeys WHERE key = <hash>→ does it resolve to the Edge City user?network-invitation.service.ts:217having merged her with the Edge City account — that would be a data fix, not a key swap).2. Immediate rebind (one resident)
networkInvitationService.resendInvite({ networkId, memberId: <serenUserId> })(adapter setsreferenceId = userId = Seren, so it resolves correctly —agent-token.adapter.ts:101).install_index.ts, or updateconfig.yaml+.envand let the 08:00 cron pick it up).3. Systemic fix (the cohort)
install_index.tswith--index-api-key. (Lives outsideindexnetwork/indexandEdge-City/agentvillage; needs a pointer to the entrypoint or the resident→key map.)4. Hardening (optional, separate PRs)
--telegram-handle→ MCP request carriesx-index-telegram-username. Assert the authenticated key's user actually owns that handle; reject/log on mismatch. This is the one server-side signal that would have caught this incident.mcp.controller.ts:387precedence:referenceId ?? userIdis benign today (adapter sets them equal) but resolves to the wrong principal if any key ever hasreferenceId ≠ userId. Prefer the verified sessionuserId, or assertreferenceId === userIdfor agent keys.Done (for reference)
fix(protocol): guard against self-match opportunities (hotfix)→main. Persist-time self-match rejection + read-time "caller must be an actor" skip/log inlist_opportunities.@indexnetwork/protocol1.26.6 → 1.26.7.https://claude.ai/code/session_0157jBxKSVKaJG43XAWJNsGU