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
8 changes: 7 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
112 changes: 60 additions & 52 deletions backend/app/post_summary_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ADR 0019 on this branch says fetch reads cataloged_person_id. This SELECT omits it, so person catalog_node_id stays null and the UI cannot walk the stored person. #153 selects that column and maps it to node_person.

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
""",
Expand Down Expand Up @@ -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)


Expand Down
71 changes: 71 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
2 changes: 2 additions & 0 deletions docs/adr/0018-related-nodes-team-org-walk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions docs/adr/0019-role-catalog-identity.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading