diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index da35cc779..aa557bd7a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -256,11 +256,14 @@ pattern and then hide the action button so it cannot 503 again.
`find_linked_post_ids` first expands to every post
sharing a mentioned person before calling
`backend/app/knowledge_graph.py::load_visible_subgraph` -- that function
-only loads edges among an *already-known* post set (its other caller,
-`related_for_person`, pre-resolves the full set itself), it does not
-discover new posts on its own; a real bug from calling it with only the
-single starting post was caught while building this and is now
-regression-tested (`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`).
+only loads edges among an *already-known* post set (its other callers,
+`related_for_person` / `related_for_entity` / `related_for_team`,
+pre-resolve the full set themselves), it does not discover new posts on
+its own; a real bug from calling it with only the single starting post
+was caught while building this and is now regression-tested
+(`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`).
+Person, team, and organization mention channels load independently
+(ADR 0018): a team-only or organization-only post still walks.
### Frontend (`frontend/`)
@@ -276,8 +279,9 @@ summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel
affiliate tree (resolved ancestors plus unresolved org roots), Keyman +
counterparty panels (a Keyman click loads RWR related nodes;
a related corporate-entity node, a resolved Keyman affiliation,
-or a classified name that resolves to a cataloged org continues
-the same walk via `GET /api/corporate-entities/{id}/related`;
+a classified name that resolves to a cataloged org, or an R&R team
+continues the same walk via `GET /api/corporate-entities/{id}/related`
+or `GET /api/teams/{id}/related`;
`post_admin` can extract),
and an in-popup chat whose cited sources
open a sliding evidence panel (`EvidencePanel`, CSS
diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md
new file mode 100644
index 000000000..4efa8100f
--- /dev/null
+++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md
@@ -0,0 +1,2 @@
+Related-node walks include team and organization mention edges. Click an
+R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fec2c509b..434c4d63b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,24 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.86.0] - 2026-08-16
+
+### Added
+
+- Related-node walks now include team and organization mention edges.
+ After `make seed` and a summary that names 설계팀 on two posts, open
+ either post, click the R&R team, and open the sibling post (ADR 0018).
+ A team-only follow-up is no longer an island.
+- `GET /api/teams/{team_id}/related` starts the same RWR walk Keyman
+ and corporate-entity related already use. Related team chips are
+ buttons.
+
+### Fixed
+
+- Thread-group analysis-run *lists* now require an in-cutoff visible
+ post. A later public post in that thread group no longer surfaces a
+ January run the account was not allowed to know.
+
## [0.85.0] - 2026-08-16
### Added
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index 6a1449818..d26eb6f6e 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -52,6 +52,7 @@
and exists (
select 1 from source_post p
where p.thread_group_key = scope.scope_key
+ and p.created_at <= run.knowledge_cutoff
and (
p.visibility_code = 'public'
or p.corporate_entity_id = any($2::uuid[])
diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py
index 4035134c0..ce7289bb5 100644
--- a/backend/app/knowledge_graph.py
+++ b/backend/app/knowledge_graph.py
@@ -18,9 +18,13 @@
EDGE_AFFILIATION,
EDGE_CO_MENTION,
EDGE_MENTION,
+ EDGE_MENTION_ORGANIZATION,
+ EDGE_MENTION_TEAM,
+ EDGE_TEAM_AFFILIATION,
NODE_CORPORATE_ENTITY,
NODE_PERSON,
NODE_POST,
+ NODE_TEAM,
KnowledgeGraphEdgeSpec,
adjacency_from_edges,
knowledge_graph_edges_for_post,
@@ -235,6 +239,16 @@ async def corporate_entity_exists(conn: asyncpg.Connection, entity_id: str) -> b
return row is not None
+async def team_exists(conn: asyncpg.Connection, team_id: str) -> bool:
+ """True when ``team_id`` is a UUID that exists in ``cataloged_team``."""
+ try:
+ UUID(team_id)
+ except ValueError:
+ return False
+ row = await conn.fetchrow("select 1 from cataloged_team where team_id = $1", team_id)
+ return row is not None
+
+
async def visible_mention_post_ids(
conn: asyncpg.Connection,
person_id: str,
@@ -258,27 +272,57 @@ async def visible_affiliation_post_ids(
entity_id: str,
can_see_post,
) -> list[str]:
- """Visible posts whose Keyman or R&R people affiliate with an entity."""
+ """Visible posts that mention an entity via a person or a direct org mention."""
rows = await conn.fetch(
"""
select distinct post.post_id, post.visibility_code,
post.corporate_entity_id, post.created_at
- from person_affiliation affiliation
- join combined_post_person_mention mention
- on mention.person_id = affiliation.person_id
- join source_post post on post.post_id = mention.post_id
- where affiliation.affiliated_corporate_entity_id = $1
+ from source_post post
+ where post.post_id in (
+ select mention.post_id
+ from person_affiliation affiliation
+ join combined_post_person_mention mention
+ on mention.person_id = affiliation.person_id
+ where affiliation.affiliated_corporate_entity_id = $1
+ union
+ select org_mention.post_id
+ from post_organization_mention org_mention
+ where org_mention.corporate_entity_id = $1
+ )
order by post.created_at, post.post_id
""",
entity_id,
)
return [str(row["post_id"]) for row in rows if can_see_post(row)]
+
+async def visible_team_mention_post_ids(
+ conn: asyncpg.Connection,
+ team_id: str,
+ can_see_post,
+) -> list[str]:
+ """Visible post ids supported by a cataloged team mention."""
+ rows = await conn.fetch(
+ """
+ select post.post_id, post.visibility_code, post.corporate_entity_id
+ from post_team_mention mention
+ join source_post post on post.post_id = mention.post_id
+ where mention.team_id = $1
+ order by post.created_at, post.post_id
+ """,
+ team_id,
+ )
+ return [str(row["post_id"]) for row in rows if can_see_post(row)]
+
async def load_visible_subgraph(
conn: asyncpg.Connection,
visible_post_ids: list[str],
) -> list[KnowledgeGraphEdgeSpec]:
- """Edges supported by at least one post the account may already see."""
+ """Edges supported by at least one post the account may already see.
+
+ Person, team, and organization mention channels are independent. A
+ team-only or organization-only post must still walk (ADR 0018).
+ """
if not visible_post_ids:
return []
person_rows = await conn.fetch(
@@ -287,7 +331,19 @@ async def load_visible_subgraph(
visible_post_ids,
)
person_ids = [row["person_id"] for row in person_rows]
- if not person_ids:
+ team_rows = await conn.fetch(
+ "select distinct team_id from post_team_mention "
+ "where post_id = any($1::uuid[])",
+ visible_post_ids,
+ )
+ team_ids = [row["team_id"] for row in team_rows]
+ organization_rows = await conn.fetch(
+ "select distinct corporate_entity_id from post_organization_mention "
+ "where post_id = any($1::uuid[])",
+ visible_post_ids,
+ )
+ organization_ids = [row["corporate_entity_id"] for row in organization_rows]
+ if not person_ids and not team_ids and not organization_ids:
return []
rows = await conn.fetch(
"""
@@ -326,6 +382,48 @@ async def load_visible_subgraph(
and edge.target_node_id = any($2::uuid[]))
)
)
+ or (
+ edge.edge_type_code = $8
+ and (
+ (edge.source_node_type_code = $4
+ and edge.source_node_id = any($1::uuid[]))
+ or
+ (edge.target_node_type_code = $4
+ and edge.target_node_id = any($1::uuid[]))
+ or
+ (edge.source_node_type_code = $9
+ and edge.source_node_id = any($10::uuid[]))
+ or
+ (edge.target_node_type_code = $9
+ and edge.target_node_id = any($10::uuid[]))
+ )
+ )
+ or (
+ edge.edge_type_code = $11
+ and (
+ (edge.source_node_type_code = $9
+ and edge.source_node_id = any($10::uuid[]))
+ or
+ (edge.target_node_type_code = $9
+ and edge.target_node_id = any($10::uuid[]))
+ )
+ )
+ or (
+ edge.edge_type_code = $12
+ and (
+ (edge.source_node_type_code = $4
+ and edge.source_node_id = any($1::uuid[]))
+ or
+ (edge.target_node_type_code = $4
+ and edge.target_node_id = any($1::uuid[]))
+ or
+ (edge.source_node_type_code = $13
+ and edge.source_node_id = any($14::uuid[]))
+ or
+ (edge.target_node_type_code = $13
+ and edge.target_node_id = any($14::uuid[]))
+ )
+ )
""",
visible_post_ids,
person_ids,
@@ -334,6 +432,13 @@ async def load_visible_subgraph(
EDGE_CO_MENTION,
NODE_PERSON,
EDGE_AFFILIATION,
+ EDGE_MENTION_TEAM,
+ NODE_TEAM,
+ team_ids,
+ EDGE_TEAM_AFFILIATION,
+ EDGE_MENTION_ORGANIZATION,
+ NODE_CORPORATE_ENTITY,
+ organization_ids,
)
return [edge_spec_from_row(row) for row in rows]
@@ -349,6 +454,7 @@ async def hydrate_related_nodes(
person_ids: list[str] = []
post_ids: list[str] = []
corp_ids: list[str] = []
+ team_ids: list[str] = []
parsed: list[tuple[str, str, float]] = []
for key, score in related:
node_type_code, node_id = parse_node_key(key)
@@ -359,6 +465,8 @@ async def hydrate_related_nodes(
post_ids.append(node_id)
elif node_type_code == NODE_CORPORATE_ENTITY:
corp_ids.append(node_id)
+ elif node_type_code == NODE_TEAM:
+ team_ids.append(node_id)
people = {
str(row["person_id"]): row
@@ -381,6 +489,13 @@ async def hydrate_related_nodes(
corp_ids,
)
} if corp_ids else {}
+ teams = {
+ str(row["team_id"]): row
+ for row in await conn.fetch(
+ "select team_id, team_name from cataloged_team where team_id = any($1::uuid[])",
+ team_ids,
+ )
+ } if team_ids else {}
side_labels = await labels_for_codes(
conn, [row["person_side_code"] for row in people.values()]
@@ -403,6 +518,8 @@ async def hydrate_related_nodes(
item["label"] = posts[node_id]["post_title"]
elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps:
item["label"] = corps[node_id]["entity_name"]
+ elif node_type_code == NODE_TEAM and node_id in teams:
+ item["label"] = teams[node_id]["team_name"]
else:
continue
payload.append(item)
@@ -439,3 +556,12 @@ async def related_for_entity(
) -> list[dict[str, Any]]:
"""Run RWR from ``entity_id`` over the account's visible subgraph."""
return await related_for_start(conn, NODE_CORPORATE_ENTITY, entity_id, visible_post_ids)
+
+
+async def related_for_team(
+ conn: asyncpg.Connection,
+ team_id: str,
+ visible_post_ids: list[str],
+) -> list[dict[str, Any]]:
+ """Run RWR from ``team_id`` over the account's visible subgraph."""
+ return await related_for_start(conn, NODE_TEAM, team_id, visible_post_ids)
diff --git a/backend/app/main.py b/backend/app/main.py
index de06a1167..adb7a20a8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -116,8 +116,11 @@
persist_edges_for_post,
related_for_entity,
related_for_person,
+ related_for_team,
+ team_exists,
visible_affiliation_post_ids,
visible_mention_post_ids,
+ visible_team_mention_post_ids,
)
from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph
from backend.app.post_chat_ingestion import (
@@ -473,6 +476,34 @@ async def read_related_corporate_entity(
}
+@app.get("/api/teams/{team_id}/related")
+async def read_related_team(
+ team_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """RWR-ranked related nodes from one cataloged team, hiding unseen posts."""
+ _require_post_read(account)
+ async with pool.acquire() as conn:
+ if not await team_exists(conn, team_id):
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "team not found")
+ visible_post_ids = await visible_team_mention_post_ids(
+ conn, team_id, lambda row: _can_see_post(account, row)
+ )
+ if not visible_post_ids:
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team")
+ team = await conn.fetchrow(
+ "select team_id, team_name from cataloged_team where team_id = $1",
+ team_id,
+ )
+ related = await related_for_team(conn, team_id, visible_post_ids)
+ return {
+ "team_id": str(team["team_id"]),
+ "team_name": team["team_name"],
+ "related": related,
+ }
+
+
@app.get("/api/posts/{post_id}/counterparties")
async def read_post_counterparties(
post_id: str,
diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py
index c79d036d2..36d4fadae 100644
--- a/backend/app/post_summary_ingestion.py
+++ b/backend/app/post_summary_ingestion.py
@@ -34,6 +34,7 @@
NullCorporateHierarchyInferenceClient,
)
from lineageweave.fixtures import fixture_thread_cast
+from lineageweave.knowledge_graph import NODE_CORPORATE_ENTITY, NODE_TEAM
from lineageweave.ontology import ontology_annotations
from lineageweave.post_summary import (
ACTOR_TYPE_ORGANIZATION,
@@ -68,24 +69,57 @@ async def fetch_persisted_summary(
post_id,
)
roles = await conn.fetch(
- "select actor_name, responsibility, actor_type_code, affiliated_organization_name "
- "from post_summary_role where post_id = $1 order by actor_name",
+ """
+ select role.actor_name, role.responsibility, role.actor_type_code,
+ role.affiliated_organization_name,
+ team_mention.team_id,
+ org_mention.corporate_entity_id
+ from post_summary_role role
+ left join cataloged_team team
+ on role.actor_type_code = 'prov_team'
+ and team.team_name = role.actor_name
+ and team.affiliated_organization_name
+ is not distinct from role.affiliated_organization_name
+ left join post_team_mention team_mention
+ on team_mention.post_id = role.post_id
+ and team_mention.team_id = team.team_id
+ left join corporate_entity org
+ on role.actor_type_code = 'prov_organization'
+ and org.entity_name = role.actor_name
+ left join post_organization_mention org_mention
+ on org_mention.post_id = role.post_id
+ and org_mention.corporate_entity_id = org.corporate_entity_id
+ where role.post_id = $1
+ order by role.actor_name
+ """,
post_id,
)
- return {
- "post_id": post_id,
- "korean_summary": header["korean_summary"],
- "key_events": [row["event_text"] for row in events],
- "roles_and_responsibilities": [
+ payload_roles: list[dict[str, Any]] = []
+ for row in roles:
+ catalog_node_id = None
+ catalog_node_type_code = None
+ if row["team_id"] is not None:
+ catalog_node_id = str(row["team_id"])
+ catalog_node_type_code = NODE_TEAM
+ elif row["corporate_entity_id"] is not None:
+ catalog_node_id = str(row["corporate_entity_id"])
+ catalog_node_type_code = NODE_CORPORATE_ENTITY
+ payload_roles.append(
{
"actor_name": row["actor_name"],
"responsibility": row["responsibility"],
"actor_type_code": row["actor_type_code"],
"affiliated_organization_name": row["affiliated_organization_name"],
+ "catalog_node_id": catalog_node_id,
+ "catalog_node_type_code": catalog_node_type_code,
**ontology_annotations(row["actor_type_code"]),
}
- for row in roles
- ],
+ )
+ return {
+ "post_id": post_id,
+ "korean_summary": header["korean_summary"],
+ "key_events": [row["event_text"] for row in events],
+ "roles_and_responsibilities": payload_roles,
}
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index e62ff0fa1..bff7f7c64 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -22,6 +22,7 @@
import redis
from lineageweave.http_client import HttpClientError, get_json, post_form
+from lineageweave.knowledge_graph import knowledge_graph_edges_for_post
_POSTGRES_ADMIN_DSN = os.environ.get(
"LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave"
@@ -1450,6 +1451,161 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary:
assert mentioning_post_count == 2, "both posts must link to the single cataloged team"
assert team_mention_edge_count == 2, "each post's mention must become a real KG edge"
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute("select team_id from cataloged_team where team_name = '설계팀'")
+ team_id = str(cur.fetchone()[0])
+ finally:
+ admin_conn.close()
+
+ related = client.get(
+ f"/api/teams/{team_id}/related",
+ headers=headers,
+ )
+ assert related.status_code == 200, related.text
+ related_ids = {node["node_id"] for node in related.json()["related"]}
+ assert set(post_ids) <= related_ids
+ summaries = [
+ client.get(f"/api/posts/{post_id}/summary", headers=headers).json()
+ for post_id in post_ids
+ ]
+ for body in summaries:
+ role = body["roles_and_responsibilities"][0]
+ assert role["catalog_node_id"] == team_id
+ assert role["catalog_node_type_code"] == "node_team"
+
+
+def test_organization_mention_only_posts_appear_in_entity_related(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """An org mentioned with no affiliated person must still start a related walk."""
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) "
+ "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' "
+ "from source_post where post_id = %s returning post_id",
+ ("Org-only mention", "Test Corp was named without a person.", seeded_db["own_private_post_id"]),
+ )
+ org_only_post_id = str(cur.fetchone()[0])
+ cur.execute(
+ "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)",
+ (org_only_post_id, seeded_db["own_corp_id"]),
+ )
+ for edge in knowledge_graph_edges_for_post(
+ org_only_post_id,
+ [],
+ organization_corporate_entity_ids=[seeded_db["own_corp_id"]],
+ ):
+ cur.execute(
+ "insert into knowledge_graph_edge ("
+ "source_node_type_code, source_node_id, target_node_type_code, "
+ "target_node_id, edge_type_code, edge_weight"
+ ") values (%s, %s, %s, %s, %s, %s) "
+ "on conflict do nothing",
+ (
+ edge.source_node_type_code,
+ edge.source_node_id,
+ edge.target_node_type_code,
+ edge.target_node_id,
+ edge.edge_type_code,
+ edge.edge_weight,
+ ),
+ )
+ finally:
+ admin_conn.close()
+
+ response = client.get(
+ f"/api/corporate-entities/{seeded_db['own_corp_id']}/related",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ related_ids = {node["node_id"] for node in response.json()["related"]}
+ assert org_only_post_id in related_ids
+
+
+def test_thread_group_run_list_honors_knowledge_cutoff(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """A later public post must not surface a previously hidden thread-group run."""
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, thread_group_key, created_at) "
+ "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public', %s, %s "
+ "from source_post where post_id = %s",
+ (
+ "Late thread-group post",
+ "Written after the January cutoff.",
+ "late-thread-group",
+ "2026-01-20T12:00:00Z",
+ seeded_db["own_private_post_id"],
+ ),
+ )
+ cur.execute(
+ """
+ insert into analysis_source_snapshot
+ (snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at)
+ values (%s, 'source-contract-v1',
+ '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z')
+ returning analysis_source_snapshot_id
+ """,
+ ("f" * 64,),
+ )
+ snapshot_id = cur.fetchone()[0]
+ cur.execute(
+ """
+ insert into analysis_run
+ (analysis_source_snapshot_id, run_kind_code, idempotency_key,
+ requested_by_account_id, knowledge_cutoff,
+ configuration_schema_version, configuration_sha256,
+ code_revision_sha, requested_at)
+ values (%s, 'analysis_run_lineage', %s,
+ (select user_account_id from user_account
+ where email_address = 'other.analyst@example.test'),
+ '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s,
+ '2026-01-12T12:30:00Z')
+ returning analysis_run_id
+ """,
+ (snapshot_id, "hidden-late-thread", "b" * 64, "c" * 40),
+ )
+ run_id = str(cur.fetchone()[0])
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, scope_key)
+ values (%s, 'analysis_scope_thread_group', 'late-thread-group')
+ """,
+ (run_id,),
+ )
+ cur.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values (%s, 1, 'analysis_status_succeeded', '2026-01-12T12:33:00Z')
+ """,
+ (run_id,),
+ )
+ finally:
+ admin_conn.close()
+
+ listed = client.get(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert listed.status_code == 200
+ ids = {run["analysis_run_id"] for run in listed.json()["analysis_runs"]}
+ assert run_id not in ids
+ assert seeded_db["visible_run_id"] in ids
+
def test_first_mention_of_a_new_counterparty_creates_a_real_corporate_entity(
client, demo_analyst_token, seeded_db, monkeypatch
diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
index f9c82a86d..089443374 100644
--- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -49,6 +49,9 @@ run.
- Hover a digest prefix to read the full code or configuration digest
when you need to match the API payload.
- Post-body versioning at the cutoff remains future work.
+- Thread-group *run list* visibility now uses the same cutoff
+ (ADR 0018). A later public post cannot surface a previously hidden
+ thread-group run.
## References
diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md
new file mode 100644
index 000000000..17e4236a6
--- /dev/null
+++ b/docs/adr/0018-related-nodes-team-org-walk.md
@@ -0,0 +1,67 @@
+# ADR 0018 — Related-node walks include team and organization mention edges
+
+**Decision status:** Accepted
+**Date:** 2026-08-16
+
+## Context
+
+ADR 0009 persists `edge_mention_team`, `edge_team_affiliation`, and
+`edge_mention_organization` so a cataloged team or organization can
+become a cross-post Knowledge Graph clue. The buyer-visible related-node
+walk (`load_visible_subgraph` + Tong et al., 2006 random walk with
+restart) still loaded only person mention, co-mention, and affiliation
+edges, and returned an empty graph when a visible post had no people.
+A team-only follow-up therefore never appeared as a related node, and
+clicking an R&R team name had no catalog id to start a walk.
+
+The same temporal honesty ADR 0016 applied to run *detail* posts was
+still missing from thread-group *run list* visibility: a later public
+post in that thread group could surface a run the account was not
+allowed to know at `knowledge_cutoff`.
+
+ADR 0017 already records an authorized Pending analysis-run write.
+This decision is the related-node walk, not that create path.
+
+## Decision
+
+`load_visible_subgraph` loads person, team, and organization mention
+channels independently. Empty person evidence is not a reason to drop
+team or organization edges. `hydrate_related_nodes` labels
+`cataloged_team` rows. `GET /api/teams/{team_id}/related` starts the
+same RWR walk Keyman and corporate-entity related already use.
+`visible_affiliation_post_ids` unions direct `post_organization_mention`
+rows with person-affiliation posts so an org-only mention can start a
+walk.
+
+The summary payload exposes `catalog_node_id` / `catalog_node_type_code`
+when the R&R actor resolved to a team or organization mention on that
+post. The popup turns that name into a related-node button.
+
+Thread-group run list visibility requires at least one ABAC-visible
+`source_post` whose `created_at` is at or before `knowledge_cutoff`.
+
+## Consequences
+
+- Open a post whose R&R names 설계팀, then click the team. Sibling posts
+ that mention the same cataloged team appear as related nodes.
+- Click a related team chip the same way you already click a person or
+ organization chip.
+- A later public post in a thread group no longer lists a January run
+ that could not have known that post.
+
+## References
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web
+Consortium. https://www.w3.org/TR/vocab-org/
+
+Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with
+restart and its applications. *Proceedings of the Sixth International
+Conference on Data Mining (ICDM'06)*, 613–622.
+https://doi.org/10.1109/ICDM.2006.70
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md
new file mode 100644
index 000000000..4ecb8fe82
--- /dev/null
+++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md
@@ -0,0 +1,19 @@
+# Related-node team and organization walk — doctoring
+
+These are the standards and papers that ground ADR 0018. Cite them in
+APA 7th when you extend the walk or the catalog identity layer.
+
+Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web
+Consortium. https://www.w3.org/TR/vocab-org/
+
+Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with
+restart and its applications. *Proceedings of the Sixth International
+Conference on Data Mining (ICDM'06)*, 613–622.
+https://doi.org/10.1109/ICDM.2006.70
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/frontend/package.json b/frontend/package.json
index c8f67bc8d..dac241738 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.85.0",
+ "version": "0.86.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index dae8e1674..934f150e4 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -649,6 +649,14 @@ describe("App, authenticated", () => {
actor_type_code: "prov_organization",
affiliated_organization_name: null,
},
+ {
+ actor_name: "설계팀",
+ responsibility: "도면 검토",
+ actor_type_code: "prov_team",
+ affiliated_organization_name: "Demo Corp",
+ catalog_node_id: "team-1",
+ catalog_node_type_code: "node_team",
+ },
],
}),
);
@@ -750,6 +758,32 @@ describe("App, authenticated", () => {
label: "Demo Corp",
relevance: 0.2,
},
+ {
+ node_id: "team-1",
+ node_type_code: "node_team",
+ ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Team",
+ ontology_label: "Team",
+ label: "설계팀",
+ relevance: 0.15,
+ },
+ ],
+ }),
+ );
+ }
+ if (url.endsWith("/api/teams/team-1/related")) {
+ return Promise.resolve(
+ jsonResponse({
+ team_id: "team-1",
+ team_name: "설계팀",
+ related: [
+ {
+ node_id: "post-2",
+ node_type_code: "node_post",
+ ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post",
+ ontology_label: "Post",
+ label: "Linked post",
+ relevance: 0.6,
+ },
],
}),
);
@@ -1201,6 +1235,30 @@ describe("App, authenticated", () => {
);
});
+ it("opens related nodes from an R&R team", async () => {
+ stubBackend();
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(await screen.findByRole("button", { name: "R&R team: 설계팀" }));
+ await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument());
+ expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent(
+ "Linked post",
+ );
+ });
+
+ it("opens related nodes from a related team chip", async () => {
+ stubBackend();
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" }));
+ await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument());
+ await userEvent.click(screen.getByRole("button", { name: "Related nodes for 설계팀" }));
+ await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument());
+ expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent(
+ "Linked post",
+ );
+ });
+
it("opens related nodes from a related corporate entity", async () => {
stubBackend();
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e3ddce1ac..83fb88860 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, type ReactNode } from "react";
import { useAuth } from "react-oidc-context";
import {
askPostChat,
@@ -30,6 +30,7 @@ import {
fetchPosts,
fetchRelatedEntity,
fetchRelatedKeymen,
+ fetchRelatedTeam,
rebuildLineage,
rebuildPeriodReports,
updateTicketStatus,
@@ -54,6 +55,7 @@ import {
type PostLineage,
type PostSummary,
type RelatedNode,
+ type RelatedNodeType,
type VocEvidence,
} from "./api";
import { LineageDag } from "./LineageDag";
@@ -470,6 +472,15 @@ function VocEvidenceSection({
}
const NODE_PERSON = "node_person";
+const NODE_POST = "node_post";
+const NODE_CORPORATE_ENTITY = "node_corporate_entity";
+const NODE_TEAM = "node_team";
+
+const KNOWN_RELATED_NODE_TYPES = [NODE_PERSON, NODE_POST, NODE_CORPORATE_ENTITY, NODE_TEAM] as const;
+
+function isKnownRelatedNodeType(code: string): code is RelatedNodeType {
+ return (KNOWN_RELATED_NODE_TYPES as readonly string[]).includes(code);
+}
function relatedNodeCaption(node: RelatedNode): string {
const name = node.label ?? node.node_id;
@@ -482,9 +493,6 @@ function relatedNodeCaption(node: RelatedNode): string {
return `${name} (${node.ontology_label ?? node.node_type_code})`;
}
-const NODE_POST = "node_post";
-const NODE_CORPORATE_ENTITY = "node_corporate_entity";
-
const VERIFICATION_BADGE: Record = {
verify_pending: "Not yet checked",
verify_corroborated: "Corroborated",
@@ -539,6 +547,7 @@ function KeymanPanel({
onSelectPost,
focusPerson,
focusEntity,
+ focusTeam,
}: {
postId: string;
accessToken: string;
@@ -548,6 +557,7 @@ function KeymanPanel({
onSelectPost?: (postId: string) => void;
focusPerson?: { personId: string; personName: string } | null;
focusEntity?: { entityId: string; entityName: string } | null;
+ focusTeam?: { teamId: string; teamName: string } | null;
}) {
const [related, setRelated] = useState(null);
const [selectedName, setSelectedName] = useState(null);
@@ -585,6 +595,18 @@ function KeymanPanel({
}
}
+ async function handleSelectTeam(teamId: string, teamName: string) {
+ const requestId = ++relatedRequest.current;
+ setSelectedName(teamName);
+ setRelated(null);
+ try {
+ const result = await fetchRelatedTeam(accessToken, teamId);
+ if (requestId === relatedRequest.current) setRelated(result.related);
+ } catch {
+ if (requestId === relatedRequest.current) setRelated([]);
+ }
+ }
+
useEffect(() => {
if (!focusPerson) return;
const requestId = ++relatedRequest.current;
@@ -613,6 +635,20 @@ function KeymanPanel({
});
}, [accessToken, focusEntity]);
+ useEffect(() => {
+ if (!focusTeam) return;
+ const requestId = ++relatedRequest.current;
+ setSelectedName(focusTeam.teamName);
+ setRelated(null);
+ fetchRelatedTeam(accessToken, focusTeam.teamId)
+ .then((result) => {
+ if (requestId === relatedRequest.current) setRelated(result.related);
+ })
+ .catch(() => {
+ if (requestId === relatedRequest.current) setRelated([]);
+ });
+ }, [accessToken, focusTeam]);
+
async function handleExtract() {
setExtracting(true);
setError(null);
@@ -700,48 +736,67 @@ function KeymanPanel({
{related.map((node) => {
const caption = relatedNodeCaption(node);
- if (node.node_type_code === NODE_POST && onSelectPost) {
- return (
- -
-
-
- );
+ const key = `${node.node_type_code}:${node.node_id}`;
+ if (!isKnownRelatedNodeType(node.node_type_code)) {
+ return - {caption}
;
}
- if (node.node_type_code === NODE_PERSON) {
- return (
- -
-
-
- );
- }
- if (node.node_type_code === NODE_CORPORATE_ENTITY) {
- return (
- -
-
-
- );
+ switch (node.node_type_code) {
+ case NODE_POST:
+ if (!onSelectPost) {
+ return - {caption}
;
+ }
+ return (
+ -
+
+
+ );
+ case NODE_PERSON:
+ return (
+ -
+
+
+ );
+ case NODE_CORPORATE_ENTITY:
+ return (
+ -
+
+
+ );
+ case NODE_TEAM:
+ return (
+ -
+
+
+ );
+ default: {
+ const _exhaustive: never = node.node_type_code;
+ return - {_exhaustive}
;
+ }
}
- return (
- - {caption}
- );
})}
)}
@@ -1128,6 +1183,7 @@ function PostDetailPopup({
const [evaluation, setEvaluation] = useState(null);
const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null);
const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null);
+ const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null);
function reloadKeymen() {
fetchPostKeymen(accessToken, postId).then((r) => setKeymen(r.keymen)).catch(() => setKeymen([]));
@@ -1156,6 +1212,7 @@ function PostDetailPopup({
setEvaluation(null);
setFocusPerson(null);
setFocusEntity(null);
+ setFocusTeam(null);
fetchPost(accessToken, postId).then(setPost).catch((err) => setError(String(err)));
fetchPostEvaluation(accessToken, postId)
.then((r) => setEvaluation(r.responses))
@@ -1220,28 +1277,61 @@ function PostDetailPopup({
const person = isPerson
? keymen?.find((row) => row.person_name === rr.actor_name)
: undefined;
+ const catalogId = rr.catalog_node_id;
+ const catalogType = rr.catalog_node_type_code;
+ let actorName: ReactNode = {rr.actor_name};
+ if (person) {
+ actorName = (
+
+ );
+ } else if (catalogType === NODE_TEAM && catalogId) {
+ actorName = (
+
+ );
+ } else if (catalogType === NODE_CORPORATE_ENTITY && catalogId) {
+ actorName = (
+
+ );
+ }
return (
{actorTypeLabel}
{" "}
- {person ? (
-
- ) : (
- {rr.actor_name}
- )}
+ {actorName}
{rr.affiliated_organization_name && (
({rr.affiliated_organization_name})
)}
@@ -1271,6 +1361,7 @@ function PostDetailPopup({
affiliateTrees={affiliateTrees}
onSelectPerson={(personId, personName) => {
setFocusEntity(null);
+ setFocusTeam(null);
setFocusPerson({ personId, personName });
}}
/>
@@ -1299,10 +1390,12 @@ function PostDetailPopup({
node={node}
onSelectPerson={(personId, personName) => {
setFocusEntity(null);
+ setFocusTeam(null);
setFocusPerson({ personId, personName });
}}
onSelectEntity={(entityId, entityName) => {
setFocusPerson(null);
+ setFocusTeam(null);
setFocusEntity({ entityId, entityName });
}}
/>
@@ -1320,6 +1413,7 @@ function PostDetailPopup({
onSelectPost={onSelectPost}
focusPerson={focusPerson}
focusEntity={focusEntity}
+ focusTeam={focusTeam}
/>
{counterparties && counterparties.length > 0 && (
@@ -1331,6 +1425,7 @@ function PostDetailPopup({
onVerified={reloadCounterparties}
onSelectEntity={(entityId, entityName) => {
setFocusPerson(null);
+ setFocusTeam(null);
setFocusEntity({ entityId, entityName });
}}
/>
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index e35bcf3ed..f9bd4068e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -73,9 +73,15 @@ export interface VocEvidence {
counterparties: VocEvidenceCounterparty[];
}
+export type RelatedNodeType =
+ | "node_person"
+ | "node_post"
+ | "node_corporate_entity"
+ | "node_team";
+
export interface RelatedNode {
node_id: string;
- node_type_code: string;
+ node_type_code: RelatedNodeType | string;
relevance: number;
label?: string;
person_side_code?: string;
@@ -89,6 +95,8 @@ export interface PostRoleResponsibility {
responsibility: string;
actor_type_code: string;
affiliated_organization_name: string | null;
+ catalog_node_id?: string | null;
+ catalog_node_type_code?: string | null;
}
export interface PostAiSummary {
@@ -284,6 +292,13 @@ export function fetchRelatedEntity(
return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken);
}
+export function fetchRelatedTeam(
+ accessToken: string,
+ teamId: string,
+): Promise<{ team_id: string; team_name: string; related: RelatedNode[] }> {
+ return backendFetch(`/api/teams/${teamId}/related`, accessToken);
+}
+
export function extractPostKeymen(
accessToken: string,
postId: string,
diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py
index 5e05ef4ff..5f70c6064 100644
--- a/lineageweave/__init__.py
+++ b/lineageweave/__init__.py
@@ -55,4 +55,4 @@
"sentence_excerpts",
]
-__version__ = "0.85.0"
+__version__ = "0.86.0"
diff --git a/pyproject.toml b/pyproject.toml
index 8750ae3e3..0393b774a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "0.85.0"
+version = "0.86.0"
description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication."
readme = "README.md"
license = { text = "MIT" }
diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py
index 8aad4f6a8..5353c2a91 100644
--- a/tests/test_person_mention_projection.py
+++ b/tests/test_person_mention_projection.py
@@ -21,13 +21,21 @@
from backend.app.keyman_ingestion import ingest_post_keymen
from backend.app.knowledge_graph import (
+ hydrate_related_nodes,
load_visible_subgraph,
persist_edges_for_post,
+ related_for_start,
visible_mention_post_ids,
)
from backend.app.post_summary_ingestion import persist_post_summary
from lineageweave.keyman_extraction import OUR_SIDE, PersonMention
-from lineageweave.knowledge_graph import EDGE_MENTION, NODE_PERSON, NODE_POST
+from lineageweave.knowledge_graph import (
+ EDGE_MENTION,
+ EDGE_MENTION_TEAM,
+ NODE_PERSON,
+ NODE_POST,
+ NODE_TEAM,
+)
from lineageweave.post_summary import PostSummary, RoleResponsibility
_ADMIN_DSN = os.environ.get(
@@ -365,3 +373,81 @@ def test_cross_post_identity_upgrade_keeps_keyman_mention_context(
assert keyman_row is not None
assert keyman_row[0] == "Keyman extracted this mention from the synthetic body"
assert summary_count == 1
+
+
+async def _exercise_team_only_related_walk(
+ database_dsn: str,
+ first_post_id: str,
+) -> None:
+ """A team mentioned on two posts must walk even when one post has no people."""
+
+ connection = await asyncpg.connect(database_dsn)
+ try:
+ author_id, corporate_entity_id = await connection.fetchrow(
+ "select author_account_id, corporate_entity_id from source_post where post_id = $1",
+ first_post_id,
+ )
+ second_post_id = str(
+ await connection.fetchval(
+ """
+ insert into source_post
+ (author_account_id, corporate_entity_id, post_title, post_body,
+ voc_type_code, visibility_code)
+ values ($1, $2, 'Team-only follow-up', '설계팀이 도면을 재검토했다.',
+ 'voc', 'public')
+ returning post_id
+ """,
+ author_id,
+ corporate_entity_id,
+ )
+ )
+ team_id = str(
+ await connection.fetchval(
+ """
+ insert into cataloged_team (team_name, affiliated_organization_name)
+ values ('설계팀', 'Synthetic Corp')
+ returning team_id
+ """
+ )
+ )
+ await connection.execute(
+ """
+ insert into post_team_mention (post_id, team_id)
+ values ($1, $2), ($3, $2)
+ """,
+ first_post_id,
+ team_id,
+ second_post_id,
+ )
+ async with connection.transaction():
+ await persist_edges_for_post(connection, first_post_id)
+ await persist_edges_for_post(connection, second_post_id)
+
+ team_only_edges = await load_visible_subgraph(connection, [second_post_id])
+ assert any(
+ edge.edge_type_code == EDGE_MENTION_TEAM
+ and edge.source_node_id == team_id
+ and edge.target_node_id == second_post_id
+ for edge in team_only_edges
+ ), "a team-only post must still load its mention edge"
+
+ related = await related_for_start(
+ connection, NODE_TEAM, team_id, [first_post_id, second_post_id]
+ )
+ related_ids = {node["node_id"] for node in related}
+ assert first_post_id in related_ids
+ assert second_post_id in related_ids
+ hydrated = await hydrate_related_nodes(
+ connection, [(f"{NODE_TEAM}:{team_id}", 1.0)]
+ )
+ assert hydrated[0]["label"] == "설계팀"
+ assert hydrated[0]["node_type_code"] == NODE_TEAM
+ finally:
+ await connection.close()
+
+
+def test_team_only_posts_walk_related_nodes(projection_database: str) -> None:
+ """ADR 0018: team mention edges must participate in the visible RWR walk."""
+
+ database_dsn, post_id, _summary_person_id = projection_database.split("|")
+ asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id))
diff --git a/uv.lock b/uv.lock
index 20f9a8793..b302b1250 100644
--- a/uv.lock
+++ b/uv.lock
@@ -454,7 +454,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "0.85.0"
+version = "0.86.0"
source = { virtual = "." }
dependencies = [
{ name = "certifi" },