From f34f5579d71369dedd106a1a2690cf332fec306a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:10:30 +0000 Subject: [PATCH 1/4] fix: bind R&R catalog ids instead of rejoining by name (v0.86.1) Store the resolved team, organization, and person catalog keys on post_summary_role so a shared display name cannot duplicate or retarget the chip the buyer clicks. Team related now 403s on an unseen private mention and 404s on an unknown UUID, matching corporate-entity related. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 8 +- CHANGELOG.md | 14 ++ CLAUDE.md | 2 + backend/app/post_summary_ingestion.py | 112 ++++++----- backend/tests/test_api.py | 71 +++++++ docker/postgres-init/Dockerfile | 1 + docs/adr/0009-cross-post-actor-identity.md | 4 +- docs/adr/0018-related-nodes-team-org-walk.md | 2 + docs/adr/0019-role-catalog-identity.md | 74 ++++++++ .../ROLE_CATALOG_IDENTITY_REFERENCES.md | 22 +++ frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- migrations/0001_initial_schema.sql | 20 ++ migrations/0019_role_catalog_identity.sql | 107 +++++++++++ .../rollback/0019_role_catalog_identity.sql | 11 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_documentation_hygiene.py | 33 ++++ tests/test_person_mention_projection.py | 179 +++++++++++++++++- uv.lock | 2 +- 20 files changed, 609 insertions(+), 60 deletions(-) create mode 100644 docs/adr/0019-role-catalog-identity.md create mode 100644 docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md create mode 100644 migrations/0019_role_catalog_identity.sql create mode 100644 migrations/rollback/0019_role_catalog_identity.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa557bd7a..d637a134f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -852,7 +852,13 @@ resolves each R&R actor's identity and calls the same 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 -not currently capture). +not currently capture). ADR 0019 stores that resolved catalog id on +`post_summary_role` (`cataloged_team_id` / `corporate_entity_id` / +`cataloged_person_id`) so a later read does not rejoin +`corporate_entity` by `entity_name`. Open a post whose R&R names an +organization that shares a display name with another catalog row: the +chip keeps the id persist stored. Click it to walk that organization, +not the homonym. ## Phase 12: a real counterparty organization is auto-created, not left permanently unresolved diff --git a/CHANGELOG.md b/CHANGELOG.md index 434c4d63b..aa2e16a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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.1] - 2026-08-16 + +### Fixed + +- R&R summary chips now keep the catalog id persist stored (ADR 0019). + Open a post whose organization actor shares a display name with + another catalog row: the chip stays bound to that id, even if the + homonym is also mentioned on the post. Click it to walk the intended + organization. +- `GET /api/teams/{team_id}/related` returns 403 when the team exists + only on an unseen private post, and 404 for an unknown UUID — the + same fail-closed path corporate-entity related already uses. A + private organization mention does not open the related walk. + ## [0.86.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2f89bada7..8b0a82f6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,3 +19,5 @@ Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017) and does not reconstruct lineage. +R&R chips read the catalog id stored on `post_summary_role` +(ADR 0019). Do not rejoin `corporate_entity` by `entity_name`. diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 36d4fadae..9fc0590d8 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -21,6 +21,11 @@ verification, and the short advisory-lock creation transaction finish before the summary-replacement transaction begins; slow external work therefore cannot extend the lock or the atomic replacement window. + +ADR 0019: the resolved catalog id is stored on ``post_summary_role``. +``entity_name`` is a display label, not an identity key -- two companies +can share it, and a name join can then duplicate the role or attach the +wrong id. Fetch reads the stored foreign key. """ from __future__ import annotations @@ -57,7 +62,13 @@ 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 ids come from ``post_summary_role`` itself (ADR 0019). Do not + rejoin ``corporate_entity`` by ``entity_name`` -- that label is not + unique and a colliding catalog row would duplicate or mis-link the + role the buyer clicks. + """ header = await conn.fetchrow( "select korean_summary from post_summary_result where post_id = $1", post_id, @@ -72,23 +83,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 as team_id, + role.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 """, @@ -218,56 +215,67 @@ async def _replace_summary_projection( ordinal, event_text, ) - for role in summary.roles_and_responsibilities: + # ADR 0009 / 0019: resolve catalog identity before the role insert so + # fetch can read the stored id instead of rejoining by display name. + for role_index, role in enumerate(summary.roles_and_responsibilities): + cataloged_team_id = None + corporate_entity_id = None + cataloged_person_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: + corporate_entity_id = resolved_organization_ids.get(role_index) + elif role.actor_type_code == ACTOR_TYPE_PERSON: + person_row = await conn.fetchrow( + "select person_id from cataloged_person " + "where person_name = $1 " + "order by created_at, person_id limit 1", + role.actor_name, + ) + if person_row is not None: + cataloged_person_id = str(person_row["person_id"]) 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, " + "corporate_entity_id, cataloged_person_id) " + "values ($1, $2, $3, $4, $5, $6, $7, $8)", post_id, role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, + cataloged_team_id, + corporate_entity_id, + cataloged_person_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 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", - role.actor_name, + elif 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 cataloged_person_id is not None: + await conn.execute( + "insert into post_summary_person_mention (post_id, person_id) " + "values ($1, $2) on conflict do nothing", + post_id, + cataloged_person_id, ) - if person_row is not None: - await conn.execute( - "insert into post_summary_person_mention (post_id, person_id) " - "values ($1, $2) on conflict do nothing", - post_id, - str(person_row["person_id"]), - ) await persist_edges_for_post(conn, post_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bff7f7c64..0912390cd 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1476,6 +1476,77 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: assert role["catalog_node_type_code"] == "node_team" +def test_team_mentioned_only_on_other_corp_private_post_is_forbidden( + client, demo_analyst_token, seeded_db +) -> None: + """A team that exists only on an unseen private post must 403, not walk.""" + + 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 ('Hidden Team', '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, seeded_db +) -> None: + """An unknown team UUID must 404 the same way an unknown org does.""" + + response = client.get( + f"/api/teams/{uuid.uuid4()}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 404 + + +def test_organization_mentioned_only_on_other_corp_private_post_is_forbidden( + client, demo_analyst_token, seeded_db +) -> None: + """A private org mention must not open the related walk through the UNION.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into corporate_entity " + "(corporate_entity_code, entity_name, entity_level_code) " + "values ('HIDDEN-MENTION', 'Hidden Mention Corp', 'company') " + "returning corporate_entity_id" + ) + hidden_org_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_organization_mention " + "(post_id, corporate_entity_id) values (%s, %s)", + (seeded_db["other_private_post_id"], hidden_org_id), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/corporate-entities/{hidden_org_id}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + def test_organization_mention_only_posts_appear_in_entity_related( 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/0009-cross-post-actor-identity.md b/docs/adr/0009-cross-post-actor-identity.md index 7bdbfa091..ad10e6c56 100644 --- a/docs/adr/0009-cross-post-actor-identity.md +++ b/docs/adr/0009-cross-post-actor-identity.md @@ -121,7 +121,9 @@ Depends on [ADR 0006](0006-role-responsibility-agent-ontology.md) and [ADR 0007](0007-team-actor-type.md) (actor *type*) and `lineageweave.corporate_hierarchy_resolution` (Bhattacharya & Getoor, 2007, cited there) for the organization-matching this ADR reuses rather -than re-deriving. +than re-deriving. [ADR 0019](0019-role-catalog-identity.md) stores the +resolved catalog id on `post_summary_role` so fetch does not rejoin by +display name. ## References (APA 7th) diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index 17e4236a6..8152804f1 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -48,6 +48,8 @@ Thread-group run list visibility requires at least one ABAC-visible organization chip. - A later public post in a thread group no longer lists a January run that could not have known that post. +- Catalog ids on those chips come from `post_summary_role` (ADR 0019). + Do not rejoin `corporate_entity` by `entity_name`. ## References diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md new file mode 100644 index 000000000..e57fe42b1 --- /dev/null +++ b/docs/adr/0019-role-catalog-identity.md @@ -0,0 +1,74 @@ +# ADR 0019 — Persist the R&R catalog identity on the role row + +**Decision status:** Accepted +**Date:** 2026-08-16 +**Depends on:** ADR 0009 cross-post actor identity; ADR 0018 related-node +team/organization walk + +## Context + +ADR 0009 and ADR 0018 expose `catalog_node_id` so a buyer can click an +R&R team or organization and walk sibling posts. The read path joined +`corporate_entity` by `entity_name`. That column is a display label, not +an identity key: two companies can share it, and `corporate_entity_code` +is the unique catalog key. A name join then either duplicated the role +or attached the homonym's id when both rows were mentioned on the same +post. + +`cataloged_team` already protects the team path with +`UNIQUE NULLS NOT DISTINCT (team_name, affiliated_organization_name)`. +Person lookup used `LIMIT 1` without `ORDER BY`, so two same-named +people were non-deterministic. + +Fellegi and Sunter (1969) treat a match decision as a binding to one +record, not a later re-search by a non-unique attribute. Bhattacharya +and Getoor (2007) keep that binding once collective resolution has +chosen a candidate. + +## Decision + +`post_summary_role` stores the catalog foreign key resolved at write +time: + +- `cataloged_team_id` for `prov_team` +- `corporate_entity_id` for `prov_organization` +- `cataloged_person_id` for `prov_person` + +At most one of those columns is set, and the set column must match +`actor_type_code`. `fetch_persisted_summary` reads those columns. It +does not rejoin the catalog by display name. + +Person lookup, when it still resolves by name, orders by +`created_at`, then `person_id`, and stores that id. It still does not +create a new `cataloged_person` row (ADR 0009 gap). + +`GET /api/teams/{team_id}/related` keeps person/entity parity: unknown +UUID is 404; a team mentioned only on an unseen private post is 403. +A private `post_organization_mention` does not open the related walk +through the ADR 0018 UNION (Hu et al., 2014). + +## Consequences + +- Open a post whose R&R names an organization that shares a display + name with another catalog row. The chip keeps the id persist stored. + Click it to walk that organization's posts, not the homonym's. +- A later mention of the homonym on the same post does not duplicate + the role or retarget the chip. +- Team and organization related endpoints fail closed the same way + Keyman and corporate-entity related already do. + +## References + +Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in +relational data. *ACM Transactions on Knowledge Discovery from Data, +1*(1), Article 5. https://doi.org/10.1145/1217299.1217304 + +Fellegi, I. P., & Sunter, A. B. (1969). A theory for record linkage. +*Journal of the American Statistical Association, 64*(328), 1183–1210. +https://doi.org/10.1080/01621459.1969.10501049 + +Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., +Miller, R., & Scarfone, K. (2014). *Guide to attribute based access +control (ABAC) definition and considerations* (NIST Special Publication +800-162). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-162 diff --git a/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md b/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md new file mode 100644 index 000000000..bccfbae1f --- /dev/null +++ b/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md @@ -0,0 +1,22 @@ +# R&R catalog identity — doctoring + +These are the standards and papers that ground ADR 0019. Cite them in +APA 7th when you extend role identity binding or related-node +authorization. + +Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in +relational data. *ACM Transactions on Knowledge Discovery from Data, +1*(1), Article 5. https://doi.org/10.1145/1217299.1217304 + +Fellegi, I. P., & Sunter, A. B. (1969). A theory for record linkage. +*Journal of the American Statistical Association, 64*(328), 1183–1210. +https://doi.org/10.1080/01621459.1969.10501049 + +Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., +Miller, R., & Scarfone, K. (2014). *Guide to attribute based access +control (ABAC) definition and considerations* (NIST Special Publication +800-162). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-162 + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ diff --git a/frontend/package.json b/frontend/package.json index dac241738..803acd91b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.0", + "version": "0.86.1", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5f70c6064..48bf6e481 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.0" +__version__ = "0.86.1" diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index e2877f447..27419242f 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -411,6 +411,26 @@ create table post_organization_mention ( primary key (post_id, corporate_entity_id) ); +-- ADR 0019: store the catalog id resolved at write time. Rejoining +-- corporate_entity by entity_name is not identifying -- two companies +-- can share a display name. Columns land after the catalog tables so +-- the foreign keys can resolve. +alter table post_summary_role + add column cataloged_team_id uuid references cataloged_team (team_id), + add column corporate_entity_id uuid references corporate_entity (corporate_entity_id), + add column cataloged_person_id uuid references cataloged_person (person_id), + add constraint post_summary_role_one_catalog_chk check ( + (cataloged_team_id is not null)::int + + (corporate_entity_id is not null)::int + + (cataloged_person_id is not null)::int + <= 1 + ), + add constraint post_summary_role_catalog_type_chk check ( + (cataloged_team_id is null or actor_type_code = 'prov_team') + and (corporate_entity_id is null or actor_type_code = 'prov_organization') + and (cataloged_person_id is null or actor_type_code = 'prov_person') + ); + -- --------------------------------------------------------------------- -- 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..13f244f22 --- /dev/null +++ b/migrations/0019_role_catalog_identity.sql @@ -0,0 +1,107 @@ +-- ADR 0019: persist the catalog identity resolved for an R&R actor. +-- Fetching by corporate_entity.entity_name is not identifying -- two +-- companies can share a display name, and two same-named mentions on +-- one post can then attach the wrong catalog id or duplicate the role. + +alter table post_summary_role + add column if not exists cataloged_team_id uuid + references cataloged_team (team_id), + add column if not exists corporate_entity_id uuid + references corporate_entity (corporate_entity_id), + add column if not exists cataloged_person_id uuid + references cataloged_person (person_id); + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'post_summary_role_one_catalog_chk' + ) then + alter table post_summary_role + add constraint post_summary_role_one_catalog_chk check ( + (cataloged_team_id is not null)::int + + (corporate_entity_id is not null)::int + + (cataloged_person_id is not null)::int + <= 1 + ); + end if; + if not exists ( + select 1 + from pg_constraint + where conname = 'post_summary_role_catalog_type_chk' + ) then + alter table post_summary_role + add constraint post_summary_role_catalog_type_chk check ( + (cataloged_team_id is null or actor_type_code = 'prov_team') + and ( + corporate_entity_id is null + or actor_type_code = 'prov_organization' + ) + and ( + cataloged_person_id is null + or actor_type_code = 'prov_person' + ) + ); + end if; +end +$$; + +update post_summary_role role + set cataloged_team_id = team.team_id + from cataloged_team team + join post_team_mention mention + on mention.team_id = team.team_id + and mention.post_id = role.post_id + where role.actor_type_code = 'prov_team' + and role.cataloged_team_id is null + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name; + +update post_summary_role role + set corporate_entity_id = picked.corporate_entity_id + from ( + select distinct on (role_key.post_id, role_key.actor_name) + role_key.post_id, + role_key.actor_name, + org.corporate_entity_id + from post_summary_role role_key + join post_organization_mention mention + on mention.post_id = role_key.post_id + join corporate_entity org + on org.corporate_entity_id = mention.corporate_entity_id + and org.entity_name = role_key.actor_name + where role_key.actor_type_code = 'prov_organization' + and role_key.corporate_entity_id is null + order by role_key.post_id, role_key.actor_name, org.corporate_entity_id + ) picked + where role.post_id = picked.post_id + and role.actor_name = picked.actor_name + and role.actor_type_code = 'prov_organization' + and role.corporate_entity_id is null; + +update post_summary_role role + set cataloged_person_id = picked.person_id + from ( + select distinct on (role_key.post_id, role_key.actor_name) + role_key.post_id, + role_key.actor_name, + person.person_id + from post_summary_role role_key + join post_summary_person_mention mention + on mention.post_id = role_key.post_id + join cataloged_person person + on person.person_id = mention.person_id + and person.person_name = role_key.actor_name + where role_key.actor_type_code = 'prov_person' + and role_key.cataloged_person_id is null + order by role_key.post_id, + role_key.actor_name, + person.created_at, + person.person_id + ) picked + where role.post_id = picked.post_id + and role.actor_name = picked.actor_name + and role.actor_type_code = 'prov_person' + and role.cataloged_person_id is null; diff --git a/migrations/rollback/0019_role_catalog_identity.sql b/migrations/rollback/0019_role_catalog_identity.sql new file mode 100644 index 000000000..621b7b631 --- /dev/null +++ b/migrations/rollback/0019_role_catalog_identity.sql @@ -0,0 +1,11 @@ +-- Fail-closed rollback for migration 0019. +-- +-- Drops the persisted catalog identity columns on post_summary_role. +-- Re-running after a successful rollback is safe. + +alter table if exists post_summary_role + drop constraint if exists post_summary_role_catalog_type_chk, + drop constraint if exists post_summary_role_one_catalog_chk, + drop column if exists cataloged_person_id, + drop column if exists corporate_entity_id, + drop column if exists cataloged_team_id; diff --git a/pyproject.toml b/pyproject.toml index 0393b774a..eb2e26318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.0" +version = "0.86.1" 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_documentation_hygiene.py b/tests/test_documentation_hygiene.py index 6dc89dff8..41c3966cf 100644 --- a/tests/test_documentation_hygiene.py +++ b/tests/test_documentation_hygiene.py @@ -8,6 +8,11 @@ _ROOT = Path(__file__).resolve().parents[1] _ADR_DIRECTORY = _ROOT / "docs" / "adr" +_ROLE_CATALOG_COLUMNS = ( + "cataloged_team_id", + "corporate_entity_id", + "cataloged_person_id", +) _ADR_NAME = re.compile(r"^(?P[0-9]{4})-.+\.md$") _FORBIDDEN_MARKERS = ( "PLACEHOLDER_DO_NOT_WRITE", @@ -37,3 +42,31 @@ def test_adr_numbers_are_unique_and_documents_are_not_placeholders() -> None: counts = Counter(number for number, _ in numbered_paths) duplicates = sorted(number for number, count in counts.items() if count > 1) assert duplicates == [], f"duplicate ADR numbers: {duplicates}" + + +def test_fetch_persisted_summary_reads_stored_catalog_ids() -> None: + """ADR 0019: fetch must not rejoin the catalog by a non-unique name.""" + + source = (_ROOT / "backend" / "app" / "post_summary_ingestion.py").read_text( + encoding="utf-8" + ) + assert "org.entity_name = role.actor_name" not in source + assert "role.cataloged_team_id as team_id" in source + assert "order by created_at, person_id limit 1" in source + + +def test_role_catalog_identity_migration_is_wired() -> None: + """Fresh stacks and seed must apply the catalog-identity columns.""" + + dockerfile = (_ROOT / "docker" / "postgres-init" / "Dockerfile").read_text( + encoding="utf-8" + ) + seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") + migration = (_ROOT / "migrations" / "0019_role_catalog_identity.sql").read_text( + encoding="utf-8" + ) + assert "0019_role_catalog_identity.sql" in dockerfile + assert "0019_role_catalog_identity.sql" in seed + for column_name in _ROLE_CATALOG_COLUMNS: + assert column_name in migration + assert len(column_name.split("_")) >= 2 diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 5353c2a91..e242f3afb 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -27,7 +27,10 @@ related_for_start, visible_mention_post_ids, ) -from backend.app.post_summary_ingestion import persist_post_summary +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, @@ -36,7 +39,12 @@ NODE_POST, NODE_TEAM, ) -from lineageweave.post_summary import PostSummary, RoleResponsibility +from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + ACTOR_TYPE_PERSON, + PostSummary, + RoleResponsibility, +) _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -451,3 +459,170 @@ 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_catalog_binding( + database_dsn: str, + post_id: str, +) -> None: + """Two same-named orgs on one post must keep the role bound to one id.""" + + connection = await asyncpg.connect(database_dsn) + try: + first_org_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-A', 'Northridge Grid', 'company') + returning corporate_entity_id + """ + ) + ) + second_org_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-B', 'Northridge Grid', 'company') + returning corporate_entity_id + """ + ) + ) + await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="노스리지 그리드가 일정 확인을 요청했다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Northridge Grid", + responsibility="일정 확인", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + ), + ), + ) + stored_id = str( + await connection.fetchval( + """ + select corporate_entity_id + from post_summary_role + where post_id = $1 and actor_name = 'Northridge Grid' + """, + post_id, + ) + ) + assert stored_id in {first_org_id, second_org_id} + other_id = second_org_id if stored_id == first_org_id else first_org_id + await connection.execute( + """ + insert into post_organization_mention (post_id, corporate_entity_id) + values ($1, $2) + on conflict do nothing + """, + post_id, + other_id, + ) + payload = await fetch_persisted_summary(connection, post_id) + assert payload is not None + roles = payload["roles_and_responsibilities"] + assert len(roles) == 1 + assert roles[0]["catalog_node_id"] == stored_id + assert roles[0]["catalog_node_type_code"] == "node_corporate_entity" + finally: + await connection.close() + + +def test_homonym_organization_roles_keep_the_persisted_catalog_id( + projection_database: str, +) -> None: + """ADR 0019: a shared display name must not duplicate or rebind the role.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_homonym_organization_catalog_binding(database_dsn, post_id)) + + +async def _exercise_same_name_person_catalog_order( + database_dsn: str, + post_id: str, +) -> None: + """Two people with the same name must bind the earlier catalog row.""" + + connection = await asyncpg.connect(database_dsn) + try: + earlier_id = str( + await connection.fetchval( + """ + insert into cataloged_person + (person_name, person_side_code, last_known_job_title, created_at) + values ( + 'Kim Cheolsu', 'our_side', 'Sales Manager', + '2024-01-01T00:00:00+00' + ) + returning person_id + """ + ) + ) + await connection.execute( + """ + insert into cataloged_person + (person_name, person_side_code, last_known_job_title, created_at) + values ( + 'Kim Cheolsu', 'counterparty', 'Purchasing Lead', + '2024-06-01T00:00:00+00' + ) + """ + ) + await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="김철수가 후속을 맡았다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Kim Cheolsu", + responsibility="후속", + actor_type_code=ACTOR_TYPE_PERSON, + ), + ), + ), + ) + payload = await fetch_persisted_summary(connection, post_id) + assert payload is not None + roles = payload["roles_and_responsibilities"] + assert len(roles) == 1 + stored_id = str( + await connection.fetchval( + """ + select cataloged_person_id + from post_summary_role + where post_id = $1 and actor_name = 'Kim Cheolsu' + """, + post_id, + ) + ) + assert stored_id == earlier_id + assert roles[0]["catalog_node_id"] is None + mention_id = str( + await connection.fetchval( + """ + select person_id + from post_summary_person_mention + where post_id = $1 + """, + post_id, + ) + ) + assert mention_id == earlier_id + finally: + await connection.close() + + +def test_same_name_person_roles_bind_the_earliest_catalog_row( + projection_database: str, +) -> None: + """R&R person lookup must order by created_at, then person_id.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_same_name_person_catalog_order(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index b302b1250..c759df267 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.0" +version = "0.86.1" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 4464be2135a846829dbc25091478de1adf304d08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:20:29 +0000 Subject: [PATCH 2/4] fix: bind R&R person chips and refuse homonym backfill (v0.86.2) Fetch now returns cataloged_person_id so a person chip walks the stored id even when Keyman was not extracted on that post. Historical 0019 backfill leaves a role unbound when two same-named mentions already exist. A private mention of a visible organization stays out of that walk. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 11 ++-- CHANGELOG.d/0.86.2-role-catalog-identity.md | 3 + CHANGELOG.md | 13 ++++ CLAUDE.md | 4 +- backend/app/post_summary_ingestion.py | 12 +++- backend/tests/test_api.py | 59 ++++++++++++++++++ docs/adr/0019-role-catalog-identity.md | 23 +++++-- frontend/package.json | 2 +- frontend/src/App.test.tsx | 14 +++++ frontend/src/App.tsx | 19 +++++- lineageweave/__init__.py | 2 +- migrations/0019_role_catalog_identity.sql | 67 +++++++++------------ pyproject.toml | 2 +- tests/test_documentation_hygiene.py | 3 + tests/test_person_mention_projection.py | 40 +++++++----- uv.lock | 2 +- 16 files changed, 209 insertions(+), 67 deletions(-) create mode 100644 CHANGELOG.d/0.86.2-role-catalog-identity.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d637a134f..5b77c8ed5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -855,10 +855,13 @@ by name (never originated by R&R itself -- documented gap in the ADR: not currently capture). ADR 0019 stores that resolved catalog id on `post_summary_role` (`cataloged_team_id` / `corporate_entity_id` / `cataloged_person_id`) so a later read does not rejoin -`corporate_entity` by `entity_name`. Open a post whose R&R names an -organization that shares a display name with another catalog row: the -chip keeps the id persist stored. Click it to walk that organization, -not the homonym. +`corporate_entity` by `entity_name`. Fetch returns the person foreign +key as `catalog_node_id` the same way. Historical backfill leaves a +role unbound when two same-named mentions already exist on the post. +Open a post whose R&R names an organization that shares a display name +with another catalog row: the chip keeps the id persist stored. Click +it to walk that organization, not the homonym. Click a person chip to +walk the stored person even when Keyman was not extracted on that post. ## Phase 12: a real counterparty organization is auto-created, not left permanently unresolved 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..92a024b90 --- /dev/null +++ b/CHANGELOG.d/0.86.2-role-catalog-identity.md @@ -0,0 +1,3 @@ +R&R person chips read the stored catalog id. Historical backfill leaves +homonym mentions unbound. A private mention of a visible org stays out +of that walk. diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2e16a7f..8c9983bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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 + +- R&R person chips now read `cataloged_person_id` from + `post_summary_role`. Open a post whose R&R names a cataloged person: + the chip is a button even when Keyman extraction was not run on that + post. Click it to walk that person, not a later same-named row. +- Historical 0019 backfill leaves a role unbound when two same-named + mentions already exist on the post. It does not pick a UUID. +- A private mention of an organization you can already see does not + appear in that organization's related walk. + ## [0.86.1] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 8b0a82f6b..1ff983ea8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,4 +20,6 @@ cutoff before treating the body as reconstructed evidence (ADR 0016). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017) and does not reconstruct lineage. R&R chips read the catalog id stored on `post_summary_role` -(ADR 0019). Do not rejoin `corporate_entity` by `entity_name`. +(ADR 0019), including `cataloged_person_id`. Do not rejoin +`corporate_entity` by `entity_name`. Historical backfill leaves a +role unbound when two same-named mentions already exist on the post. diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 9fc0590d8..b3e6ca174 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -39,7 +39,11 @@ NullCorporateHierarchyInferenceClient, ) from lineageweave.fixtures import fixture_thread_cast -from lineageweave.knowledge_graph import NODE_CORPORATE_ENTITY, NODE_TEAM +from lineageweave.knowledge_graph import ( + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_TEAM, +) from lineageweave.ontology import ontology_annotations from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, @@ -84,7 +88,8 @@ async def fetch_persisted_summary( select role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, role.cataloged_team_id as team_id, - role.corporate_entity_id + role.corporate_entity_id, + role.cataloged_person_id from post_summary_role role where role.post_id = $1 order by role.actor_name @@ -101,6 +106,9 @@ async def fetch_persisted_summary( elif row["corporate_entity_id"] is not None: catalog_node_id = str(row["corporate_entity_id"]) catalog_node_type_code = NODE_CORPORATE_ENTITY + elif row["cataloged_person_id"] is not None: + catalog_node_id = str(row["cataloged_person_id"]) + catalog_node_type_code = NODE_PERSON payload_roles.append( { "actor_name": row["actor_name"], diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0912390cd..796cb5912 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1547,6 +1547,65 @@ def test_organization_mentioned_only_on_other_corp_private_post_is_forbidden( assert response.status_code == 403 +def test_private_other_corp_organization_mention_does_not_leak( + client, demo_analyst_token, seeded_db +) -> None: + """A private mention of a visible org must not appear in that org walk.""" + + 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_organization_mention_only_posts_appear_in_entity_related( client, demo_analyst_token, seeded_db ) -> None: diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md index e57fe42b1..eb8d16508 100644 --- a/docs/adr/0019-role-catalog-identity.md +++ b/docs/adr/0019-role-catalog-identity.md @@ -35,27 +35,42 @@ time: - `cataloged_person_id` for `prov_person` At most one of those columns is set, and the set column must match -`actor_type_code`. `fetch_persisted_summary` reads those columns. It -does not rejoin the catalog by display name. +`actor_type_code`. `fetch_persisted_summary` reads those columns, +including `cataloged_person_id` into `catalog_node_id`. It does not +rejoin the catalog by display name. Person lookup, when it still resolves by name, orders by `created_at`, then `person_id`, and stores that id. It still does not create a new `cataloged_person` row (ADR 0009 gap). +Historical backfill copies a mention only when exactly one mentioned +catalog row on that post shares the role's actor name +(`HAVING count(*) = 1`). Two same-named mentions stay unbound. A +`DISTINCT ON` / min-UUID pick is a later re-search by a non-unique +attribute and is forbidden here (Fellegi & Sunter, 1969). + `GET /api/teams/{team_id}/related` keeps person/entity parity: unknown UUID is 404; a team mentioned only on an unseen private post is 403. A private `post_organization_mention` does not open the related walk -through the ADR 0018 UNION (Hu et al., 2014). +through the ADR 0018 UNION (Hu et al., 2014), including when the +mentioned organization is one the requester can already see. ## Consequences - Open a post whose R&R names an organization that shares a display name with another catalog row. The chip keeps the id persist stored. Click it to walk that organization's posts, not the homonym's. +- Open a post whose R&R names a person already in `cataloged_person`. + The chip is a button even when Keyman extraction was not run on that + post. Click it to walk that person, not a later same-named row. - A later mention of the homonym on the same post does not duplicate the role or retarget the chip. +- Pre-0019 rows with two same-named mentions stay unbound until an + operator re-persists the summary. Do not guess a UUID at migrate + time. - Team and organization related endpoints fail closed the same way - Keyman and corporate-entity related already do. + Keyman and corporate-entity related already do. A private mention of + an organization you can already see must not appear in that walk. ## References 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/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 934f150e4..de2ac0181 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -642,6 +642,8 @@ describe("App, authenticated", () => { responsibility: "고객 측 수신", actor_type_code: "prov_person", affiliated_organization_name: "Northridge Grid", + catalog_node_id: "person-priya", + catalog_node_type_code: "node_person", }, { actor_name: "당사", @@ -1045,6 +1047,7 @@ describe("App, authenticated", () => { expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument(); expect(screen.getByText(/우리 측 후속/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "R&R person: Priya Nair" })).toBeInTheDocument(); expect(screen.getByText("당사").closest("li")).toHaveTextContent("Organization"); expect(screen.queryByRole("button", { name: "R&R Keyman: 당사" })).not.toBeInTheDocument(); await waitFor(() => expect(screen.getByText("간접")).toBeInTheDocument()); @@ -1235,6 +1238,17 @@ describe("App, authenticated", () => { ); }); + it("opens related nodes from an R&R person catalog id", 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 person: Priya Nair" })); + await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); + expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent( + "Ada West (Our side)", + ); + }); + it("opens related nodes from an R&R team", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83fb88860..52525963d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1280,7 +1280,24 @@ function PostDetailPopup({ const catalogId = rr.catalog_node_id; const catalogType = rr.catalog_node_type_code; let actorName: ReactNode = {rr.actor_name}; - if (person) { + if (catalogType === NODE_PERSON && catalogId) { + actorName = ( + + ); + } else if (person) { actorName = (