diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a8d082f0b..b66c7cde7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.d/0.86.2-role-catalog-identity.md b/CHANGELOG.d/0.86.2-role-catalog-identity.md new file mode 100644 index 000000000..a56b3d47e --- /dev/null +++ b/CHANGELOG.d/0.86.2-role-catalog-identity.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8717513..13aeb02f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 36d4fadae..3febf9b21 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -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 @@ -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, @@ -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 """, @@ -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( { @@ -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", diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bff7f7c64..21c71bc9a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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", @@ -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: diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index d95e2c917..e394d376e 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -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 diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index 17e4236a6..ae0a1c331 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -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`. diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md new file mode 100644 index 000000000..32b5d0a09 --- /dev/null +++ b/docs/adr/0019-role-catalog-identity.md @@ -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/ diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md index 4ecb8fe82..fc7ded98f 100644 --- a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -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*. diff --git a/frontend/package.json b/frontend/package.json index 803acd91b..fb52f7948 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.1", + "version": "0.86.2", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 48bf6e481..efe84890b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.1" +__version__ = "0.86.2" diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index e2877f447..a94b05a0c 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -411,6 +411,15 @@ create table post_organization_mention ( primary key (post_id, corporate_entity_id) ); +-- ADR 0019: store the resolved catalog id on the role row itself. +-- corporate_entity.entity_name is not unique, and mention tables are +-- post-scoped, so reconstructing identity by name is not 3NF. +alter table post_summary_role + add column cataloged_team_id uuid references cataloged_team (team_id); +alter table post_summary_role + add column cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + -- --------------------------------------------------------------------- -- Knowledge graph: person/company/post nodes, typed edges. The type -- codes (which kind of node, which kind of edge) are real enums and DO diff --git a/migrations/0019_role_catalog_identity.sql b/migrations/0019_role_catalog_identity.sql new file mode 100644 index 000000000..2881be9b3 --- /dev/null +++ b/migrations/0019_role_catalog_identity.sql @@ -0,0 +1,46 @@ +-- ADR 0019: bind each R&R role to the catalog row resolved for that +-- role. corporate_entity.entity_name is not unique, so a fetch join on +-- name can attach a homonym or duplicate the role. Mention tables are +-- post-scoped, not role-scoped, and cannot reconstruct that binding. + +alter table post_summary_role + add column if not exists cataloged_team_id uuid + references cataloged_team (team_id); + +alter table post_summary_role + add column if not exists cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + +-- Teams already have a unique (team_name, affiliated_organization_name) +-- key. Backfill only when that pair was mentioned on the same post. +update post_summary_role as role + set cataloged_team_id = team.team_id + from cataloged_team as team + join post_team_mention as mention + on mention.team_id = team.team_id + where role.actor_type_code = 'prov_team' + and role.cataloged_team_id is null + and mention.post_id = role.post_id + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name; + +-- Organizations: copy a mention only when exactly one mentioned org on +-- that post has this role's actor_name. Two same-named mentions stay +-- unbound rather than guessing. +update post_summary_role role + set cataloged_corporate_entity_id = matched.corporate_entity_id + from ( + select mention.post_id, + org.entity_name, + min(org.corporate_entity_id) as corporate_entity_id + from post_organization_mention mention + join corporate_entity org + on org.corporate_entity_id = mention.corporate_entity_id + group by mention.post_id, org.entity_name + having count(*) = 1 + ) matched + where role.actor_type_code = 'prov_organization' + and role.cataloged_corporate_entity_id is null + and role.post_id = matched.post_id + and role.actor_name = matched.entity_name; diff --git a/migrations/rollback/0019_role_catalog_identity.sql b/migrations/rollback/0019_role_catalog_identity.sql new file mode 100644 index 000000000..5efafed8b --- /dev/null +++ b/migrations/rollback/0019_role_catalog_identity.sql @@ -0,0 +1,8 @@ +-- Drop role-scoped catalog identity columns added by 0019. +-- Mention tables remain; only the role-row binding is removed. + +alter table post_summary_role + drop column if exists cataloged_team_id; + +alter table post_summary_role + drop column if exists cataloged_corporate_entity_id; diff --git a/pyproject.toml b/pyproject.toml index eb2e26318..6e41c7ca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" 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/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index f6f575ccc..9d246445d 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -120,6 +120,7 @@ def seed( cur.execute((migrations / "0015_organization_name_resolution.sql").read_text()) cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) + cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index 641a43cc6..d2994e2c4 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -209,12 +209,16 @@ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: if "from post_summary_event" in compact: return [{"event_text": "검토 완료"}] if "from post_summary_role" in compact: + assert "entity_name" not in compact + assert "cataloged_corporate_entity_id" in compact return [ { "actor_name": "Synthetic Design Team", "responsibility": "도면 검토", "actor_type_code": ACTOR_TYPE_TEAM, "affiliated_organization_name": "Synthetic Energy", + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, } ] raise AssertionError(f"unexpected fetch query: {compact}") @@ -354,6 +358,14 @@ async def persist_edges(conn, post_id) -> list[Any]: and event[0] == "execute" and "insert into post_organization_mention" in event[1] ) + role_insert = next( + event[1] + for event in events + if isinstance(event, tuple) + and event[0] == "execute" + and "insert into post_summary_role" in event[1] + ) + assert "cataloged_corporate_entity_id" in role_insert assert resolve_index < enter_index < mention_index < exit_index @@ -469,3 +481,29 @@ def test_release_notes_describe_balanced_outer_emphasis_stripping() -> None: assert "strips balanced outer Markdown emphasis from field values" in content assert "while still accepting emphasized field labels" in content assert "preserves Markdown emphasis in field values" not in content + + +def test_role_catalog_identity_is_stored_on_the_role_row() -> None: + """ADR 0019: fetch must not reconstruct organization identity by name.""" + root = Path(__file__).resolve().parents[1] + fetch_source = ( + root / "backend" / "app" / "post_summary_ingestion.py" + ).read_text(encoding="utf-8") + initial = (root / "migrations" / "0001_initial_schema.sql").read_text( + encoding="utf-8" + ) + upgrade = (root / "migrations" / "0019_role_catalog_identity.sql").read_text( + encoding="utf-8" + ) + dockerfile = ( + root / "docker" / "postgres-init" / "Dockerfile" + ).read_text(encoding="utf-8") + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + fetch_sql = fetch_source.split("async def fetch_persisted_summary", 1)[1] + fetch_sql = fetch_sql.split("async def persist_post_summary", 1)[0] + assert "org.entity_name = role.actor_name" not in fetch_sql + assert "cataloged_corporate_entity_id" in fetch_sql + assert "cataloged_team_id" in initial + assert "cataloged_corporate_entity_id" in upgrade + assert "0019_role_catalog_identity.sql" in dockerfile + assert "ADR 0019" in changelog diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 5353c2a91..81e63a75f 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -27,16 +27,25 @@ related_for_start, visible_mention_post_ids, ) -from backend.app.post_summary_ingestion import persist_post_summary +from backend.app import post_summary_ingestion as summary_ingestion +from backend.app.post_summary_ingestion import ( + fetch_persisted_summary, + persist_post_summary, +) from lineageweave.keyman_extraction import OUR_SIDE, PersonMention from lineageweave.knowledge_graph import ( EDGE_MENTION, EDGE_MENTION_TEAM, + NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, NODE_TEAM, ) -from lineageweave.post_summary import PostSummary, RoleResponsibility +from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + PostSummary, + RoleResponsibility, +) _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -451,3 +460,85 @@ def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: database_dsn, post_id, _summary_person_id = projection_database.split("|") asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) + + +async def _exercise_homonym_organization_role_binding( + database_dsn: str, + post_id: str, +) -> None: + """A same-named catalog org that this post did not resolve must stay off the role.""" + + connection = await asyncpg.connect(database_dsn) + try: + mentioned_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-MENTIONED', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + other_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-OTHER', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + + async def resolve_mentioned_organization(*_args, **_kwargs) -> str: + return mentioned_id + + original = summary_ingestion.get_or_create_corporate_entity + summary_ingestion.get_or_create_corporate_entity = resolve_mentioned_organization + try: + payload = await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="동명이인 조직이 일정만 확정했다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Homonym Energy", + responsibility="납품 일정 확정", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + ), + ), + ) + finally: + summary_ingestion.get_or_create_corporate_entity = original + + roles = payload["roles_and_responsibilities"] + assert len(roles) == 1 + assert roles[0]["catalog_node_id"] == mentioned_id + assert roles[0]["catalog_node_type_code"] == NODE_CORPORATE_ENTITY + fetched = await fetch_persisted_summary(connection, post_id) + assert fetched is not None + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] == mentioned_id + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] != other_id + mention_ids = [ + str(row["corporate_entity_id"]) + for row in await connection.fetch( + "select corporate_entity_id from post_organization_mention " + "where post_id = $1", + post_id, + ) + ] + assert mention_ids == [mentioned_id] + finally: + await connection.close() + + +def test_homonym_organization_role_binds_the_resolved_catalog_id( + projection_database: str, +) -> None: + """ADR 0019: two catalog orgs can share a display name; the role keeps one id.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_homonym_organization_role_binding(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index c759df267..e1d2860cf 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" source = { virtual = "." } dependencies = [ { name = "certifi" },