From 84eed1450d5ba4e361868f047b4b3c84bdd38e40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 13:33:24 +0000 Subject: [PATCH] fix: persist R&R person catalog ids after landed 0019 (v2.7.2) Store cataloged_person_id on post_summary_role so a person chip walks the stored catalog row even when Keyman was not extracted on that post. Lookup orders by created_at, then person_id. Historical backfill leaves two same-named mentions unbound (ADR 0027). Co-authored-by: Seongho Bae --- AGENTS.md | 6 + ARCHITECTURE.md | 12 +- .../2.7.2-role-person-catalog-identity.md | 6 + CHANGELOG.md | 11 ++ CLAUDE.md | 3 +- backend/app/post_summary_ingestion.py | 90 +++++++--- docker/postgres-init/Dockerfile | 1 + docs/adr/0009-cross-post-actor-identity.md | 5 +- docs/adr/0019-role-catalog-identity.md | 3 +- docs/adr/0027-role-person-catalog-identity.md | 55 ++++++ .../ROLE_CATALOG_IDENTITY_REFERENCES.md | 22 +++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 14 ++ frontend/src/App.tsx | 19 +- lineageweave/__init__.py | 2 +- migrations/0001_initial_schema.sql | 18 ++ .../0025_role_person_catalog_identity.sql | 65 +++++++ .../0025_role_person_catalog_identity.sql | 10 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 44 ++--- ...test_analysis_run_reconstruction_schema.py | 1 + tests/test_analysis_run_registry_schema.py | 4 + tests/test_documentation_hygiene.py | 51 ++++++ tests/test_ingestion_transaction_contracts.py | 166 ++++++++++++++++++ tests/test_person_mention_projection.py | 76 ++++++++ tests/test_source_post_revision.py | 3 + uv.lock | 2 +- 27 files changed, 634 insertions(+), 59 deletions(-) create mode 100644 CHANGELOG.d/2.7.2-role-person-catalog-identity.md create mode 100644 docs/adr/0027-role-person-catalog-identity.md create mode 100644 docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md create mode 100644 migrations/0025_role_person_catalog_identity.sql create mode 100644 migrations/rollback/0025_role_person_catalog_identity.sql 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 = (