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 = (
+ {selectedNextAction &&
{selectedNextAction}
}
Date: Sun, 16 Aug 2026 16:27:19 +0000
Subject: [PATCH 4/4] docs: number person-catalog identity as ADR 0021
#149 already uses ADR 0020 for kind-specific pending copy. Person
catalog identity on post_summary_role is 0021, including the upgrade
migration and rollback.
Co-authored-by: Seongho Bae
---
ARCHITECTURE.md | 2 +-
CHANGELOG.md | 2 +-
CLAUDE.md | 2 +-
backend/app/post_summary_ingestion.py | 2 +-
docker/postgres-init/Dockerfile | 2 +-
docs/adr/0019-role-catalog-identity.md | 2 +-
...ntity.md => 0021-role-person-catalog-identity.md} | 6 +++---
docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md | 2 +-
migrations/0001_initial_schema.sql | 2 +-
...ity.sql => 0021_role_person_catalog_identity.sql} | 2 +-
...ity.sql => 0021_role_person_catalog_identity.sql} | 2 +-
scripts/seed_demo_data.py | 2 +-
tests/test_documentation_hygiene.py | 12 ++++++------
tests/test_ingestion_transaction_contracts.py | 4 ++--
tests/test_person_mention_projection.py | 2 +-
15 files changed, 23 insertions(+), 23 deletions(-)
rename docs/adr/{0020-role-person-catalog-identity.md => 0021-role-person-catalog-identity.md} (91%)
rename migrations/{0020_role_person_catalog_identity.sql => 0021_role_person_catalog_identity.sql} (97%)
rename migrations/rollback/{0020_role_person_catalog_identity.sql => 0021_role_person_catalog_identity.sql} (88%)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ea1ca9ae2..25f887fe9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -858,7 +858,7 @@ 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` /
`cataloged_corporate_entity_id` / `cataloged_person_id`, ADR 0019 /
-0020) so a later read does not rejoin `corporate_entity` by
+0021) so a later read does not rejoin `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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9bfe89f81..1aa9c7594 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ All notable changes to this project are documented here. Format follows
### Fixed
- R&R person chips now read `cataloged_person_id` from
- `post_summary_role` (ADR 0020). Open a post whose R&R names a
+ `post_summary_role` (ADR 0021). 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 backfill leaves a role unbound when two
diff --git a/CLAUDE.md b/CLAUDE.md
index c1790d7c7..1baf0f59e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,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 / 0020), including `cataloged_person_id`. Do not rejoin
+(ADR 0019 / 0021), 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 2b8e95241..b1c2e8c32 100644
--- a/backend/app/post_summary_ingestion.py
+++ b/backend/app/post_summary_ingestion.py
@@ -65,7 +65,7 @@ async def fetch_persisted_summary(
"""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 / 0020). This function does not join ``corporate_entity``
+ (ADR 0019 / 0021). This function does not join ``corporate_entity``
by ``entity_name``. Person chips read ``cataloged_person_id``.
"""
header = await conn.fetchrow(
diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile
index 70c30a77f..c46f1e6a0 100644
--- a/docker/postgres-init/Dockerfile
+++ b/docker/postgres-init/Dockerfile
@@ -25,7 +25,7 @@ COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/1
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
-COPY migrations/0020_role_person_catalog_identity.sql /docker-entrypoint-initdb.d/21-role-person-catalog-identity.sql
+COPY migrations/0021_role_person_catalog_identity.sql /docker-entrypoint-initdb.d/21-role-person-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/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md
index aa2617d7c..673fab53a 100644
--- a/docs/adr/0019-role-catalog-identity.md
+++ b/docs/adr/0019-role-catalog-identity.md
@@ -32,7 +32,7 @@ not.
(`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`. Person identity
-is ADR 0020 (`cataloged_person_id`).
+is ADR 0021 (`cataloged_person_id`).
Migration `0019_role_catalog_identity.sql` backfills existing rows from
a post-scoped mention only when the name match is unique on that post.
diff --git a/docs/adr/0020-role-person-catalog-identity.md b/docs/adr/0021-role-person-catalog-identity.md
similarity index 91%
rename from docs/adr/0020-role-person-catalog-identity.md
rename to docs/adr/0021-role-person-catalog-identity.md
index 2e07c85e0..755f9e9a6 100644
--- a/docs/adr/0020-role-person-catalog-identity.md
+++ b/docs/adr/0021-role-person-catalog-identity.md
@@ -1,4 +1,4 @@
-# ADR 0020 — Persist the R&R person catalog identity on the role row
+# ADR 0021 — Persist the R&R person catalog identity on the role row
**Decision status:** Accepted
**Date:** 2026-08-16
@@ -25,7 +25,7 @@ record, not a later re-search by a non-unique attribute.
by name, orders by `created_at`, then `person_id`. It still does not
create a new `cataloged_person` row (ADR 0009 gap).
-Migration `0020_role_person_catalog_identity.sql` backfills existing
+Migration `0021_role_person_catalog_identity.sql` backfills existing
rows from a post-scoped mention only when the name match is unique on
that post (`HAVING count(*) = 1`). Two same-named mentions stay unbound.
@@ -38,7 +38,7 @@ At most one of `cataloged_team_id`, `cataloged_corporate_entity_id`, and
- 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.
-- Pre-0020 rows with two same-named mentions stay unbound until an
+- Pre-0021 rows with two same-named mentions stay unbound until an
operator re-persists the summary. Do not guess a UUID at migrate time.
## References
diff --git a/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md b/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md
index a13db9c2c..46fa9c9a0 100644
--- a/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md
+++ b/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md
@@ -1,6 +1,6 @@
# R&R catalog identity — doctoring
-These are the standards and papers that ground ADR 0019 and ADR 0020.
+These are the standards and papers that ground ADR 0019 and ADR 0021.
Cite them in APA 7th when you extend role identity binding or
related-node authorization.
diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql
index c9ddbc503..bd6d674d4 100644
--- a/migrations/0001_initial_schema.sql
+++ b/migrations/0001_initial_schema.sql
@@ -419,7 +419,7 @@ alter table post_summary_role
alter table post_summary_role
add column cataloged_corporate_entity_id uuid
references corporate_entity (corporate_entity_id);
--- ADR 0020: person chips read the stored catalog id, not a later
+-- ADR 0021: person chips read the stored catalog id, not a later
-- same-named Keyman row. At most one catalog FK is set.
alter table post_summary_role
add column cataloged_person_id uuid references cataloged_person (person_id),
diff --git a/migrations/0020_role_person_catalog_identity.sql b/migrations/0021_role_person_catalog_identity.sql
similarity index 97%
rename from migrations/0020_role_person_catalog_identity.sql
rename to migrations/0021_role_person_catalog_identity.sql
index 47c90d5ce..fc0743831 100644
--- a/migrations/0020_role_person_catalog_identity.sql
+++ b/migrations/0021_role_person_catalog_identity.sql
@@ -1,4 +1,4 @@
--- ADR 0020: persist the person catalog id resolved for an R&R actor.
+-- ADR 0021: persist the person catalog id resolved for an R&R actor.
-- Fetching by person_name is not identifying -- two people can share a
-- display name. Write-time persist orders by created_at, then person_id.
-- Historical backfill copies a mention only when exactly one mentioned
diff --git a/migrations/rollback/0020_role_person_catalog_identity.sql b/migrations/rollback/0021_role_person_catalog_identity.sql
similarity index 88%
rename from migrations/rollback/0020_role_person_catalog_identity.sql
rename to migrations/rollback/0021_role_person_catalog_identity.sql
index 708d666ad..08a1b49d3 100644
--- a/migrations/rollback/0020_role_person_catalog_identity.sql
+++ b/migrations/rollback/0021_role_person_catalog_identity.sql
@@ -1,4 +1,4 @@
--- Fail-closed rollback for migration 0020.
+-- Fail-closed rollback for migration 0021.
--
-- Drops the persisted person catalog identity on post_summary_role.
-- Team and organization columns from 0019 remain.
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index db75ac987..5464b0f9a 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -121,7 +121,7 @@ def seed(
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((migrations / "0020_role_person_catalog_identity.sql").read_text())
+ cur.execute((migrations / "0021_role_person_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 d39876260..c3c1d6c7c 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -67,19 +67,19 @@ def test_role_catalog_identity_migration_is_wired() -> None:
migration_0019 = (_ROOT / "migrations" / "0019_role_catalog_identity.sql").read_text(
encoding="utf-8"
)
- migration_0020 = (_ROOT / "migrations" / "0020_role_person_catalog_identity.sql").read_text(
+ migration_0021 = (_ROOT / "migrations" / "0021_role_person_catalog_identity.sql").read_text(
encoding="utf-8"
)
assert "0019_role_catalog_identity.sql" in dockerfile
- assert "0020_role_person_catalog_identity.sql" in dockerfile
+ assert "0021_role_person_catalog_identity.sql" in dockerfile
assert "0019_role_catalog_identity.sql" in seed
- assert "0020_role_person_catalog_identity.sql" in seed
+ assert "0021_role_person_catalog_identity.sql" in seed
assert "cataloged_team_id" in migration_0019
assert "cataloged_corporate_entity_id" in migration_0019
- assert "cataloged_person_id" in migration_0020
+ assert "cataloged_person_id" in migration_0021
for column_name in _ROLE_CATALOG_COLUMNS:
assert len(column_name.split("_")) >= 2
assert "having count(*) = 1" in migration_0019
- assert "having count(*) = 1" in migration_0020
+ assert "having count(*) = 1" in migration_0021
assert "distinct on" not in migration_0019.lower()
- assert "distinct on" not in migration_0020.lower()
+ assert "distinct on" not in migration_0021.lower()
diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py
index 79a83584d..ee02185a0 100644
--- a/tests/test_ingestion_transaction_contracts.py
+++ b/tests/test_ingestion_transaction_contracts.py
@@ -498,7 +498,7 @@ def test_role_catalog_identity_is_stored_on_the_role_row() -> None:
encoding="utf-8"
)
person_upgrade = (
- root / "migrations" / "0020_role_person_catalog_identity.sql"
+ root / "migrations" / "0021_role_person_catalog_identity.sql"
).read_text(encoding="utf-8")
dockerfile = (
root / "docker" / "postgres-init" / "Dockerfile"
@@ -513,5 +513,5 @@ def test_role_catalog_identity_is_stored_on_the_role_row() -> None:
assert "cataloged_corporate_entity_id" in upgrade
assert "cataloged_person_id" in person_upgrade
assert "0019_role_catalog_identity.sql" in dockerfile
- assert "0020_role_person_catalog_identity.sql" in dockerfile
+ assert "0021_role_person_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 682dfcb8a..234f7c209 100644
--- a/tests/test_person_mention_projection.py
+++ b/tests/test_person_mention_projection.py
@@ -614,7 +614,7 @@ async def _exercise_same_name_person_catalog_order(
def test_same_name_person_roles_bind_the_earliest_catalog_row(
projection_database: str,
) -> None:
- """ADR 0020: R&R person lookup must order by created_at, then person_id."""
+ """ADR 0021: 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))