@@ -333,9 +333,14 @@ async def find_related(
333333 relation_date_filter = ""
334334 timeframe_condition = ""
335335
336- # Add project filtering for security - ensure all entities and relations belong to the same project
337- project_filter = "AND e.project_id = :project_id"
338- relation_project_filter = "AND e_from.project_id = :project_id"
336+ # Trigger: build_context starts from a project-scoped search result.
337+ # Why: the seed entity must belong to the requested project, but an
338+ # explicit relation edge may point at another project.
339+ # Outcome: traversal follows only project-owned edges from reached
340+ # entities, instead of forcing every reached entity into the seed project.
341+ seed_project_filter = "AND e.project_id = :project_id"
342+ connected_entity_project_filter = ""
343+ relation_project_filter = "AND e_from.project_id = r.project_id"
339344
340345 # Use a CTE that operates directly on entity and relation tables
341346 # This avoids the overhead of the search_index virtual table
@@ -351,7 +356,8 @@ async def find_related(
351356 query = self ._build_postgres_query (
352357 entity_id_values ,
353358 date_filter ,
354- project_filter ,
359+ seed_project_filter ,
360+ connected_entity_project_filter ,
355361 relation_date_filter ,
356362 relation_project_filter ,
357363 timeframe_condition ,
@@ -362,7 +368,8 @@ async def find_related(
362368 query = self ._build_sqlite_query (
363369 entity_id_values ,
364370 date_filter ,
365- project_filter ,
371+ seed_project_filter ,
372+ connected_entity_project_filter ,
366373 relation_date_filter ,
367374 relation_project_filter ,
368375 timeframe_condition ,
@@ -397,7 +404,8 @@ def _build_postgres_query( # pragma: no cover
397404 self ,
398405 entity_id_values : str ,
399406 date_filter : str ,
400- project_filter : str ,
407+ seed_project_filter : str ,
408+ connected_entity_project_filter : str ,
401409 relation_date_filter : str ,
402410 relation_project_filter : str ,
403411 timeframe_condition : str ,
@@ -421,11 +429,13 @@ def _build_postgres_query( # pragma: no cover
421429 0 as depth,
422430 e.id as root_id,
423431 e.created_at,
424- e.created_at as relation_date
432+ e.created_at as relation_date,
433+ e.project_id as project_id,
434+ ',' || e.id::text || ',' as entity_path
425435 FROM entity e
426436 WHERE e.id IN ({ entity_id_values } )
427437 { date_filter }
428- { project_filter }
438+ { seed_project_filter }
429439
430440 UNION ALL
431441
@@ -477,15 +487,25 @@ def _build_postgres_query( # pragma: no cover
477487 CASE
478488 WHEN step_type = 1 THEN e_from.created_at
479489 ELSE eg.relation_date
480- END as relation_date
490+ END as relation_date,
491+ CASE
492+ WHEN step_type = 1 THEN eg.project_id
493+ ELSE e.project_id
494+ END as project_id,
495+ CASE
496+ WHEN step_type = 1 THEN eg.entity_path
497+ ELSE eg.entity_path || e.id::text || ','
498+ END as entity_path
481499 FROM entity_graph eg
482500 CROSS JOIN LATERAL (VALUES (1), (2)) AS steps(step_type)
483501 JOIN relation r ON (
484502 eg.type = 'entity' AND
485- (r.from_id = eg.id OR r.to_id = eg.id)
503+ (r.from_id = eg.id OR r.to_id = eg.id) AND
504+ r.project_id = eg.project_id
486505 )
487506 JOIN entity e_from ON (
488507 r.from_id = e_from.id
508+ { relation_date_filter }
489509 { relation_project_filter }
490510 )
491511 LEFT JOIN entity e ON (
@@ -495,10 +515,17 @@ def _build_postgres_query( # pragma: no cover
495515 ELSE r.from_id
496516 END
497517 { date_filter }
498- { project_filter }
518+ { connected_entity_project_filter }
499519 )
500520 WHERE eg.depth < :max_depth
501- AND (step_type = 1 OR (step_type = 2 AND e.id IS NOT NULL AND e.id != eg.id))
521+ AND (
522+ step_type = 1 OR (
523+ step_type = 2
524+ AND e.id IS NOT NULL
525+ AND e.id != eg.id
526+ AND position(',' || e.id::text || ',' in eg.entity_path) = 0
527+ )
528+ )
502529 { timeframe_condition }
503530 )
504531 -- Materialize and filter
@@ -529,7 +556,8 @@ def _build_sqlite_query(
529556 self ,
530557 entity_id_values : str ,
531558 date_filter : str ,
532- project_filter : str ,
559+ seed_project_filter : str ,
560+ connected_entity_project_filter : str ,
533561 relation_date_filter : str ,
534562 relation_project_filter : str ,
535563 timeframe_condition : str ,
@@ -555,11 +583,13 @@ def _build_sqlite_query(
555583 e.id as root_id,
556584 e.created_at,
557585 e.created_at as relation_date,
558- 0 as is_incoming
586+ 0 as is_incoming,
587+ e.project_id as project_id,
588+ ',' || e.id || ',' as entity_path
559589 FROM entity e
560590 WHERE e.id IN ({ entity_id_values } )
561591 { date_filter }
562- { project_filter }
592+ { seed_project_filter }
563593
564594 UNION ALL
565595
@@ -580,11 +610,14 @@ def _build_sqlite_query(
580610 eg.root_id,
581611 e_from.created_at,
582612 e_from.created_at as relation_date,
583- CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
613+ CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming,
614+ eg.project_id as project_id,
615+ eg.entity_path as entity_path
584616 FROM entity_graph eg
585617 JOIN relation r ON (
586618 eg.type = 'entity' AND
587- (r.from_id = eg.id OR r.to_id = eg.id)
619+ (r.from_id = eg.id OR r.to_id = eg.id) AND
620+ r.project_id = eg.project_id
588621 )
589622 JOIN entity e_from ON (
590623 r.from_id = e_from.id
@@ -615,7 +648,9 @@ def _build_sqlite_query(
615648 eg.root_id,
616649 e.created_at,
617650 eg.relation_date,
618- eg.is_incoming
651+ eg.is_incoming,
652+ e.project_id as project_id,
653+ eg.entity_path || e.id || ',' as entity_path
619654 FROM entity_graph eg
620655 JOIN entity e ON (
621656 eg.type = 'relation' AND
@@ -624,9 +659,10 @@ def _build_sqlite_query(
624659 ELSE eg.from_id
625660 END
626661 { date_filter }
627- { project_filter }
662+ { connected_entity_project_filter }
628663 )
629664 WHERE eg.depth < :max_depth
665+ AND instr(eg.entity_path, ',' || e.id || ',') = 0
630666 { timeframe_condition }
631667 )
632668 SELECT DISTINCT
0 commit comments