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
80 changes: 0 additions & 80 deletions .github/workflows/repair-global-ask-pnpm-v2.yml

This file was deleted.

5 changes: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,10 @@ Grounded in SKOS `skos:altLabel`/`skos:prefLabel` (Miles & Bechhofer,
2009). Wired into `backend/app/keyman_ingestion.py`'s affiliation loop
and the offline synthetic-batch script's paced re-implementation of it
(the batch script's own copy was also missing `role_title` persistence
entirely -- fixed alongside this).
entirely -- fixed alongside this). Global Ask discloses corroborated
raw→canonical pairs on cited-post evidence after ABAC and names opening
Event Lineage as the next action; pending and uncorroborated aliases
stay hidden ([ADR 0107](docs/adr/0107-verified-organization-label-evidence.md)).

Also fixed while running this against synthetic embedded-image fixtures:
`image_content.py`'s `_parse_description` required an exact single-pass
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.d/2.21.0-verified-organization-label-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 2.21.0 — Disclose corroborated organization labels on Global Ask

- Global Ask now shows search-corroborated raw→canonical organization
labels on cited-post evidence (for example `DC → Demo Corp`) after
ABAC-visible posts are selected.
- Pending and uncorroborated aliases stay excluded from nomination and
disclosure.
- Verified aliases now join the catalog through a stable entity id, so
same-named organizations cannot cross-match through display labels; existing
Compose volumes replay the required migrations.
- When a corroborated label matched, Ask names opening a cited post to
read Event Lineage as the next action (ADR 0107 / ADR 0008).
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ 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.21.0] - 2026-08-20

### Added

- Global Ask now discloses search-corroborated organization labels on
cited-post evidence (for example `DC → Demo Corp`) and names opening
a cited post to read Event Lineage as the next action. Pending and
uncorroborated aliases stay hidden. No TEPP theta is invented
(ADR 0107 / ADR 0008 / ADR 0106).
- Verified aliases join the catalog through a stable entity id rather than a
display-name match, and existing Compose volumes replay the identity
migration (ADR 0122).

## [2.19.0] - 2026-08-20

### Added
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,10 @@ current and moves focus to the Keyman heading once Keyman rows have
settled (ADR 0100). The report-member auto-land chain to related nodes
and Ask is not used for GNB origins. A home-list open does not gain that
focus. Do not invent a theta.

## Verified organization labels on Global Ask (v2.21.0)

Ask a corroborated synthetic alias such as `DC`. Cited-post evidence
names `DC → Demo Corp` and tells the buyer to open that cited post to
read Event Lineage (ADR 0107). Pending and uncorroborated aliases stay
hidden. Do not invent a theta.
102 changes: 101 additions & 1 deletion backend/app/global_ask_retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,102 @@
_TOKEN = re.compile(r"[^\W_]+(?:-[^\W_]+)*", re.UNICODE)
_EVIDENCE_POST_IDS = re.compile(r"\[evidence_post_id=([^]]+)\]")

VERIFIED_ORGANIZATION_LABEL_PREFIX = "verified organization label:"
VERIFIED_ORGANIZATION_LABEL_NEXT_ACTION = (
"Corroborated organization labels are current. Open a cited post to read Event Lineage."
)


def verified_organization_label_fact(
raw_organization_name: str,
resolved_organization_name: str,
) -> str:
"""Return one buyer-visible SKOS altLabel → prefLabel pair."""

return (
f"{VERIFIED_ORGANIZATION_LABEL_PREFIX} {raw_organization_name} → "
f"{resolved_organization_name}"
)


async def verified_organization_label_facts(
conn: asyncpg.Connection,
question: str | None,
post_ids: list[str],
*,
maximum_terms: int = 8,
) -> dict[str, tuple[str, ...]]:
"""Disclose corroborated raw→canonical labels for already-visible posts.

Pending and uncorroborated aliases never appear. Nomination remains
identifier-only; this query runs after ABAC-visible post ids are known.
"""

