Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
12 changes: 11 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.d/2.7.2-role-person-catalog-identity.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
90 changes: 61 additions & 29 deletions backend/app/post_summary_ingestion.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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
Expand All @@ -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"],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -226,19 +257,25 @@ 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,
role.actor_type_code,
role.affiliated_organization_name,
cataloged_team_id,
cataloged_corporate_entity_id,
cataloged_person_id,
)
if cataloged_team_id is not None:
await conn.execute(
Expand All @@ -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)


Expand Down
1 change: 1 addition & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion docs/adr/0009-cross-post-actor-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0019-role-catalog-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions docs/adr/0027-role-person-catalog-identity.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions docs/doctoring/ROLE_CATALOG_IDENTITY_REFERENCES.md
Original file line number Diff line number Diff line change
@@ -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/
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.7.1",
"version": "2.7.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "당사",
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1712,6 +1715,17 @@ describe("App, authenticated", () => {
);
});

it("opens related nodes from an R&R person catalog id", async () => {
stubBackend();
render(<App />);
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(<App />);
Expand Down
Loading