Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -849,8 +849,9 @@ new table needed. `lineageweave/knowledge_graph.py`'s
`knowledge_graph_edges_for_post` extended with three new edge kinds
(`edge_mention_team`, `edge_team_affiliation`, `edge_mention_organization`);
`backend/app/post_summary_ingestion.py`'s `persist_post_summary` now
resolves each R&R actor's identity and calls the same
`persist_edges_for_post` Keyman ingestion already uses. A person R&R
resolves each R&R actor's identity, stores that id on
`post_summary_role` (ADR 0019 — `entity_name` is not unique), and calls
the same `persist_edges_for_post` Keyman ingestion already uses. A person R&R
actor is opportunistically joined to an existing `cataloged_person` row
by name (never originated by R&R itself -- documented gap in the ADR:
`cataloged_person` needs `person_side_code`, which R&R's prompt does
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.d/0.86.2-role-catalog-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
R&R organization buttons walk the catalog id stored on the role row. A
shared display name no longer attaches a homonym. Team related matches
person/entity 403/404.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ 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.2] - 2026-08-16

### Fixed

- An R&R organization button now walks the catalog id stored on that
role row (ADR 0019). Two catalog orgs can share a display name; open
the post, click the name, and you stay on the resolved org — not a
homonym. `GET /api/teams/{id}/related` matches person/entity authz:
another corp's private-only team is 403; an unknown UUID is 404.

## [0.86.1] - 2026-08-16

### Changed
Expand Down
99 changes: 49 additions & 50 deletions backend/app/post_summary_ingestion.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Persist and load the popup's Korean summary / key events / R&R.