if not post_ids:
return {}
terms = global_ask_query_terms(question, maximum_terms=maximum_terms)
if not terms:
return {}
rows = await conn.fetch(
"""
with query_terms as (
select unnest($1::text[]) as term
), nominated_post as (
select unnest($2::uuid[]) as post_id
), verified_organization as (
select distinct
entity.corporate_entity_id,
resolution.raw_organization_name,
resolution.resolved_organization_name
from organization_name_resolution resolution
join corporate_entity entity
on resolution.resolved_corporate_entity_id = entity.corporate_entity_id
join query_terms term
on resolution.raw_organization_name ilike '%' || term.term || '%'
or resolution.resolved_organization_name ilike '%' || term.term || '%'
where resolution.verification_status_code = 'verify_corroborated'
), matched_organization_label as (
select distinct
mention.post_id,
organization.raw_organization_name,
organization.resolved_organization_name
from post_organization_mention mention
join verified_organization organization
on organization.corporate_entity_id = mention.corporate_entity_id
join nominated_post nominated
on nominated.post_id = mention.post_id
union
select distinct
mention.post_id,
organization.raw_organization_name,
organization.resolved_organization_name
from post_person_mention mention
join person_affiliation affiliation
on affiliation.person_id = mention.person_id
join verified_organization organization
on organization.corporate_entity_id = affiliation.affiliated_corporate_entity_id
join nominated_post nominated
on nominated.post_id = mention.post_id
Comment on lines +100 to +121

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: Label disclosure is context-agnostic once a resolution is entity-linked

link_verified_organization_entity (organization_name_resolution_ingestion.py) links a resolution keyed by raw-name + context digest to a stable entity id, but verified_organization_label_facts then discloses that alias for ANY ABAC-visible post mentioning the linked corporate_entity (or affiliating a mentioned person to it) whose raw/resolved label matches a query term — it does not re-check the post's own context digest. This is intentional per ADR 0107/0122 (disclosure joins by catalog identity, not per-post context), so a homonym-safe entity id gates it; noting it because it means a corroboration established in one post's context surfaces the alias across other posts about the same entity.

Open in Devin Review

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

)
select post_id::text as post_id,
raw_organization_name,
resolved_organization_name
from matched_organization_label
order by post_id, raw_organization_name, resolved_organization_name
""",
list(terms),
list(post_ids),
)
facts: dict[str, list[str]] = {}
for row in rows:
facts.setdefault(str(row["post_id"]), []).append(
verified_organization_label_fact(
row["raw_organization_name"],
row["resolved_organization_name"],
)
)
return {post_id: tuple(dict.fromkeys(values)) for post_id, values in facts.items()}
Comment on lines +64 to +140

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: Disclosure query recomputes terms independently of nomination

verified_organization_label_facts re-derives query terms via global_ask_query_terms(question, maximum_terms=8) (global_ask_retrieval.py), the same default used by semantic_candidate_post_ids's global_ask_query_terms(question). Because both cap at 8 terms and use the same stopword/token rules, the disclosed labels stay consistent with what nomination matched. Disclosure runs strictly on the ABAC-filtered visible_ids, and pending/uncorroborated aliases are excluded by the verify_corroborated filter — matching ADR 0107's 'disclosure cannot precede ABAC' constraint. No divergence found.

Open in Devin Review

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



