Skip to content

feat(engine): scope LangGraph sessions per user_id via checkpoint metadata #670

Description

@ahmedennaifer

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingengineEngine specific issueenhancementNew feature or request

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions