user_id Audit Deep-Dive — May 12 2026 P0 FIX

How 2 Cross-Account Leak Bugs Surfaced
(and 5 BFFs Got Properly Wired)

A focused audit of every Anything Engine BFF wrapper that touches user state. Two of them hardcoded Robert's WorkOS user id "15", silently attributing Mark's dogfood writes to Robert's account. The bug was masked because both dogfooded from the same dev environment. This is the full forensic.

tl;dr

The headline

Mark’s Zep memory writes were landing in Robert’s account.

Two files in the codebase had user_id: "15" hardcoded — that’s Robert’s WorkOS id. /chat dispatched with it; /upload-files form-appended it. Anything Mark did from those code paths got tagged to Robert in Xano. Per-user memory continuity was structurally broken in the chat-shell + deck-upload subsystems.

Section 1 — the discovery

How we found them

The W13 SANDBOX-PARITY-AUDIT (May 11) flagged that the canvas was sending user_id: "" to ep 8506 — meaning Zep memory was session-ephemeral per page-load, not per user. That was the entry point. The fix wired useAuth().user.id into the canvas dispatch payload (commit 64a847c).

That alone wasn’t the bug. The audit prompted a wider sweep across all 9 AE BFF wrappers to find sister sites that needed the same wire-up. Halfway through that sweep:

// chat-shell.tsx, line 1495 — the gut punch
const result = (await dispatchAction({
  data: {
    query,
    thread_id: threadIdRef.current,
    user_id: "15",  // ← Robert's WorkOS id, hardcoded
    ...(classOverride ? { class_override: classOverride } : {}),
    count: requestedN,
    ...(suggestionRequestIdRef.current
      ? { suggestion_request_id: suggestionRequestIdRef.current }
      : {}),
    ...(toolCalls ? { tool_calls: toolCalls } : {}),
  },
})) as DispatchResult;

Then later, deeper in the upload pipeline:

// upload-files-fn.ts, line 67 — the second one
const form = new FormData();
form.append("suggestion_request_id", String(data.suggestion_request_id));
form.append("user_id", "15");  // ← same id, different file
form.append("mode", "suggestion_request");

for (const f of data.files) {
  const bytes = Uint8Array.from(atob(f.base64), (c) => c.charCodeAt(0));
  const blob = new Blob([bytes], { type: f.mime_type });
  form.append("files[]", blob, f.filename);
}
Why the bug was invisible.

Both Robert and Mark dogfooded the app from the same machine, both authenticated as their own WorkOS users. The frontend showed each of them their own UI — auth worked correctly. But the dispatch payload to Xano carried Robert’s id regardless of who was logged in. Backend processing then attributed memory writes / suggestion_request rows / uploaded decks to Robert. Mark would never see his own data because his data didn’t exist on his account.

Section 2 — the fix pattern

useAuth().user.id wired into every BFF call site

The fix is the same shape everywhere: import useAuth from the WorkOS adapter, read user.id with an empty-string fallback, pass it through. The empty-string fallback preserves dev-without-auth behavior and keeps existing test setups working.

import { useAuth } from "@workos/authkit-tanstack-react-start/client";

// inside the component:
const { user } = useAuth();
const authedUserId = user?.id ?? "";

// every BFF call:
const result = (await dispatchAction({
  data: {
    query,
    thread_id: threadIdRef.current,
    user_id: authedUserId,  // ← from useAuth, not hardcoded
    ...
  },
})) as DispatchResult;

useCallback dependency arrays were updated everywhere this lands so React doesn’t close over a stale id when a different user signs in.

Section 3

BFF wrapper coverage matrix

Every Anything Engine BFF wrapper, what Xano endpoint it hits, whether it touches user state, and the user_id status before / after this audit.