ADR 0009: an R&R actor is not just per-post free text -- when it is a
team or organization, it is resolved to a shared catalog identity
(``cataloged_team`` / ``corporate_entity``) and a Knowledge Graph
mention edge is written, so the same "설계팀" or organization named
across two posts becomes one linkable node, not two unrelated strings.
ADR 0009 / 0019: an R&R actor is not just per-post free text -- when it
is a team or organization, it is resolved to a shared catalog identity
(``cataloged_team`` / ``corporate_entity``) stored on the role row and
a Knowledge Graph mention edge is written, so the same "설계팀" or
organization named across two posts becomes one linkable node. Fetch
never reconstructs that id by ``entity_name``; that column is not unique.
A person actor is opportunistically joined to an *existing*
``cataloged_person`` row by name when Keyman extraction has already
cataloged that name. The R&R evidence is written to
Expand Down Expand Up @@ -57,7 +58,12 @@
async def fetch_persisted_summary(
conn: asyncpg.Connection, post_id: str
) -> dict[str, Any] | None:
"""Return the stored summary payload, or None when none has been written."""
"""Return the stored summary payload, or None when none has been written.

``catalog_node_id`` comes from the role row's catalog foreign keys
(ADR 0019). This function does not join ``corporate_entity`` by
``entity_name``.
"""
header = await conn.fetchrow(
"select korean_summary from post_summary_result where post_id = $1",
post_id,
Expand All @@ -72,23 +78,9 @@ async def fetch_persisted_summary(
"""
select role.actor_name, role.responsibility, role.actor_type_code,
role.affiliated_organization_name,
team_mention.team_id,
org_mention.corporate_entity_id
role.cataloged_team_id,
role.cataloged_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
""",
Expand All @@ -98,11 +90,11 @@ async def fetch_persisted_summary(
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"])
if row["cataloged_team_id"] is not None:
catalog_node_id = str(row["cataloged_team_id"])
catalog_node_type_code = NODE_TEAM
elif row["corporate_entity_id"] is not None:
catalog_node_id = str(row["corporate_entity_id"])
elif row["cataloged_corporate_entity_id"] is not None:
catalog_node_id = str(row["cataloged_corporate_entity_id"])
catalog_node_type_code = NODE_CORPORATE_ENTITY
payload_roles.append(
{
Expand Down Expand Up @@ -218,44 +210,51 @@ async def _replace_summary_projection(
ordinal,
event_text,
)
for role in summary.roles_and_responsibilities:
# ADR 0009 / 0019: resolve catalog identity before writing the role
# row so fetch never reconstructs it by a non-unique name.
for role_index, role in enumerate(summary.roles_and_responsibilities):
cataloged_team_id = None
cataloged_corporate_entity_id = None
if role.actor_type_code == ACTOR_TYPE_TEAM:
cataloged_team_id = await upsert_team(
conn,
role.actor_name,
role.affiliated_organization_name,
candidates,
)
elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION:
cataloged_corporate_entity_id = resolved_organization_ids.get(
role_index
)
await conn.execute(
"insert into post_summary_role "
"(post_id, actor_name, responsibility, actor_type_code, "
"affiliated_organization_name) values ($1, $2, $3, $4, $5)",
"affiliated_organization_name, cataloged_team_id, "
"cataloged_corporate_entity_id) values "
"($1, $2, $3, $4, $5, $6, $7)",
post_id,
role.actor_name,
role.responsibility,
role.actor_type_code,
role.affiliated_organization_name,
cataloged_team_id,
cataloged_corporate_entity_id,
)

# ADR 0009: cross-post identity resolution for team/organization/person
# actors -- see module docstring.
for role_index, role in enumerate(summary.roles_and_responsibilities):
if role.actor_type_code == ACTOR_TYPE_TEAM:
team_id = await upsert_team(
conn,
role.actor_name,
role.affiliated_organization_name,
candidates,
)
if cataloged_team_id is not None:
await conn.execute(
"insert into post_team_mention (post_id, team_id) values ($1, $2) "
"on conflict do nothing",
post_id,
team_id,
cataloged_team_id,
)
elif cataloged_corporate_entity_id is not None:
await conn.execute(
"insert into post_organization_mention "
"(post_id, corporate_entity_id) values ($1, $2) "
"on conflict do nothing",
post_id,
cataloged_corporate_entity_id,
)
elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION:
corporate_entity_id = resolved_organization_ids.get(role_index)
if corporate_entity_id is not None:
await conn.execute(
"insert into post_organization_mention "
"(post_id, corporate_entity_id) values ($1, $2) "
"on conflict do nothing",
post_id,
corporate_entity_id,
)
elif role.actor_type_code == ACTOR_TYPE_PERSON:
person_row = await conn.fetchrow(
"select person_id from cataloged_person where person_name = $1 limit 1",
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Person join is still LIMIT 1 with no ORDER BY. cataloged_person.person_name is not unique. Keep this on #151 / #153 — do not add cataloged_person_id to a second 0019.

Expand Down
97 changes: 97 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,44 @@ def test_unknown_corporate_entity_related_is_not_found(
assert response.status_code == 404


def test_team_only_on_other_corp_private_post_is_forbidden(
client, demo_analyst_token, seeded_db
) -> None:
"""A team mentioned only on another corp's private post must 403."""

admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
try:
with admin_conn.cursor() as cur:
cur.execute(
"insert into cataloged_team (team_name, affiliated_organization_name) "
"values ('비공개 설계팀', 'Other Corp') returning team_id"
)
team_id = str(cur.fetchone()[0])
cur.execute(
"insert into post_team_mention (post_id, team_id) values (%s, %s)",
(seeded_db["other_private_post_id"], team_id),
)
finally:
admin_conn.close()

response = client.get(
f"/api/teams/{team_id}/related",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert response.status_code == 403


def test_unknown_team_related_is_not_found(client, demo_analyst_token) -> None:
"""An unknown team UUID must 404, matching person and entity related."""

response = client.get(
f"/api/teams/{uuid.uuid4()}/related",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert response.status_code == 404


def test_keyman_only_on_other_corp_private_post_is_forbidden(client, demo_analyst_token, seeded_db) -> None:
response = client.get(
f"/api/keymen/{seeded_db['hidden_person_id']}/related",
Expand Down Expand Up @@ -1528,6 +1566,65 @@ def test_organization_mention_only_posts_appear_in_entity_related(
assert org_only_post_id in related_ids


def test_private_other_corp_organization_mention_does_not_leak(
client, demo_analyst_token, seeded_db
) -> None:
"""The org-mention UNION must still apply ABAC per post."""

admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
try:
with admin_conn.cursor() as cur:
cur.execute(
"insert into post_organization_mention (post_id, corporate_entity_id) "
"values (%s, %s) on conflict do nothing",
(seeded_db["other_private_post_id"], seeded_db["own_corp_id"]),
)
cur.execute(
"insert into post_organization_mention (post_id, corporate_entity_id) "
"values (%s, %s) on conflict do nothing",
(seeded_db["other_private_post_id"], seeded_db["other_corp_id"]),
)
for entity_id in (seeded_db["own_corp_id"], seeded_db["other_corp_id"]):
for edge in knowledge_graph_edges_for_post(
seeded_db["other_private_post_id"],
[],
organization_corporate_entity_ids=[entity_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()

headers = {"Authorization": f"Bearer {demo_analyst_token}"}
own = client.get(
f"/api/corporate-entities/{seeded_db['own_corp_id']}/related",
headers=headers,
)
assert own.status_code == 200, own.text
own_ids = {node["node_id"] for node in own.json()["related"]}
assert seeded_db["other_private_post_id"] not in own_ids

hidden = client.get(
f"/api/corporate-entities/{seeded_db['other_corp_id']}/related",
headers=headers,
)
assert hidden.status_code == 403


def test_thread_group_run_list_honors_knowledge_cutoff(
client, demo_analyst_token, seeded_db
) -> None:
Expand Down
1 change: 1 addition & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ COPY migrations/0015_organization_name_resolution.sql /docker-entrypoint-initdb.
COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/17-cross-post-actor-identity.sql
COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql
COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql
COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql
# Official image already drops to this account at runtime; declare it so
# the Dockerfile itself satisfies DS-0002 (explicit non-root USER).
USER postgres
5 changes: 3 additions & 2 deletions docs/adr/0018-related-nodes-team-org-walk.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ 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.
from the catalog foreign keys stored on `post_summary_role` (ADR 0019).
The popup turns that name into a related-node button. Do not reconstruct
the id by `corporate_entity.entity_name`.

Thread-group run list visibility requires at least one ABAC-visible
`source_post` whose `created_at` is at or before `knowledge_cutoff`.
Expand Down
65 changes: 65 additions & 0 deletions docs/adr/0019-role-catalog-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ADR 0019 — R&R catalog identity lives on the role row

**Decision status:** Accepted
**Date:** 2026-08-16

## Context

ADR 0009 writes `post_team_mention` and `post_organization_mention` so a
cataloged team or organization can start a related-node walk. ADR 0018
exposes `catalog_node_id` on the summary payload by joining those
mentions back to `post_summary_role` through `actor_name`.

`corporate_entity.entity_name` is not unique. Two catalog rows can share
a display name (different `corporate_entity_code`, different parents).
A fetch join on name therefore:

- attaches a homonym that this post never resolved, or
- duplicates the role when more than one same-named row exists.

Mention tables are post-scoped, not role-scoped. They cannot reconstruct
which catalog id was chosen for a specific R&R row. That reconstruction
is a transitive dependency on a non-key attribute, so it is not third
normal form (Codd, 1970; Date, 2019).

Team identity is already unique on
`(team_name, affiliated_organization_name)`. Organization identity is
not.

## Decision

`post_summary_role` stores the resolved catalog foreign keys
(`cataloged_team_id`, `cataloged_corporate_entity_id`) written during
`persist_post_summary`. `fetch_persisted_summary` reads those columns.
It does not join `corporate_entity` by `entity_name`.

Migration `0019_role_catalog_identity.sql` backfills existing rows from
a post-scoped mention only when the name match is unique on that post.
Two same-named mentions stay unbound rather than guessing.

## Consequences

- Open a post whose R&R names an organization that shares a display
name with another catalog row. The button walks the resolved id, not
the homonym.
- Clicking that name still uses `GET /api/corporate-entities/{id}/related`
or `GET /api/teams/{id}/related`. Authz stays person/entity-parity:
a team mentioned only on another corp's private post is 403; an
unknown UUID is 404.

## References

Codd, E. F. (1970). A relational model of data for large shared data
banks. *Communications of the ACM, 13*(6), 377–387.
https://doi.org/10.1145/362384.362685

Date, C. J. (2019). *Database design and relational theory: Normal forms
and all that jazz* (2nd ed.). Apress.
https://doi.org/10.1007/978-1-4842-5540-7

International Organization for Standardization. (2023). *ISO/IEC
11179-1:2023: Information technology—Metadata registries (MDR)—Part 1:
Framework*.

Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web
Consortium. https://www.w3.org/TR/vocab-org/
12 changes: 12 additions & 0 deletions docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,15 @@ rules* (confirmed 2024; Amendment 1:2022).

World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
Recommendation). https://www.w3.org/TR/owl-time/

Codd, E. F. (1970). A relational model of data for large shared data
banks. *Communications of the ACM, 13*(6), 377–387.
https://doi.org/10.1145/362384.362685

Date, C. J. (2019). *Database design and relational theory: Normal forms
and all that jazz* (2nd ed.). Apress.
https://doi.org/10.1007/978-1-4842-5540-7

International Organization for Standardization. (2023). *ISO/IEC
11179-1:2023: Information technology—Metadata registries (MDR)—Part 1:
Framework*.
Loading
Loading