Context
/agent/sessions currently returns every thread on the deploy regardless of
caller. Cross-user session leak.
Step 1 (PR feat/chat-user-id) wired identity end-to-end: SPA mints + sends
X-Idun-User-Id, engine resolves it via _resolve_user_id and binds to
current_user_id ContextVar on /agent/sessions* and /agent/run. The
LangGraph adapter receives user_id as a kwarg on list_sessions and
get_session and can read current_user_id.get() during run. The adapter
ignores all of this today (see
libs/idun_agent_engine/src/idun_agent_engine/agent/langgraph/langgraph.py:1002-1005).
Reference SPEC: idun-dev/tasks/standalone-user-isolation-19-05-2026/SPEC.md, §Step 2.
Architectural decision
Filter-based, not DI-per-user.
LangGraph 1.0 supports per-user thread scoping natively. Any non-reserved key in
configurable is merged into the checkpoint's metadata column at write time
(langgraph-checkpoint 4.1.0:base/__init__.py:525-543).
BaseCheckpointSaver.alist(filter={"user_id": ...}) filters at the DB layer
(json_extract on SQLite, metadata @> %s::jsonb on Postgres).
DI per-user would require either recompiling the graph per request or
maintaining a process-level registry of compiled graphs keyed by user_id, with
eviction logic. Filter-based avoids that and aligns with LangGraph's own
pattern. Tenancy boundary stays at the deploy level.
The stale comment at langgraph.py:1002 ("LangGraph checkpointers have no
user-id concept") is wrong for 1.0+. Update to: "per-user scoping is
filter-based via checkpoint metadata; tenant isolation is the deploy boundary."
Scope
S2.1 Stamp user_id at write time
Build a fresh LangGraphAGUIAgent per LanggraphAgent.run() (langgraph.py:1119)
with user_id baked into config["configurable"]. Construction is cheap
(~5 attribute assignments) and race-safe across concurrent requests.
async def run(self, input_data):
user_id = current_user_id.get()
config = {"configurable": {"user_id": user_id}}
if self._obs_callbacks:
config["callbacks"] = self._obs_callbacks
per_run = LangGraphAGUIAgent(
name=self._name,
description="Agent description",
graph=self._agent_instance,
config=config,
)
async for event in per_run.run(input_data):
yield event
Skip deprecated invoke / stream paths.
S2.2 Filter list_sessions
Update _enumerate_thread_ids (langgraph.py:129) to accept user_id and pass
through. Prefer the framework-native path:
async for tup in saver.alist(
None,
filter={"user_id": user_id} if user_id else None,
limit=limit,
):
...
Fall back to raw SQL only if benchmarks show alist is too slow at demo scale.
S2.3 Validate user_id on get_session
After aget_state(config) returns, check state.metadata.get("user_id") == user_id.
On mismatch, return None (route turns into 404).
S2.4 Fix the stale comment
Replace the langgraph.py:1002 comment per the framing above.
S2.5 Populate user_id in summaries
Lines 1027-1033 and 1065-1070 hardcode user_id=None. Populate from the
snapshot's metadata.
Out of scope
- ADK adapter. Already user-scoped via
user_id_extractor=lambda _: current_user_id.get() at adk/adk.py:383.
- Deprecated
/agent/invoke and /agent/stream. Marked for removal.
- Per-tenant DI / separate checkpointers. Tenancy is the deploy.
- Postgres RLS. Multi-tenant SaaS concern, v1.0+.
Tests (TDD)
In tests/unit/agent/test_langgraph_user_scoping.py:
- After
run() with current_user_id="alice", the latest checkpoint's
metadata carries user_id="alice".
- Two runs with different user_ids produce checkpoints with respective values.
list_sessions(user_id="alice") returns only Alice's threads when Alice and
Bob both have threads in the same checkpointer.
get_session(B_thread, user_id="alice") returns None.
get_session(A_thread, user_id="alice") returns SessionDetail with
user_id="alice" populated.
- Pre-migration checkpoints (no metadata.user_id) are invisible to filtered list.
Pre-migration data
Threads written before this lands have metadata.user_id IS NULL and become
invisible to filtered list_sessions. Acceptable because:
- Cloud Run demo's
/tmp/checkpoints.db resets on cold start.
- Self-hosted mid-flight upgrades are rare in v0.x.
- No data destroyed; threads remain queryable by direct
thread_id. A one-shot
backfill script could populate user_id metadata if needed.
Document in CHANGELOG.
Dependencies
Step 1 PR feat/chat-user-id must land first. Step 2 reads current_user_id
which Step 1 sets.
Context
/agent/sessionscurrently returns every thread on the deploy regardless ofcaller. Cross-user session leak.
Step 1 (PR
feat/chat-user-id) wired identity end-to-end: SPA mints + sendsX-Idun-User-Id, engine resolves it via_resolve_user_idand binds tocurrent_user_idContextVar on/agent/sessions*and/agent/run. TheLangGraph adapter receives
user_idas a kwarg onlist_sessionsandget_sessionand can readcurrent_user_id.get()duringrun. The adapterignores all of this today (see
libs/idun_agent_engine/src/idun_agent_engine/agent/langgraph/langgraph.py:1002-1005).Reference SPEC:
idun-dev/tasks/standalone-user-isolation-19-05-2026/SPEC.md, §Step 2.Architectural decision
Filter-based, not DI-per-user.
LangGraph 1.0 supports per-user thread scoping natively. Any non-reserved key in
configurableis merged into the checkpoint'smetadatacolumn at write time(
langgraph-checkpoint 4.1.0:base/__init__.py:525-543).BaseCheckpointSaver.alist(filter={"user_id": ...})filters at the DB layer(
json_extracton SQLite,metadata @> %s::jsonbon Postgres).DI per-user would require either recompiling the graph per request or
maintaining a process-level registry of compiled graphs keyed by user_id, with
eviction logic. Filter-based avoids that and aligns with LangGraph's own
pattern. Tenancy boundary stays at the deploy level.
The stale comment at
langgraph.py:1002("LangGraph checkpointers have nouser-id concept") is wrong for 1.0+. Update to: "per-user scoping is
filter-based via checkpoint metadata; tenant isolation is the deploy boundary."
Scope
S2.1 Stamp user_id at write time
Build a fresh
LangGraphAGUIAgentperLanggraphAgent.run()(langgraph.py:1119)with
user_idbaked intoconfig["configurable"]. Construction is cheap(~5 attribute assignments) and race-safe across concurrent requests.
Skip deprecated
invoke/streampaths.S2.2 Filter list_sessions
Update
_enumerate_thread_ids(langgraph.py:129) to acceptuser_idand passthrough. Prefer the framework-native path:
Fall back to raw SQL only if benchmarks show
alistis too slow at demo scale.S2.3 Validate user_id on get_session
After
aget_state(config)returns, checkstate.metadata.get("user_id") == user_id.On mismatch, return
None(route turns into 404).S2.4 Fix the stale comment
Replace the
langgraph.py:1002comment per the framing above.S2.5 Populate user_id in summaries
Lines 1027-1033 and 1065-1070 hardcode
user_id=None. Populate from thesnapshot's metadata.
Out of scope
user_id_extractor=lambda _: current_user_id.get()atadk/adk.py:383./agent/invokeand/agent/stream. Marked for removal.Tests (TDD)
In
tests/unit/agent/test_langgraph_user_scoping.py:run()withcurrent_user_id="alice", the latest checkpoint'smetadata carries
user_id="alice".list_sessions(user_id="alice")returns only Alice's threads when Alice andBob both have threads in the same checkpointer.
get_session(B_thread, user_id="alice")returnsNone.get_session(A_thread, user_id="alice")returns SessionDetail withuser_id="alice"populated.Pre-migration data
Threads written before this lands have
metadata.user_id IS NULLand becomeinvisible to filtered
list_sessions. Acceptable because:/tmp/checkpoints.dbresets on cold start.thread_id. A one-shotbackfill script could populate user_id metadata if needed.
Document in CHANGELOG.
Dependencies
Step 1 PR
feat/chat-user-idmust land first. Step 2 readscurrent_user_idwhich Step 1 sets.