BFF wrapperXano epTouches user state?BeforeAfter
openui05DispatchFn 8506 (find-X) Zep memory write per dispatch ×
dispatchFn (legacy) 8399 Zep memory write per dispatch × (hardcoded "15")
interviewFn 8411 Zep memory write per turn ×
findTalentInterviewFn 8484 Zep memory write per turn ×
startOutcomeFn 8417 Creates suggestion_request row ×
uploadFilesFn 8420 Creates suggestion_request_file + pitch_profile × (hardcoded "15")
classifyFn 8400 Stateless (no DB write) N/A N/A
buildSummaryFn 8505 Synthesis only (no DB write) N/A N/A
pitchProfileFn 8420 (GET) Read-only, keyed on suggestion_request_id N/A N/A
6 of 6 stateful BFFs now wired.

All 6 BFFs that touch user state pass authenticated WorkOS user.id from useAuth(). The 3 stateless BFFs (classify, buildSummary, pitchProfile) don’t need it.

Section 4 — every call site touched

9 call sites wired across 3 component files

anything-engine-canvas-openui.tsx — 6 sites
3 dispatchEngine sites (PATH A deck-upload, PATH B result-count picker, interview-gate auto-dispatch). 1 interviewAction site. 1 findTalentInterviewAction site. 2 startOutcome sites (firstTurn + composer ensureDraft). 1 uploadFiles site. All useCallback deps updated.
64a847c ba11dc2 e58f83f a5d8f8f
WIRED
chat-shell.tsx — 4 sites
1 dispatchAction (formerly hardcoded "15"). 1 interviewAction. 1 findTalentInterviewAction. 1 startOutcomeAction inside ensureDraft. 1 uploadFilesAction. useCallback deps updated.
bb5e329 ba11dc2 e58f83f 6e17286 a5d8f8f
WIRED
upload-files-fn.ts — BFF wrapper itself
Replaced form.append("user_id","15") with data.user_id ?? "". Added user_id?: string to UploadFilesInput. Backend already accepts the field; before this fix the form just always sent "15".
a5d8f8f
WIRED
Section 5

How we verified

grep -F sweep across entire src/
After the 2 hardcoded sites were replaced, swept the whole src/ tree for remaining string-literal "15" near user_id contexts. Zero remaining matches. (The 4 user_id: 0 finds in discover/notes/collections were optimistic-update placeholders, never hit the wire.)
CLEAN
Runtime canvas dogfood — full find_investors flow
Tile click → composer prefill → interview turns 1+2 fire → MCQ chips render → all dispatch payloads observed via window.fetch interceptor. authedUserId field present in every body. Code path runs through new wire-up without crash.
VERIFIED
Repo-wide quality gates green
0 typecheck errors. 0 lint warnings. 30/30 tests pass. Production build clean (1m 7s). Canvas dogfood-verified post each commit chain.
100%
Section 6

Backend follow-up (Mark territory)

Frontend wires the field; backend ignores it for now — but safely.

Xano discards unknown body keys, so sending user_id on endpoints that don’t yet accept it is a safe extension. Mark wires the server-side handlers at his pace; when they land, every call from the frontend will already be sending the right id.

ep 8411 — interview endpoint needs user_id input declaration
XanoScript needs text user_id? added to the input block + Zep memory call needs to key on it.
MARK
ep 8417 — start-outcome needs user_id stored on suggestion_request row
Currently the row gets backend-default user. Add user_id to the input + write to the row’s user_id column.
MARK
ep 8420 — upload-files needs to honor multipart user_id
The form already sends user_id. Backend needs to read it from $input.user_id instead of hardcoded "15" or $auth.id (which would also break since this BFF doesn’t pass auth headers).
MARK
ep 8484 — find-talent interview parity with 8411
Same pattern as ep 8411. Mark’s May 6 verbatim prompt for find_talent should add Zep continuity per user.
MARK
ep 8506 — OpenUI 0.5 dispatch fully wired
EP 8506 already accepts user_id (per W13 XANO-PROBE). Frontend now sends it correctly. Should “just work” once Mark’s memory layer is keyed on it.
READY