def global_ask_query_terms(question: str | None, *, maximum_terms: int = 8) -> tuple[str, ...]:
"""Return bounded, de-duplicated lexical terms from a Global Ask query."""
Expand Down Expand Up @@ -90,7 +186,7 @@ async def semantic_candidate_post_ids(
select distinct entity.corporate_entity_id
from organization_name_resolution resolution
join corporate_entity entity
on entity.entity_name = resolution.resolved_organization_name
on resolution.resolved_corporate_entity_id = entity.corporate_entity_id
join query_terms term
on resolution.raw_organization_name ilike '%' || term.term || '%'
or resolution.resolved_organization_name ilike '%' || term.term || '%'
Expand Down Expand Up @@ -233,8 +329,12 @@ def _public_semantic_fact(fact: str) -> str:


__all__ = [
"VERIFIED_ORGANIZATION_LABEL_NEXT_ACTION",
"VERIFIED_ORGANIZATION_LABEL_PREFIX",
"global_ask_query_terms",
"graph_fact_evidence_post_ids",
"public_external_claim_facts",
"semantic_candidate_post_ids",
"verified_organization_label_fact",
"verified_organization_label_facts",
]
12 changes: 11 additions & 1 deletion backend/app/keyman_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@

from .corporate_entity_ingestion import get_or_create_corporate_entity
from .knowledge_graph import persist_edges_for_post
from .organization_name_resolution_ingestion import resolve_organization_name
from .organization_name_resolution_ingestion import (
link_verified_organization_entity,
resolve_organization_name,
)


async def _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[CorporateEntityCandidate]:
Expand Down Expand Up @@ -288,6 +291,13 @@ async def ingest_post_keymen(
corporate_entity_id,
mention.job_title,
)
if corporate_entity_id is not None:
await link_verified_organization_entity(
conn,
organization_name,
post_body,
corporate_entity_id,
)
Comment on lines +294 to +300

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: link_verified_organization_entity is a no-op for non-corroborated resolutions

link_verified_organization_entity (organization_name_resolution_ingestion.py) is called from the Keyman loop whenever corporate_entity_id is not None (keyman_ingestion.py). get_or_create_corporate_entity can return a non-null id even when the organization name was never corroborated (a plain similarity match on an unchanged raw name), so the UPDATE runs with a WHERE clause requiring verification_status_code = 'verify_corroborated' and simply matches zero rows. This is harmless (no wrong link is created) but issues an extra query per uncorroborated affiliation. Worth noting only if ingestion throughput matters; not a correctness issue.

Open in Devin Review

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

Comment on lines +294 to +300

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Verified-label nomination now requires the ingestion-time entity link

The verified_organization CTE join changed from a display-name match (entity.entity_name = resolution.resolved_organization_name) to a stable-identity match (resolution.resolved_corporate_entity_id = entity.corporate_entity_id) in both global_ask_retrieval.py (nomination) and global_ask_retrieval.py (label disclosure). resolved_corporate_entity_id is only ever populated by link_verified_organization_entity, which is wired solely into keyman_ingestion.py (the Keyman affiliation loop). Consequently: (1) historical organization_name_resolution rows with a NULL entity id no longer nominate or disclose anything (documented/intended per ADR 0122), and (2) R&R organization actors resolved in backend/app/post_summary_ingestion.py are NOT linked, so a post_organization_mention whose organization was resolved only through the R&R path will not surface a verified label unless the same entity was independently linked via the Keyman path. ADR 0107 lists "mentioned on ... those visible posts" as a supported path; verify whether omitting the link call from the R&R ingestion path is intentional scope or a coverage gap.

Open in Devin Review

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

if resolved_name not in resolved_names:
resolved_names.append(resolved_name)
normalized_mentions.append(
Expand Down
21 changes: 19 additions & 2 deletions backend/app/main.py

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: next_action override coexists with the verify_external skip status

_ask_next_action (main.py) overrides the verification-derived next action with the verified-label message whenever any cited post carries a verified_organization_label fact, regardless of external_verification_status. This means even with verify_external=False (status SKIPPED), a corroborated label match will surface the Event-Lineage next action rather than 'Enable public verification...'. This appears intentional per ADR 0107 ('Public-verification next actions remain for answers that have no such label'), but worth confirming that suppressing the verification-enable hint when a label is present is the desired precedence.

(Refers to this code)

Open in Devin Review

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

Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
cited_post_summaries,
render_global_ask_context,
)
from backend.app.global_ask_retrieval import VERIFIED_ORGANIZATION_LABEL_NEXT_ACTION
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_evaluation import (
ContextualOrchestratorPostEvaluationClient,
Expand Down Expand Up @@ -2708,6 +2709,21 @@ def _verification_next_action(
}.get(status_code, "Inspect the authorized cited posts and their evidence.")


def _ask_next_action(
status_code: str,
cited_evidence: list[dict[str, object]],
) -> str:
"""Name Event Lineage when a corroborated organization label nominated the cite."""

if any(
isinstance(fact, dict) and fact.get("kind") == "verified_organization_label"
for item in cited_evidence
for fact in item.get("facts", [])
):
return VERIFIED_ORGANIZATION_LABEL_NEXT_ACTION
return _verification_next_action(status_code)


async def _verify_public_claims(
question: str,
sources: list[ChatSourceDocument],
Expand Down Expand Up @@ -2960,6 +2976,7 @@ async def ask_agent(
cited_ids,
verify_external=request.verify_external,
)
evidence = cited_post_evidence(sources, cited_ids)
async with pool.acquire() as conn:
await persist_global_ask_turn(
conn,
Expand All @@ -2979,12 +2996,12 @@ async def ask_agent(
"answer_text": answer.answer_text,
"cited_post_ids": cited_ids,
"cited_posts": cited_post_summaries(sources, cited_ids),
"cited_post_evidence": cited_post_evidence(sources, cited_ids),
"cited_post_evidence": evidence,
"source_post_ids": [source.post_id for source in sources],
"timeline": global_ask_timeline(sources),
"external_verification_status": verification_status,
"external_claims": [claim.to_payload() for claim in external_claims],
"next_action": _verification_next_action(verification_status),
"next_action": _ask_next_action(verification_status, evidence),
}


Expand Down
27 changes: 27 additions & 0 deletions backend/app/organization_name_resolution_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,33 @@ def _context_sha256(context_text: str) -> str:
return hashlib.sha256(context_text.encode("utf-8")).hexdigest()


async def link_verified_organization_entity(
conn: asyncpg.Connection,
raw_name: str,
context_text: str,
corporate_entity_id: str,
) -> None:
"""Attach a corroborated resolution to its stable catalog entity id.

Display names are not identity keys: two catalog entities may legitimately
share one name. Only the exact raw-name/context cache row is linked, and
only after external verification has corroborated the resolution.
"""
await conn.execute(
"""
update organization_name_resolution
set resolved_corporate_entity_id = $1,
resolved_at = now()
where raw_organization_name = $2
and context_sha256 = $3
and verification_status_code = 'verify_corroborated'
""",
corporate_entity_id,
raw_name,
_context_sha256(context_text),
)
Comment on lines +26 to +50

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: Resolution row committed before the transaction, linked inside it

In keyman_ingestion.py, _resolve_affiliated_organization (which calls resolve_organization_name, potentially INSERTing the organization_name_resolution cache row) runs in the pre-transaction enrichment loop at keyman_ingestion.py, so that row auto-commits. link_verified_organization_entity then runs inside the async with conn.transaction() block at line 294-300 and updates that already-committed row. The cache key is consistent on both sides (raw name = organization_name, context_sha256 = sha256(post_body)), and the verification_status_code = 'verify_corroborated' guard means an uncorroborated resolution is never linked even though get_or_create_corporate_entity may still return a non-null entity id. If the Keyman write transaction rolls back, the resolution row survives with a NULL entity id while its intended link is discarded — a benign transient state that self-heals on the next re-ingestion. No correctness issue found.

Open in Devin Review

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



async def resolve_organization_name(
conn: asyncpg.Connection,
resolution_client: OrganizationNameResolutionClient,
Expand Down
Loading
Loading