ChatVault-style downstream: trusted user id and merge
This page documents patterns used by ChatVault (tutorial reference implementation) that apply to any downstream MCP server that stores per-user data keyed by a userId tool argument while Agentsyx asserts a canonical user identity on the HTTP request.
For general trust boundaries, see Request path & trust boundaries. For headers and injected args, see Injected parameters & metadata.
Trusted canonical id on the wire
Agentsyx forwards identity context to your MCP server as headers. ChatVault treats the following as trusted canonical user id (in order):
x-a6-canonical-user-id— explicit canonical id for merge and ownership.x-a6-user-uuid— asserted user UUID when the canonical header is not set.
If the tool call’s arguments.userId (declared id from the model or widget) differs from the trusted canonical id, ChatVault records an idempotent user_id_merges row and migrates chats / chat_save_jobs rows asynchronously via the same Upstash list and mcp-worker path as saveChat (queue:mcp:chat-save), after a synchronous Redis → Neon idempotency check.
Synchronous idempotency (before enqueue)
On each tool call, before lpush to the chat-save queue, ChatVault:
GETa composite Upstash key derived from(declaredUserId, canonicalUserId)(SHA-256 of the pair).- On miss,
SELECTfromuser_id_merges; if a row exists,SETthe cache with a TTL. - Only if work may still be needed (including repair when mapping exists but rows still use the legacy id), enqueue a
source: "userMerge"job or run theUPDATEs in-process when Redis is not configured.
loadMyChats: Creator-facing excerpts
Tool parameters (simplified from the reference server):
export interface LoadChatsParams {
userId: string;
page?: number;
size?: number;
query?: string;
widgetVersion?: string;
userContext?: UserContext;
headers?: Record<string, string | string[] | undefined>;
aboveTheFoldOnly?: boolean;
}The MCP handler merges header-based UserContext with optional mirrored args (isAnon, portalLink, loginLink), then passes headers through for logging (e.g. x-a6-username).
Reads use getMergedUserIdScopeForReads(canonicalUserId) so chats stored under a legacy user_id remain visible until the async worker finishes migrating rows.
Async queue shape (userMerge)
Jobs pushed to CHAT_SAVE_QUEUE use a discriminated payload:
{
"jobId": "<uuid>",
"source": "userMerge",
"fromUserId": "<declared>",
"toUserId": "<canonical>"
}The mcp-worker handles this in the same loop as chat-save jobs (processChatSaveJob), applies UPDATEs, then refreshes the composite Upstash key so producers skip redundant work.
Related: saveChat async pattern
For the standard chat-save job shape (saveChat, saveChatTurnsFinalize, widgetAdd), see the ChatVault implementation: async path uses pushChatSaveJob and getChatSaveJobStatus; userMerge reuses the queue name and worker process, not the reconcile queue (queue:mcp:worker / worker_v1).