Skip to content
Closed
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
16 changes: 10 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,12 +862,16 @@ new table needed. `lineageweave/knowledge_graph.py`'s
(`edge_mention_team`, `edge_team_affiliation`, `edge_mention_organization`);
`backend/app/post_summary_ingestion.py`'s `persist_post_summary` now
resolves each R&R actor's identity, stores that id on
`post_summary_role` (ADR 0019 — `entity_name` is not unique), and calls
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).
`post_summary_role` (ADR 0019 — `entity_name` and `person_name` are not
unique), and calls 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, ordered by `created_at` then
`person_id` (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). Fetch returns `cataloged_person_id` as
`catalog_node_id` so a person chip walks the stored row even when
Keyman was not extracted on that post. Historical backfill leaves a
role unbound when two same-named mentions already exist.

## Phase 12: a real counterparty organization is auto-created, not left permanently unresolved

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.d/0.88.1-role-person-catalog-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
R&R person chips read the stored catalog id. Historical person
backfill leaves homonym mentions unbound.
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).

## [0.88.1] - 2026-08-16

### Fixed

- An R&R person chip now walks the catalog id stored on that role row
(ADR 0019). Two catalog people can share a display name; persist
orders by `created_at`, then `person_id`, and fetch does not rejoin
by `person_name`. Open a post whose R&R names a cataloged person
even when Keyman was not extracted on that post — the chip is a
button. Historical backfill leaves two same-named mentions unbound.

## [0.88.0] - 2026-08-16

### Added
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ cutoff before treating the body as reconstructed evidence (ADR 0016).
cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start`
reconstructs that frozen cutoff bag (ADR 0021) and does not invent a
theta. Hover the Result prefix to read the parent-choice digest.
R&R chips read the catalog id stored on `post_summary_role`
(ADR 0019), including `cataloged_person_id`. Do not rejoin
`corporate_entity` by `entity_name` or `cataloged_person` by
`person_name`. Historical backfill leaves a role unbound when two
same-named mentions already exist on the post.
80 changes: 56 additions & 24 deletions backend/app/post_summary_ingestion.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""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
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
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 "설계팀", organization, or person
named across two posts becomes one linkable node. Fetch 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, ordered by ``created_at`` then
``person_id``. 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.
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 @@ -62,7 +68,7 @@ async def fetch_persisted_summary(

``catalog_node_id`` comes from the role row's catalog foreign keys
(ADR 0019). This function does not join ``corporate_entity`` by
``entity_name``.
``entity_name`` or ``cataloged_person`` by ``person_name``.
"""
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 @@ -215,6 +225,7 @@ async def _replace_summary_projection(
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 +237,24 @@ 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 _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,21 +271,37 @@ 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)


async def _existing_cataloged_person_id(
conn: asyncpg.Connection, person_name: str
) -> str | None:
"""Return the earliest existing catalog person for ``person_name``.

R&R does not create a ``cataloged_person`` row (ADR 0009). Two
people can share a display name, so this query orders by
``created_at``, then ``person_id``, instead of ``LIMIT 1`` without
``ORDER BY``.
"""
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"])


def seeded_demo_summary() -> PostSummary:
"""Synthetic Korean summary for the demo public post -- not an LLM result."""
return PostSummary(
Expand Down
1 change: 1 addition & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-ro
COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql
COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql
COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.sql
COPY migrations/0023_role_person_catalog_identity.sql /docker-entrypoint-initdb.d/24-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
4 changes: 3 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,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)

Expand Down
3 changes: 3 additions & 0 deletions docs/adr/0018-related-nodes-team-org-walk.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ 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` or
`cataloged_person` by `person_name`.

## References

Expand Down
40 changes: 30 additions & 10 deletions docs/adr/0019-role-catalog-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,42 @@ normal form (Codd, 1970; Date, 2019).

Team identity is already unique on
`(team_name, affiliated_organization_name)`. Organization identity is
not.
not. Person display names are not unique either: two `cataloged_person`
rows can share `person_name`. A fetch join on that name, or
`LIMIT 1` without `ORDER BY`, can attach a later homonym.

## Decision

`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`.

Migration `0019_role_catalog_identity.sql` backfills existing rows from
a post-scoped mention only when the name match is unique on that post.
Two same-named mentions stay unbound rather than guessing.
(`cataloged_team_id`, `cataloged_corporate_entity_id`,
`cataloged_person_id`) written during `persist_post_summary`.
`fetch_persisted_summary` reads those columns. It does not join
`corporate_entity` by `entity_name` or `cataloged_person` by
`person_name`. At most one catalog foreign key is set, and the set
column must match `actor_type_code`.

Person lookup, when it still resolves by name, orders by `created_at`,
then `person_id`. It still does not create a `cataloged_person` row
(ADR 0009 gap).

Migration `0019_role_catalog_identity.sql` backfills team and
organization rows from a post-scoped mention only when the name match
is unique on that post. Migration `0023_role_person_catalog_identity.sql`
does the same for people (`HAVING count(*) = 1`). Two same-named
mentions stay unbound rather than guessing a UUID (Fellegi & Sunter,
1969).

## Consequences

- Open a post whose R&R names an organization that shares a display
name with another catalog row. The button walks the resolved id, not
the homonym.
- Clicking that name still uses `GET /api/corporate-entities/{id}/related`
or `GET /api/teams/{id}/related`. Authz stays person/entity-parity:
- 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.
- Clicking a team or organization name still uses
`GET /api/corporate-entities/{id}/related` or
`GET /api/teams/{id}/related`. Authz stays person/entity-parity:
a team mentioned only on another corp's private post is 403; an
unknown UUID is 404.

Expand All @@ -57,6 +73,10 @@ Date, C. J. (2019). *Database design and relational theory: Normal forms
and all that jazz* (2nd ed.). Apress.
https://doi.org/10.1007/978-1-4842-5540-7

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

International Organization for Standardization. (2023). *ISO/IEC
11179-1:2023: Information technology—Metadata registries (MDR)—Part 1:
Framework*.
Expand Down
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. 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/
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": "0.88.0",
"version": "0.88.1",
"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 @@ -789,6 +789,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 @@ -1209,6 +1211,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 @@ -1388,6 +1391,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 Keyman nodes from an R&R person", async () => {
stubBackend();
render(<App />);
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1278,7 +1278,24 @@ function PostDetailPopup({
const catalogId = rr.catalog_node_id;
const catalogType = rr.catalog_node_type_code;
let actorName: ReactNode = <strong>{rr.actor_name}</strong>;
if (person) {
if (catalogType === NODE_PERSON && catalogId) {
actorName = (
<button
className="keyman-select"
aria-label={`R&R person: ${rr.actor_name}`}
onClick={() => {
setFocusEntity(null);
setFocusTeam(null);
setFocusPerson({
personId: catalogId,
personName: rr.actor_name,
});
}}
>
<strong>{rr.actor_name}</strong>
</button>
);
} else if (person) {
actorName = (
<button
className="keyman-select"
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@
"sentence_excerpts",
]

__version__ = "0.88.0"
__version__ = "0.88.1"
Loading