#819·parlant

[Bug] <SDK accepts cross-journey transitions and later crashes journey projection with `KeyError`>

Author: bossjoker1Created Jun 23, 2026Updated Jun 30, 2026
Labelsbug

Description

The SDK currently allows creating a transition in one journey that targets a state owned by a different journey.

That invalid edge is persisted successfully, but later runtime code assumes that every edge target in a journey belongs to that same journey. Once JourneyGuidelineProjection.project_journey_to_guidelines(...) traverses the invalid edge, it crashes with KeyError.

This appears to come from two missing checks:

  • _validate_transition_parameters(...) validates overload shape, but does not verify that state=<JourneyState> belongs to the same journey as the source state.
  • Journey.create_transition(...) passes target.id straight into JourneyStore.create_edge(...), and JourneyStore.create_edge(...) persists it without a same-journey ownership check.

So a caller can build an invalid cross-journey edge through the public SDK, and a later runtime path crashes on it.

How to Reproduce

From a clean checkout:

bash
uv sync --group dev

Create a small probe such as:

python
import asyncio
import json
from typing import Any, Generic, Mapping, TypeVar

from parlant import sdk as p
from parlant.core.common import DefaultBaseModel
from parlant.core.journey_guideline_projection import JourneyGuidelineProjection
from parlant.core.journeys import JourneyStore
from parlant.core.nlp.embedding import NullEmbedder
from parlant.core.nlp.generation import SchematicGenerationResult, SchematicGenerator, StreamingTextGenerator
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.nlp.service import EmbedderHints, NLPService, SchematicGeneratorHints, StreamingTextGeneratorHints
from parlant.core.nlp.tokenization import ZeroEstimatingTokenizer

T = TypeVar("T", bound=DefaultBaseModel)


class ProbeComplete(Exception):
    pass


class DummyGenerator(SchematicGenerator[T], Generic[T]):
    def __init__(self, schema: type[T]) -> None:
        self._schema = schema
        self._tokenizer = ZeroEstimatingTokenizer()

    @property
    def schema(self) -> type[T]:
        return self._schema

    async def generate(self, prompt: str | Any, hints: Mapping[str, Any] = {}):
        raise RuntimeError("not used in this repro")

    @property
    def id(self) -> str:
        return f"dummy:{self._schema.__name__}"

    @property
    def max_tokens(self) -> int:
        return 8192

    @property
    def tokenizer(self):
        return self._tokenizer


class NoopNLPService(NLPService):
    @property
    def supports_streaming(self) -> bool:
        return False

    async def get_schematic_generator(
        self,
        t: type[T],
        hints: SchematicGeneratorHints = {},
    ) -> SchematicGenerator[T]:
        return DummyGenerator[t](t)  # type: ignore[index]

    async def get_streaming_text_generator(
        self,
        hints: StreamingTextGeneratorHints = {},
    ) -> StreamingTextGenerator:
        raise RuntimeError("not used in this repro")

    async def get_embedder(self, hints: EmbedderHints = {}):
        return NullEmbedder()

    async def get_moderation_service(self) -> ModerationService:
        return NoModeration()


async def main() -> None:
    server = p.Server(
        port=8800,
        tool_service_port=8818,
        log_level=p.LogLevel.TRACE,
        nlp_service=lambda c: NoopNLPService(),
    )

    try:
        async with server:
            agent = await server.create_agent(
                name="Probe Agent",
                description="Agent for reproducing cross-journey transition behavior",
            )

            journey1 = await agent.create_journey(
                title="Journey One",
                triggers=[],
                description="First journey",
            )
            journey2 = await agent.create_journey(
                title="Journey Two",
                triggers=[],
                description="Second journey",
            )

            journey2_transition = await journey2.initial_state.transition_to(
                chat_state="inside journey two"
            )
            foreign_state = journey2_transition.target

            cross_transition = await journey1.initial_state.transition_to(
                condition="jump across journeys",
                state=foreign_state,
            )

            journey_store = server.container[JourneyStore]
            projection = server.container[JourneyGuidelineProjection]

            stored_journey = await journey_store.read_journey(journey1.id)
            edges = await journey_store.list_edges(journey1.id)
            nodes = await journey_store.list_nodes(journey1.id)

            result = {
                "journey1_id": journey1.id,
                "journey2_id": journey2.id,
                "foreign_state_id": foreign_state.id,
                "cross_transition_target_id": cross_transition.target.id,
                "journey1_root_id": stored_journey.root_id,
                "journey1_node_ids": [n.id for n in nodes],
                "journey1_edges": [
                    {
                        "id": e.id,
                        "source": e.source,
                        "target": e.target,
                        "condition": e.condition,
                    }
                    for e in edges
                ],
            }

            try:
                guidelines = await projection.project_journey_to_guidelines(journey1.id)
                result["projection"] = {
                    "status": "ok",
                    "guideline_ids": [g.id for g in guidelines],
                }
            except Exception as exc:
                result["projection"] = {
                    "status": "error",
                    "error_type": type(exc).__name__,
                    "error": str(exc),
                }

            print(json.dumps(result, indent=2, default=str))
            raise ProbeComplete()
    except ProbeComplete:
        pass


asyncio.run(main())

Run it with:

bash
uv run python cross_journey_probe.py

Observed behavior:

json
{
  "journey1_id": "8f2rGlcVin",
  "journey2_id": "htMqKzUPLW",
  "foreign_state_id": "hnJ5V3UIsq",
  "cross_transition_target_id": "hnJ5V3UIsq",
  "journey1_root_id": "r1HEKb2WOW",
  "journey1_node_ids": [
    "r1HEKb2WOW",
    "end"
  ],
  "journey1_edges": [
    {
      "id": "lvIDDCk8Xb",
      "source": "r1HEKb2WOW",
      "target": "hnJ5V3UIsq",
      "condition": "jump across journeys"
    }
  ],
  "projection": {
    "status": "error",
    "error_type": "KeyError",
    "error": "'hnJ5V3UIsq'"
  }
}

This shows:

  • the SDK accepted the foreign target
  • the edge was persisted under journey1
  • journey1 does not actually own that target node
  • projection later crashes when it reaches the foreign target

Expected Behavior

One of these should happen instead:

  • the SDK should reject transition_to(state=...) when the provided state belongs to a different journey, or
  • the lower-level edge creation path should reject cross-journey source/target combinations before persisting them

In either case, a caller should not be able to create a journey edge that later crashes runtime projection code.

Environment

  • OS: Linux
  • Python version: reproduced with uv on Python 3.13
  • Parlant version: 3.3.1

Discussion

The impact here is more than a validation gap.

This creates an invalid journey graph through the public SDK and then fails later in shipped runtime code:

  • the bad edge is accepted at authoring time
  • the journey is now internally inconsistent
  • runtime projection crashes with KeyError

JourneyGuidelineProjection is also used by higher-level query/runtime paths, so this is not limited to a cosmetic visualization problem.

It would be safer to fail early when the transition is created, rather than letting an invalid cross-journey edge persist and explode later during projection.