diff --git a/AGENTS.md b/AGENTS.md
index 958da2e08..a16c8cd95 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -107,6 +107,12 @@ resolution, hierarchy inference, and verification are available. Keyman
must test the raw organization name before any abbreviation rewrite so a
rewrite cannot turn an existing tie into an apparent creation miss.
+R&R chips read the catalog id stored on `post_summary_role`
+(ADR 0019 / 0027), including `cataloged_person_id`. Do not rejoin
+`corporate_entity` or `cataloged_person` by display name. Historical
+backfill leaves a role unbound when two same-named mentions already
+exist on the post.
+
## CI gates
`.github/workflows/tests.yml` runs the full suite on every PR to `main`.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index cda036d11..df1363c63 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -882,7 +882,17 @@ the same `persist_edges_for_post` Keyman ingestion already uses. A person R&R
actor is opportunistically joined to an existing `cataloged_person` row
by name (never originated by R&R itself -- documented gap in the ADR:
`cataloged_person` needs `person_side_code`, which R&R's prompt does
-not currently capture).
+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 /
+0027) 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.
+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/2.7.2-role-person-catalog-identity.md b/CHANGELOG.d/2.7.2-role-person-catalog-identity.md
new file mode 100644
index 000000000..cdf3414e0
--- /dev/null
+++ b/CHANGELOG.d/2.7.2-role-person-catalog-identity.md
@@ -0,0 +1,6 @@
+# 2.7.2 Persist R&R person catalog ids
+
+R&R person chips read the stored catalog id. Historical person backfill
+leaves homonym mentions unbound. Open a post whose R&R names a cataloged
+person: the chip is a button even when Keyman was not extracted on that
+post.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 418c8e83a..ca7bfb41f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ 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).
+## [2.7.2] - 2026-08-17
+
+### Fixed
+
+- R&R person chips now read `cataloged_person_id` from
+ `post_summary_role` (ADR 0027). 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
+ same-named mentions already exist.
+
## [2.7.1] - 2026-08-17
### Fixed
diff --git a/CLAUDE.md b/CLAUDE.md
index a5a448323..05cc36284 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,7 +13,8 @@ export `analysis_run_retention_event`, delete those rows, and roll
back 0020 then 0018. The published phrase is not a secret. Do not
`DISABLE TRIGGER` as superuser. Do not grant the admin role or a
retention grant to the application `DATABASE_URL` login. ADR 0019
-is the R&R catalog-id bind, not this purge.
+is the R&R catalog-id bind, not this purge. Person catalog identity
+on that role row is ADR 0027 (`cataloged_person_id`).
## Analysis-run seed (v0.96.0)
diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py
index 3febf9b21..03426b0ef 100644
--- a/backend/app/post_summary_ingestion.py
+++ b/backend/app/post_summary_ingestion.py
@@ -1,17 +1,19 @@
"""Persist and load the popup's Korean summary / key events / R&R.
-ADR 0009 / 0019: an R&R actor is not just per-post free text -- when it
-is a team or organization, it is resolved to a shared catalog identity
-(``cataloged_team`` / ``corporate_entity``) stored on the role row and
+ADR 0009 / 0019 / 0027: an R&R actor is not just per-post free text --
+when it is a team, organization, or already-cataloged person, it is
+resolved to a shared catalog identity (``cataloged_team`` /
+``corporate_entity`` / ``cataloged_person``) stored on the role row and
a Knowledge Graph mention edge is written, so the same "설계팀" or
organization named across two posts becomes one linkable node. Fetch
-never reconstructs that id by ``entity_name``; that column is not unique.
-A person actor is opportunistically joined to an *existing*
-``cataloged_person`` row by name when Keyman extraction has already
-cataloged that name. The R&R evidence is written to
-``post_summary_person_mention`` rather than Keyman's
-``post_person_mention`` so either extractor can replace its own result
-without leaving or deleting the other's evidence.
+never reconstructs that id by ``entity_name`` or ``person_name``; those
+columns are not unique. A person actor is opportunistically joined to
+an *existing* ``cataloged_person`` row by name when Keyman extraction
+has already cataloged that name. The resolved ``cataloged_person_id``
+is stored on the role so a later read does not rejoin by display name.
+The R&R evidence is written to ``post_summary_person_mention`` rather
+than Keyman's ``post_person_mention`` so either extractor can replace
+its own result without leaving or deleting the other's evidence.
ADR 0010: an organization actor's name is resolved via
``get_or_create_corporate_entity`` -- similarity matching first, then
@@ -35,7 +37,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,
@@ -61,8 +67,8 @@ 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). This function does not join ``corporate_entity`` by
- ``entity_name``.
+ (ADR 0019 / 0027). This function does not join ``corporate_entity``
+ by ``entity_name``. Person chips read ``cataloged_person_id``.
"""
header = await conn.fetchrow(
"select korean_summary from post_summary_result where post_id = $1",
@@ -79,7 +85,8 @@ async def fetch_persisted_summary(
select role.actor_name, role.responsibility, role.actor_type_code,
role.affiliated_organization_name,
role.cataloged_team_id,
- role.cataloged_corporate_entity_id
+ role.cataloged_corporate_entity_id,
+ role.cataloged_person_id
from post_summary_role role
where role.post_id = $1
order by role.actor_name
@@ -96,6 +103,9 @@ async def fetch_persisted_summary(
elif row["cataloged_corporate_entity_id"] is not None:
catalog_node_id = str(row["cataloged_corporate_entity_id"])
catalog_node_type_code = NODE_CORPORATE_ENTITY
+ 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"],
@@ -180,6 +190,26 @@ async def persist_post_summary(
return payload
+async def _resolve_existing_cataloged_person_id(
+ conn: asyncpg.Connection, person_name: str
+) -> str | None:
+ """Return the earliest existing catalog person id for ``person_name``.
+
+ Lookup orders by ``created_at``, then ``person_id``. This function
+ does not insert a ``cataloged_person`` row (ADR 0009). A missing
+ catalog row stays unbound rather than inventing a person.
+ """
+ person_row = await conn.fetchrow(
+ "select person_id from cataloged_person "
+ "where person_name = $1 "
+ "order by created_at, person_id limit 1",
+ person_name,
+ )
+ if person_row is None:
+ return None
+ return str(person_row["person_id"])
+
+
async def _replace_summary_projection(
conn: asyncpg.Connection,
post_id: str,
@@ -210,11 +240,12 @@ async def _replace_summary_projection(
ordinal,
event_text,
)
- # ADR 0009 / 0019: resolve catalog identity before writing the role
- # row so fetch never reconstructs it by a non-unique name.
+ # ADR 0009 / 0019 / 0027: resolve catalog identity before writing
+ # the role row so fetch never reconstructs it by a non-unique name.
for role_index, role in enumerate(summary.roles_and_responsibilities):
cataloged_team_id = None
cataloged_corporate_entity_id = None
+ cataloged_person_id = None
if role.actor_type_code == ACTOR_TYPE_TEAM:
cataloged_team_id = await upsert_team(
conn,
@@ -226,12 +257,17 @@ async def _replace_summary_projection(
cataloged_corporate_entity_id = resolved_organization_ids.get(
role_index
)
+ elif role.actor_type_code == ACTOR_TYPE_PERSON:
+ cataloged_person_id = await _resolve_existing_cataloged_person_id(
+ conn,
+ role.actor_name,
+ )
await conn.execute(
"insert into post_summary_role "
"(post_id, actor_name, responsibility, actor_type_code, "
"affiliated_organization_name, cataloged_team_id, "
- "cataloged_corporate_entity_id) values "
- "($1, $2, $3, $4, $5, $6, $7)",
+ "cataloged_corporate_entity_id, cataloged_person_id) values "
+ "($1, $2, $3, $4, $5, $6, $7, $8)",
post_id,
role.actor_name,
role.responsibility,
@@ -239,6 +275,7 @@ async def _replace_summary_projection(
role.affiliated_organization_name,
cataloged_team_id,
cataloged_corporate_entity_id,
+ cataloged_person_id,
)
if cataloged_team_id is not None:
await conn.execute(
@@ -255,18 +292,13 @@ async def _replace_summary_projection(
post_id,
cataloged_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 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/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile
index 82e679d83..cb79897d5 100644
--- a/docker/postgres-init/Dockerfile
+++ b/docker/postgres-init/Dockerfile
@@ -30,6 +30,7 @@ COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d
COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.sql
COPY migrations/0023_analysis_run_outbox.sql /docker-entrypoint-initdb.d/24-analysis-run-outbox.sql
COPY migrations/0024_source_post_revision.sql /docker-entrypoint-initdb.d/25-source-post-revision.sql
+COPY migrations/0025_role_person_catalog_identity.sql /docker-entrypoint-initdb.d/26-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/0009-cross-post-actor-identity.md b/docs/adr/0009-cross-post-actor-identity.md
index 7bdbfa091..1a970c0fb 100644
--- a/docs/adr/0009-cross-post-actor-identity.md
+++ b/docs/adr/0009-cross-post-actor-identity.md
@@ -121,7 +121,10 @@ 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. Person identity on that row is
+[ADR 0027](0027-role-person-catalog-identity.md).
## References (APA 7th)
diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md
index 32b5d0a09..adc36574c 100644
--- a/docs/adr/0019-role-catalog-identity.md
+++ b/docs/adr/0019-role-catalog-identity.md
@@ -31,7 +31,8 @@ not.
`post_summary_role` stores the resolved catalog foreign keys
(`cataloged_team_id`, `cataloged_corporate_entity_id`) written during
`persist_post_summary`. `fetch_persisted_summary` reads those columns.
-It does not join `corporate_entity` by `entity_name`.
+It does not join `corporate_entity` by `entity_name`. Person identity
+is ADR 0027 (`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/0027-role-person-catalog-identity.md b/docs/adr/0027-role-person-catalog-identity.md
new file mode 100644
index 000000000..8ec63f6ae
--- /dev/null
+++ b/docs/adr/0027-role-person-catalog-identity.md
@@ -0,0 +1,55 @@
+# ADR 0027 — Persist the R&R person catalog identity on the role row
+
+**Decision status:** Accepted
+**Date:** 2026-08-17
+**Depends on:** ADR 0009 cross-post actor identity; ADR 0019 role catalog
+identity
+
+## Context
+
+ADR 0019 stores `cataloged_team_id` and `cataloged_corporate_entity_id`
+on `post_summary_role` so fetch does not rejoin by display name. Person
+actors were still joined at read time by `person_name`, or dropped from
+the payload entirely. Two people can share a display name. A later
+Keyman row with the same name then steals the chip, and a person named
+only in R&R (Keyman not extracted on that post) has no button.
+
+Fellegi and Sunter (1969) treat a match decision as a binding to one
+record, not a later re-search by a non-unique attribute.
+
+ADR 0021 is authorized analysis-run start. ADR 0026 is tied organization
+similarity. This is the next free slot.
+
+## Decision
+
+`post_summary_role` stores `cataloged_person_id` written during
+`persist_post_summary`. `fetch_persisted_summary` reads that column into
+`catalog_node_id` / `node_person`. Person lookup, when it still resolves
+by name, orders by `created_at`, then `person_id`. It still does not
+create a new `cataloged_person` row (ADR 0009 gap).
+
+Migration `0025_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.
+
+At most one of `cataloged_team_id`, `cataloged_corporate_entity_id`, and
+`cataloged_person_id` is set, and the set column must match
+`actor_type_code`.
+
+## Consequences
+
+- 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-0025 rows with two same-named mentions stay unbound until an
+ operator re-persists the summary. Do not guess a UUID at migrate time.
+
+## References
+
+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.2307/2286061
+
+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
diff --git a/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md b/docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md
new file mode 100644
index 000000000..d972377f6
--- /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 and ADR 0027.
+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.2307/2286061
+
+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 f9ababdae..d487877e1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.7.1",
+ "version": "2.7.2",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index f63c699d6..c972280dd 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1063,6 +1063,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: "당사",
@@ -1483,6 +1485,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());
@@ -1712,6 +1715,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 076420785..046d6796c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1497,7 +1497,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 = (