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
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,13 @@ Keyman sides are labeled from `common_lookup_value` (`Our side`,
codes when a label exists. Related-node person chips use the same
side lookup label (for example, `Our side` or `Counterparty`) rather
than exposing the generic PROV-O `Person` class as business context.
When a person has several distinct affiliation identities, the API emits
`affiliation_ambiguous` and the reusable `RelatedNodeChip` says
`multiple organizations`; it never chooses the first row as a primary.
When exactly one identity remains, the chip includes that organization.
Organization chips use the cataloged entity-level label and post chips use
the source title only. The full N:N list stays visible on the Keyman panel,
which names the next action before the buyer continues the walk.

`GET /api/posts` and `GET /api/posts/{post_id}` include
`voc_type_label` / `visibility_label` from `common_lookup_value` so
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ All notable changes to this project are documented here. Format follows

### Changed

- Related-node chips now show authorized business context: a unique
affiliation, a truthful `multiple organizations` signal, or the
cataloged entity level. Post chips retain the source title only.
- The plural-affiliation panel tells the buyer to read the complete
Keyman list before continuing the graph walk, preserving every
membership instead of inventing a primary organization.
- Renamed "Buyer" terminology to reader/workspace naming across the frontend
shell, backend evidence helpers, and living docs (ADR 0119). Historical ADRs
and changelog entries retain their point-in-time wording.
Expand Down
92 changes: 91 additions & 1 deletion backend/app/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from uuid import UUID

Expand Down Expand Up @@ -449,6 +451,65 @@ async def load_visible_subgraph(
)
return [edge_spec_from_row(row) for row in rows]


@dataclass(frozen=True)
class CompactAffiliation:
"""Authorized compact affiliation for one related-node person."""

identity_count: int
display_name: str | None = None

@property
def ambiguous(self) -> bool:
"""Return whether more than one organization identity is known."""
return self.identity_count > 1


def compact_affiliation_summaries(
rows: list[Mapping[str, Any]],
) -> dict[str, CompactAffiliation]:
"""Summarize affiliations without inventing a primary organization."""
catalog_ids: dict[str, set[str]] = {}
catalog_labels: dict[str, dict[str, str]] = {}
unresolved_labels: dict[str, dict[str, str]] = {}
for row in rows:
person_id = str(row["person_id"])
raw_name = (row["affiliated_organization_name"] or "").strip()
catalog_id = row["affiliated_corporate_entity_id"]
catalog_name = (row["catalog_entity_name"] or "").strip()
if catalog_id is not None:
identity = str(catalog_id)
catalog_ids.setdefault(person_id, set()).add(identity)
label = catalog_name or raw_name
if label:
catalog_labels.setdefault(person_id, {})[identity] = label
continue
if raw_name:
unresolved_labels.setdefault(person_id, {}).setdefault(
raw_name.casefold(), raw_name
)

summaries: dict[str, CompactAffiliation] = {}
for person_id in set(catalog_ids) | set(unresolved_labels):
labels_by_id = catalog_labels.get(person_id, {})
catalog_name_fold = {name.casefold() for name in labels_by_id.values()}
leftover_names = {
name
for fold, name in unresolved_labels.get(person_id, {}).items()
if fold not in catalog_name_fold
}
identity_count = len(catalog_ids.get(person_id, set())) + len(leftover_names)
if identity_count == 0:
continue
display_name: str | None = None
if identity_count == 1:
display_name = next(iter(leftover_names), None)
if display_name is None and labels_by_id:
display_name = next(iter(labels_by_id.values()))
summaries[person_id] = CompactAffiliation(identity_count, display_name)
return summaries
Comment on lines +492 to +510

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: compact_affiliation_summaries identity counting is conservative and well-tested

compact_affiliation_summaries (knowledge_graph.py) counts one identity per distinct affiliated_corporate_entity_id plus each unresolved raw name whose case-folded form does not match a resolved catalog label. Notable behaviors verified against the new unit tests: two distinct catalog ids that happen to share the same entity_name are treated as two identities (ambiguous), a nameless resolved catalog id yields identity_count==1 with no display_name (side-only, not ambiguous), and unresolved aliases matching a catalog label collapse. The display_name when identity_count==1 depends on row iteration order only when a single catalog id carries multiple differing labels, which is a benign edge case. No inconsistency with the emitted affiliation_ambiguous/affiliation_organization_name payload contract.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



async def hydrate_related_nodes(
conn: asyncpg.Connection,
related: list[tuple[str, float]],
Expand All @@ -457,6 +518,8 @@ async def hydrate_related_nodes(

Unknown ids are dropped. Ontology fields are omitted (not faked)
when ``node_type_code`` has no term in lineageweave-kg.ttl.
Person and organization nodes carry decision-relevant affiliation and
entity-level labels when the catalog provides them.
"""
person_ids: list[str] = []
post_ids: list[str] = []
Expand All @@ -482,6 +545,20 @@ async def hydrate_related_nodes(
person_ids,
)
} if person_ids else {}
affiliations = compact_affiliation_summaries(
await conn.fetch(
"""
select pa.person_id, pa.affiliated_organization_name,
pa.affiliated_corporate_entity_id,
ce.entity_name as catalog_entity_name
from person_affiliation pa
left join corporate_entity ce
on ce.corporate_entity_id = pa.affiliated_corporate_entity_id
where pa.person_id = any($1::uuid[])
""",
person_ids,
)
) if person_ids else {}
Comment on lines +548 to +561

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Affiliation summary query is not ABAC-filtered by visible posts

In knowledge_graph.py, hydrate_related_nodes fetches person_affiliation rows for related people directly (joining corporate_entity) with no filter on which posts the affiliation evidence came from, unlike the RWR subgraph itself which is bounded by visible_post_ids. This means an affiliation_organization_name could be surfaced for a related person even if that affiliation is only attested on posts the account cannot see. This matches the pre-existing pattern in fetch_post_keymen (which also queries person_affiliation without a visibility filter), so it is likely consistent with the affiliate-tree contract, but it is worth confirming that person_affiliation is considered account-agnostic catalog data and not something that could leak private-post-derived org context via the new compact chip. Flagging for confirmation only.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

posts = {
str(row["post_id"]): row
# Safe SQL: the eligibility predicate is an immutable schema fragment; post ids are bound.
Expand All @@ -496,7 +573,8 @@ async def hydrate_related_nodes(
corps = {
str(row["corporate_entity_id"]): row
for row in await conn.fetch(
"select corporate_entity_id, entity_name from corporate_entity where corporate_entity_id = any($1::uuid[])",
"select corporate_entity_id, entity_name, entity_level_code "
"from corporate_entity where corporate_entity_id = any($1::uuid[])",
corp_ids,
)
} if corp_ids else {}
Expand All @@ -511,6 +589,9 @@ async def hydrate_related_nodes(
side_labels = await labels_for_codes(
conn, [row["person_side_code"] for row in people.values()]
)
level_labels = await labels_for_codes(
conn, [row["entity_level_code"] for row in corps.values()]
)

payload: list[dict[str, Any]] = []
for node_type_code, node_id, score in parsed:
Expand All @@ -525,12 +606,21 @@ async def hydrate_related_nodes(
item["label"] = people[node_id]["person_name"]
item["person_side_code"] = side
item["person_side_label"] = side_labels.get(side, side)
summary = affiliations.get(node_id)
if summary is not None:
if summary.display_name:
item["affiliation_organization_name"] = summary.display_name
if summary.ambiguous:
item["affiliation_ambiguous"] = True
elif node_type_code == NODE_POST and node_id in posts:
item["label"] = posts[node_id]["post_title"]
item["post_body_excerpt"] = posts[node_id]["post_body_excerpt"]
item["post_body_truncated"] = posts[node_id]["post_body_truncated"]
elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps:
item["label"] = corps[node_id]["entity_name"]
level = corps[node_id]["entity_level_code"]
item["entity_level_code"] = level
item["entity_level_label"] = level_labels.get(level, level)
elif node_type_code == NODE_TEAM and node_id in teams:
item["label"] = teams[node_id]["team_name"]
else:
Expand Down
7 changes: 6 additions & 1 deletion backend/app/post_eligibility.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""Shared source-post eligibility SQL for reader-facing evidence reads."""
"""Shared source-post eligibility SQL for buyer evidence reads.

Keep this module importable as a first-class backend dependency: the knowledge
graph and every post-scoped read use the same predicate so a graph projection
cannot bypass the buyer visibility boundary.
"""

SOURCE_CONTEXT_COLUMNS = (
"source_author_code",
Expand Down
25 changes: 19 additions & 6 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,7 @@ def test_other_corp_private_voc_evidence_is_forbidden(client, demo_analyst_token


def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_token, seeded_db) -> None:
"""Expose buyer-facing labels while excluding invisible related posts."""
response = client.get(
f"/api/keymen/{seeded_db['our_person_id']}/related",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
Expand All @@ -2214,6 +2215,13 @@ def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_to
assert counterpart["ontology_iri"].endswith("#Person")
assert counterpart["person_side_code"] == "counterparty"
assert counterpart["person_side_label"] == "Counterparty"
assert "affiliation_organization_name" not in counterpart
assert counterpart["affiliation_ambiguous"] is True
corp_nodes = [
node for node in body["related"] if node["node_type_code"] == "node_corporate_entity"
]
assert corp_nodes
assert all(node.get("entity_level_label") for node in corp_nodes)
own_post = by_id[seeded_db["own_private_post_id"]]
assert own_post["ontology_label"] == "Post"

Expand Down Expand Up @@ -2311,8 +2319,10 @@ def test_related_keymen_role_history_is_empty_without_any_role_classification(
def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts(
client, demo_analyst_token, seeded_db
) -> None:
"""GET /api/corporate-entities/{id}/related must walk from the org
the same way Keyman related walks from a person.
"""Verify the org-related endpoint walks like Keyman-related lookup.

The response includes person-side labels while excluding private and
hidden related posts.
"""
response = client.get(
f"/api/corporate-entities/{seeded_db['own_corp_id']}/related",
Expand All @@ -2326,6 +2336,8 @@ def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts(
our_person = next(node for node in body["related"] if node["node_id"] == seeded_db["our_person_id"])
assert our_person["person_side_code"] == "our_side"
assert our_person["person_side_label"] == "Our side"
assert our_person["affiliation_organization_name"] == "Test Corp"
assert "affiliation_ambiguous" not in our_person
assert seeded_db["other_private_post_id"] not in related_ids
assert seeded_db["hidden_person_id"] not in related_ids

Expand Down Expand Up @@ -3180,6 +3192,7 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok
POST /api/posts/{id}/verify-relations.
"""
os.environ["SEARXNG_BASE_URL"] = _SEARXNG_BASE_URL
fake_org_name = f"Zzqxvthorp Fictitious Nonexistent Org {uuid.uuid4().hex}"

admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
Expand All @@ -3199,8 +3212,8 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok
cur.execute(
"insert into post_counterparty_entity (post_id, counterparty_entity_name, relationship_type_code) "
"values (%s, 'Wikipedia', 'rel_voc'), "
"(%s, 'Zzqxvthorp Fictitious Nonexistent Org 8f3e1c', 'rel_voco')",
(seeded_db["public_post_id"], seeded_db["public_post_id"]),
"(%s, %s, 'rel_voco')",
(seeded_db["public_post_id"], seeded_db["public_post_id"], fake_org_name),
)
finally:
admin_conn.close()
Expand All @@ -3220,7 +3233,7 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok
)
assert real_org["verification_evidence_url"]

fake_org = verified["Zzqxvthorp Fictitious Nonexistent Org 8f3e1c"]
fake_org = verified[fake_org_name]
assert fake_org["verification_status_code"] == "verify_uncorroborated"
assert fake_org["verification_evidence_url"] is None

Expand All @@ -3230,7 +3243,7 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok
)
persisted = {c["counterparty_entity_name"]: c for c in counterparties_response.json()["counterparties"]}
assert persisted["Wikipedia"]["verification_status_code"] == "verify_corroborated"
assert persisted["Zzqxvthorp Fictitious Nonexistent Org 8f3e1c"]["verification_status_code"] == "verify_uncorroborated"
assert persisted[fake_org_name]["verification_status_code"] == "verify_uncorroborated"

# Already-checked rows are left alone on a second call, not re-searched.
second_response = client.post(
Expand Down
36 changes: 36 additions & 0 deletions docs/adr/0106-related-node-business-captions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR 0106: Related-node chips use business context, not ontology class

- Status: Accepted
- Date: 2026-08-20

## Context

The related-node walk is a buyer decision surface. Showing only `Person`,
`Organization`, or `Post` does not identify the next useful action. A person
may have several memberships, and `person_affiliation` has no primary marker;
choosing the first sorted row would invent a primary organization.

## Decision

- Use the authorized side label and a unique organization only when one
identity remains after catalog-id and case-folded alias reconciliation.
- Mark more than one identity as `affiliation_ambiguous` and render
`multiple organizations`; never expose a guessed primary.
- Use the cataloged entity level for organization chips and the source title
only for post chips.
- Keep the full affiliation list on the Keyman surface. The related panel
tells the buyer to read that list, or extract Keymen first, before clicking
the chip to continue the walk.
- Reuse `RelatedNodeChip` and `--related-node-*` design tokens for every
repeated walk control. Visible captions are included in accessible names.

## Consequences

The compact walk remains scannable while retaining multiple-membership truth.
An unavailable or unresolved affiliation remains unavailable; it is never
converted into a plausible-sounding company name. Temporal membership
intervals remain a follow-up schema decision and are not inferred here.

## References

See [RELATED_NODE_AFFILIATION_REFERENCES.md](../doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md).
23 changes: 23 additions & 0 deletions docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Related-node affiliation references

APA 7th sources for [ADR 0106](../adr/0106-related-node-business-captions.md).

Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership
multiple classification (MMMC) models. *Statistical Modelling, 1*(2),
103–124. https://doi.org/10.1177/1471082X0100100202

World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines
(WCAG) 2.2*. https://www.w3.org/TR/WCAG22/

W3C Design Tokens Community Group. (2025). *Design Tokens Format Module*.
https://www.w3.org/community/design-tokens/

Singer, J. D., & Willett, J. B. (2003). *Applied longitudinal data analysis:
Modeling change and event occurrence*. Oxford University Press.

MMMC grounds the no-invented-primary rule; WCAG 2.2 grounds accessible names
that contain visible chip captions; the Design Tokens Format grounds the
shared repeated-control tokens. Singer and Willett document why a future
time-bounded affiliation schema must distinguish a former membership from a
current one. Until that schema exists, this feature counts stored identities
without asserting that they are current.
32 changes: 28 additions & 4 deletions docs/lineage-bi-research-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ in [ADR 0064](adr/0064-lineage-evidence-and-tree-assembly.md), [ADR 0062](adr/00
and the existing channel-specific ADRs; update this file as literature and
validation evidence changes, not as an untracked architecture decision.

## Related-node business captions

ADR 0103 keeps compact graph navigation truthful for multiple-membership

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Docs cite wrong ADR number for this feature

The added text attributes the related-node caption feature to "ADR 0103", but ADR 0103 is the semantic-document-evidence-contract; this feature's ADR is 0106 (0106-related-node-business-captions.md), which the same line even links to. The number contradicts its own link and appears again at line 304.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

people: the UI uses an authorized unique affiliation only when one identity
remains and otherwise says `multiple organizations`. The full N:N evidence
stays on the Keyman surface, and the panel gives the buyer the next action.
The implementation and APA 7th sources are recorded in
[`docs/adr/0106-related-node-business-captions.md`](adr/0106-related-node-business-captions.md)
and [`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md).

## The problem this is answering

Given a pile of short, timestamped records that are only loosely grouped
Expand Down Expand Up @@ -261,10 +271,21 @@ not yet a resolved person node, and a mention whose side cannot be
classified into the closed `{our_side, counterparty}` set is dropped
rather than guessed. N:N organization attachments are slot-filling on
that mention (a person may have zero, one, or several affiliations in
the same post), not a second independent NER pass. The live client
the same post), not a second independent NER pass. Compact related-node
chips therefore add an organization only when exactly one organization
identity is known. Resolved catalog aliases collapse; distinct
memberships stay distinct and the chip says `multiple organizations`
so a plural set is not mistaken for a missing affiliation. Collapsing
several memberships into a sorted "primary" would repeat the
atomistic fallacy Browne et al. (2001) warn against for
multiple-membership structures. The related panel names that next
action: read the Keyman list (or extract Keymen), then click the
chip to continue the walk. Citations live in
[`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). The live client
calls contextual-orchestrator (`mode="auto"`) rather than a raw LLM
API so adaptive reasoning-effort allocation stays centralized with the
adjudication channel. Proven for real during development against
API so the orchestration plane can allocate route, verify, or a
deeper workflow; adjudication and post-chat keep explicit
`mode="verify"`. Proven for real during development against
`fixtures.ambiguous_keyman_post` when orchestrator credentials are set;
the default suite asserts the parser and the never-fake null client.

Expand All @@ -279,7 +300,10 @@ adaptive cutoff (a relevance-ratio threshold against the top score) --
`tests/test_knowledge_graph.py` proves this concretely: the same ratio
threshold yields a five-node related-set from a well-connected "hub" node
and a one-node related-set from a sparsely-connected node, with no hop-count
constant anywhere in the algorithm or the test.
constant anywhere in the algorithm or the test. Hydrated related-node
chips (ADR 0103) then replace the ontology class with the authorized
side or entity-level label so the next click is a business decision,
not a class reminder.

## Entity-relationship classification and corporate hierarchy resolution (Phase 3)

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import "./relatedNodeTokens.css";

/* App-level Shell Layout */
.app-shell {
display: flex;
Expand Down
Loading