From 63ebb11ce2dfb39209d163ad90eff4deff0cb80d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:24:38 +0000 Subject: [PATCH 1/7] feat: disclose verified organization labels on Global Ask Show search-corroborated raw-to-canonical organization labels on cited-post evidence after ABAC, and name opening Event Lineage as the next action. Pending and uncorroborated aliases stay excluded (ADR 0107). --- ARCHITECTURE.md | 5 +- ....0-verified-organization-label-evidence.md | 9 ++ CHANGELOG.md | 10 ++ CLAUDE.md | 7 ++ backend/app/global_ask_retrieval.py | 100 ++++++++++++++++++ backend/app/main.py | 23 +++- backend/app/post_chat_ingestion.py | 4 + .../test_global_ask_public_verification.py | 20 ++++ ...08-organization-abbreviation-resolution.md | 11 +- ...07-verified-organization-label-evidence.md | 68 ++++++++++++ frontend/package.json | 2 +- frontend/src/App.tsx | 18 +++- frontend/src/AskAgentPanel.test.tsx | 40 +++++++ frontend/src/i18n.test.ts | 2 + frontend/src/i18n.ts | 12 +++ lineageweave/post_chat.py | 2 + pyproject.toml | 2 +- tests/test_global_ask_retrieval.py | 91 ++++++++++++++++ tests/test_global_ask_sources.py | 40 +++++++ tests/test_post_chat.py | 30 ++++++ uv.lock | 2 +- 21 files changed, 486 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.d/2.21.0-verified-organization-label-evidence.md create mode 100644 docs/adr/0107-verified-organization-label-evidence.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a6d312de4..19fb41877 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md b/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md new file mode 100644 index 000000000..def076a1d --- /dev/null +++ b/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md @@ -0,0 +1,9 @@ +# 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. +- When a corroborated label matched, Ask names opening a cited post to + read Event Lineage as the next action (ADR 0107 / ADR 0008). diff --git a/CHANGELOG.md b/CHANGELOG.md index df6d0d9fe..845b18114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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). + ## [2.19.0] - 2026-08-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6f1ee6755..121b062ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/backend/app/global_ask_retrieval.py b/backend/app/global_ask_retrieval.py index b0b333789..861775e48 100644 --- a/backend/app/global_ask_retrieval.py +++ b/backend/app/global_ask_retrieval.py @@ -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 entity.entity_name = resolution.resolved_organization_name + 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 + ) + 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()} + 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.""" @@ -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", ] diff --git a/backend/app/main.py b/backend/app/main.py index 163db7b9c..dbc75d743 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, @@ -2577,6 +2578,21 @@ def _verification_next_action(status_code: str) -> str: }.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], @@ -2795,7 +2811,7 @@ async def ask_agent( "timeline": [], "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, []), } try: answer = await asyncio.to_thread( @@ -2816,6 +2832,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, @@ -2835,12 +2852,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), } diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 48521dd56..66d05481c 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -47,6 +47,7 @@ global_ask_query_terms, public_external_claim_facts, semantic_candidate_post_ids, + verified_organization_label_facts, ) from lineageweave.claim_verification import GlobalAskSourceDocument from lineageweave.ontology import ontology_annotations @@ -706,6 +707,7 @@ async def gather_global_chat_sources( visible_ids = [str(row["post_id"]) for row in visible_rows] anchor_is_visible = lineage_anchor_id in visible_ids semantic_facts = await _semantic_facts_for_posts(conn, visible_ids) + label_facts = await verified_organization_label_facts(conn, question, visible_ids) graph_facts = (await _graph_facts_for_posts(conn, visible_ids))[:16] public_post_ids = frozenset( str(row["post_id"]) @@ -740,7 +742,9 @@ async def gather_global_chat_sources( graph_facts=graph_facts if index == 0 else (), evidence_facts=_source_hint_facts(row) + semantic_facts.get(post_id, ()) + + label_facts.get(post_id, ()) + lineage_fact, + occurred_at=_timestamp_text(row), timeline_kind=( "lineage_neighbor" diff --git a/backend/tests/test_global_ask_public_verification.py b/backend/tests/test_global_ask_public_verification.py index 5439a7584..1018f1674 100644 --- a/backend/tests/test_global_ask_public_verification.py +++ b/backend/tests/test_global_ask_public_verification.py @@ -88,3 +88,23 @@ def verify(self, claim: Any) -> ClaimVerificationResult: assert len(claims) == 1 assert claims[0].status_code == CLAIM_SUPPORTED assert verified == ["project: Apollo"] + + +def test_ask_next_action_names_event_lineage_for_verified_organization_labels() -> None: + evidence = [ + { + "post_id": "11111111-1111-1111-1111-111111111111", + "facts": [ + { + "kind": "verified_organization_label", + "text": "verified organization label: DC → Demo Corp", + } + ], + } + ] + assert main._ask_next_action(VERIFICATION_SKIPPED, evidence) == ( + "Corroborated organization labels are current. Open a cited post to read Event Lineage." + ) + assert main._ask_next_action(VERIFICATION_SKIPPED, []) == ( + "Enable public verification to check eligible public claims." + ) diff --git a/docs/adr/0008-organization-abbreviation-resolution.md b/docs/adr/0008-organization-abbreviation-resolution.md index 21a08dc0d..b1cfbe122 100644 --- a/docs/adr/0008-organization-abbreviation-resolution.md +++ b/docs/adr/0008-organization-abbreviation-resolution.md @@ -61,10 +61,13 @@ projection. A raw abbreviation, local-language name, or translated name that has actually appeared in a post context can therefore nominate the same posts as its canonical corporate-entity name. The projection joins the corroborated `resolved_organization_name` back to `corporate_entity`; pending and -uncorroborated rows remain invisible. It does not generate translations or -infer aliases at query time. This preserves the source-observed label and the -SKOS preferred/alternative-label distinction while applying the document-level -context required by multilingual entity linking (De Cao et al., 2022). +uncorroborated rows remain invisible. After ABAC-visible posts are selected, +Global Ask discloses the matched raw→canonical pair on cited-post evidence and +names opening Event Lineage as the next action (ADR 0107). It does not generate +translations or infer aliases at query time. This preserves the source-observed +label and the SKOS preferred/alternative-label distinction while applying the +document-level context required by multilingual entity linking (De Cao et al., +2022). Only a search-corroborated resolution is ever substituted in for downstream entity matching (`resolve_corporate_entity`) -- an diff --git a/docs/adr/0107-verified-organization-label-evidence.md b/docs/adr/0107-verified-organization-label-evidence.md new file mode 100644 index 000000000..f745571a1 --- /dev/null +++ b/docs/adr/0107-verified-organization-label-evidence.md @@ -0,0 +1,68 @@ +# ADR 0107 — Disclose corroborated organization labels on Global Ask + +- Status: Accepted +- Date: 2026-08-20 +- Owners: LineageWeave Buyer surface / Organization identity +- Depends on: [0008](0008-organization-abbreviation-resolution.md), [0106](0106-global-ask-public-claim-verification.md) + +## Context + +Global Ask may nominate authorized posts from search-corroborated +raw/canonical organization-name pairs (ADR 0008). Nomination returns +post identifiers only. Without a buyer-visible evidence fact, a query +for a synthetic alias such as `DC` can surface a Demo Corp post while +leaving the matched SKOS altLabel → prefLabel pair hidden. The buyer +then cannot tell why that post was nominated or what to open next. + +Pending and uncorroborated aliases must stay invisible. A guessed +translation or an unverified LLM expansion is not evidence. + +## Decision + +After the ordinary source-visibility/ABAC predicate has selected +visible posts, Global Ask SHALL load corroborated +`organization_name_resolution` rows whose raw or resolved label matches +the bounded query terms and whose canonical name joins a +`corporate_entity` already mentioned on, or affiliated to a person on, +those visible posts. + +Each match is disclosed as cited-post evidence of kind +`verified_organization_label` with the raw label and canonical label +kept separate (`DC → Demo Corp`). The fact is internal SKOS evidence. It +is not a public-search claim, is not placed in `external_claim_facts`, +and does not authority-promote a Knowledge Graph edge. + +Pending (`verify_pending`) and uncorroborated (`verify_uncorroborated`) +rows remain excluded from both nomination and disclosure. The query +uses one indexable `ILIKE` predicate per label column. + +When at least one cited post carries a verified organization label, the +response next action SHALL name opening that cited post to read Event +Lineage. Public-verification next actions remain for answers that have +no such label. + +## Buyer next action + +- corroborated label match → open a cited post to read Event Lineage +- no corroborated label match → keep the ADR 0106 public-verification + next action + +## Consequences + +- The buyer can inspect the exact raw→canonical pair that nominated the + post without inventing a translation at query time. +- Disclosure cannot precede ABAC: only already-visible post identifiers + are joined. +- Synthetic fixtures only; Demo Corp / DC and Aurora Grid Power / AGP + are the documented examples. + +## References + +De Cao, N., Wu, L., Popat, K., Artetxe, M., Goyal, N., Plekhanov, M., +Zettlemoyer, L., & Riedel, S. (2022). Multilingual autoregressive +entity linking. *Transactions of the Association for Computational +Linguistics, 10*, 274–290. https://doi.org/10.1162/tacl_a_00460 + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ diff --git a/frontend/package.json b/frontend/package.json index 4a61cd78c..053d0cb58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.19.0", + "version": "2.21.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c112b87c9..e1d6f9a58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -784,12 +784,28 @@ const CHAT_EVIDENCE_KIND_LABELS: Record = { semantic_project: "Semantic project", semantic_role: "Semantic role", semantic_keyman: "Semantic Keyman", + verified_organization_label: "Verified organization label", }; function chatEvidenceKindLabel(kind: string): string { return t(CHAT_EVIDENCE_KIND_LABELS[kind] ?? "Evidence"); } +function askCitedPostsNextAction(answer: AskAgentResponse): string { + const hasVerifiedOrganizationLabel = Boolean( + answer.cited_post_evidence?.some((item) => + item.facts.some((fact) => fact.kind === "verified_organization_label"), + ), + ); + if (hasVerifiedOrganizationLabel) { + return ( + answer.next_action || + "Corroborated organization labels are current. Open a cited post to read Event Lineage." + ); + } + return "Authorized cited posts are current. Open a cited post to read Event Lineage."; +} + const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -4794,7 +4810,7 @@ export function AskAgentPanel({ {answer.cited_posts && answer.cited_posts.length > 0 && ( <>

- {t("Authorized cited posts are current. Open a cited post to read Event Lineage.")} + {t(askCitedPostsNextAction(answer))}

{t("Cited posts")}

    diff --git a/frontend/src/AskAgentPanel.test.tsx b/frontend/src/AskAgentPanel.test.tsx index 8e84910ad..594e8bfdc 100644 --- a/frontend/src/AskAgentPanel.test.tsx +++ b/frontend/src/AskAgentPanel.test.tsx @@ -63,4 +63,44 @@ describe("AskAgentPanel public verification", () => { ).toHaveAttribute("href", "https://example.com/apollo"); expect(screen.getByText("Internal Apollo post")).toBeInTheDocument(); }); + + it("discloses corroborated organization labels and names Event Lineage next", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + answer_text: "DC is a corroborated label for Demo Corp.", + cited_post_ids: ["post-1"], + cited_posts: [{ post_id: "post-1", post_title: "Demo Corp shipment" }], + cited_post_evidence: [ + { + post_id: "post-1", + facts: [ + { + kind: "verified_organization_label", + text: "verified organization label: DC → Demo Corp", + }, + ], + }, + ], + source_post_ids: ["post-1"], + next_action: + "Corroborated organization labels are current. Open a cited post to read Event Lineage.", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await userEvent.type(screen.getByLabelText("Ask a question"), "DC"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByLabelText("Next action")).toHaveTextContent( + "Corroborated organization labels are current. Open a cited post to read Event Lineage.", + ); + expect(screen.getByText("Verified organization label")).toBeInTheDocument(); + expect(screen.getByText("verified organization label: DC → Demo Corp")).toBeInTheDocument(); + expect(screen.queryByText("verify_pending")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 5bff6a284..82acc4f95 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -41,6 +41,8 @@ describe("i18n", () => { "Authorized commitments are current. Open a commitment to read Event Lineage.", "Authorized customer entities are current. Open a related post to read Event Lineage.", "Authorized cited posts are current. Open a cited post to read Event Lineage.", + "Corroborated organization labels are current. Open a cited post to read Event Lineage.", + "Verified organization label", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 393aaa673..18dd9b435 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -130,6 +130,8 @@ const TRANSLATIONS: Partial>> = { "권한이 있는 고객 엔터티가 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 관련 글을 여세요.", "Authorized cited posts are current. Open a cited post to read Event Lineage.": "권한이 있는 인용 글이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 인용 글을 여세요.", + "Corroborated organization labels are current. Open a cited post to read Event Lineage.": + "뒷받침된 조직 이름이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 인용 글을 여세요.", "Open cited post:": "인용 글 열기:", "Filter by visibility": "공개 여부로 필터", "Sort posts": "글 정렬", @@ -183,6 +185,7 @@ const TRANSLATIONS: Partial>> = { "Semantic project": "의미 기반 프로젝트", "Semantic role": "의미 기반 역할", "Semantic Keyman": "의미 기반 핵심 담당자", + "Verified organization label": "검증된 조직 이름", "No authorized source posts are available for this question.": "이 질문에 사용할 수 있는 권한 있는 원문이 없습니다.", "Choose an authorized post before asking a question.": "질문하기 전에 권한이 있는 글을 선택하세요.", "Loading source posts...": "질문할 원문을 불러오는 중...", @@ -472,6 +475,8 @@ const TRANSLATIONS: Partial>> = { "已授权客户实体为当前内容。打开一篇相关文章阅读事件谱系。", "Authorized cited posts are current. Open a cited post to read Event Lineage.": "已授权引用文章为当前内容。打开一篇引用文章阅读事件谱系。", + "Corroborated organization labels are current. Open a cited post to read Event Lineage.": + "已核实的组织名称为当前内容。打开一篇引用文章阅读事件谱系。", "Open cited post:": "打开引用文章:", "Filter by visibility": "按公开状态筛选", "Sort posts": "排序文章", @@ -525,6 +530,7 @@ const TRANSLATIONS: Partial>> = { "Semantic project": "语义项目", "Semantic role": "语义角色", "Semantic Keyman": "语义关键人员", + "Verified organization label": "已核实的组织名称", "No authorized source posts are available for this question.": "没有可用于此问题的已授权来源文章。", "Choose an authorized post before asking a question.": "提问前请选择有权限查看的文章。", "Loading source posts...": "正在加载问题来源文章...", @@ -837,6 +843,8 @@ const TRANSLATIONS: Partial>> = { "権限のある顧客エンティティが現在表示されています。イベント系譜を読むには関連投稿を開いてください。", "Authorized cited posts are current. Open a cited post to read Event Lineage.": "権限のある引用投稿が現在表示されています。イベント系譜を読むには引用投稿を開いてください。", + "Corroborated organization labels are current. Open a cited post to read Event Lineage.": + "裏付けられた組織名が現在表示されています。イベント系譜を読むには引用投稿を開いてください。", "Open cited post:": "引用投稿を開く:", "Filter by visibility": "公開状態で絞り込み", "Sort posts": "投稿を並べ替え", @@ -890,6 +898,7 @@ const TRANSLATIONS: Partial>> = { "Semantic project": "意味的なプロジェクト", "Semantic role": "意味的な役割", "Semantic Keyman": "意味的なキーパーソン", + "Verified organization label": "確認済みの組織名", "No authorized source posts are available for this question.": "この質問に利用できる許可済みの原文投稿はありません。", "Choose an authorized post before asking a question.": "質問する前に閲覧権限のある投稿を選択してください。", "Loading source posts...": "質問の原文を読み込んでいます...", @@ -1178,6 +1187,8 @@ const TRANSLATIONS: Partial>> = { "Các thực thể khách hàng được cấp quyền đang hiện tại. Hãy mở một bài liên quan để đọc Dòng sự kiện.", "Authorized cited posts are current. Open a cited post to read Event Lineage.": "Các bài được trích dẫn được cấp quyền đang hiện tại. Hãy mở một bài trích dẫn để đọc Dòng sự kiện.", + "Corroborated organization labels are current. Open a cited post to read Event Lineage.": + "Các nhãn tổ chức được xác minh đang hiện tại. Hãy mở một bài trích dẫn để đọc Dòng sự kiện.", "Open cited post:": "Mở bài trích dẫn:", "Filter by visibility": "Lọc theo trạng thái hiển thị", "Sort posts": "Sắp xếp bài viết", @@ -1231,6 +1242,7 @@ const TRANSLATIONS: Partial>> = { "Semantic project": "Dự án ngữ nghĩa", "Semantic role": "Vai trò ngữ nghĩa", "Semantic Keyman": "Keyman ngữ nghĩa", + "Verified organization label": "Nhãn tổ chức đã xác minh", "No authorized source posts are available for this question.": "Không có bài viết nguồn được cấp quyền cho câu hỏi này.", "Choose an authorized post before asking a question.": "Hãy chọn một bài viết được cấp quyền trước khi đặt câu hỏi.", "Loading source posts...": "Đang tải bài viết nguồn cho câu hỏi...", diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index ec3e23a73..dbac24e9f 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -96,6 +96,8 @@ def cited_post_summaries( def _buyer_evidence_kind(fact: str) -> str: + if fact.startswith("verified organization label:"): + return "verified_organization_label" if fact.startswith("project:"): return "semantic_project" if fact.startswith("actor:"): diff --git a/pyproject.toml b/pyproject.toml index ec0d44278..6fbba1976 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.19.0" +version = "2.21.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_global_ask_retrieval.py b/tests/test_global_ask_retrieval.py index 21a3bc33f..baa4cbffc 100644 --- a/tests/test_global_ask_retrieval.py +++ b/tests/test_global_ask_retrieval.py @@ -101,6 +101,18 @@ async def fetch(self, query: str, *arguments): ] +class _LabelConnection: + def __init__(self, rows: list[dict[str, str]]) -> None: + self.rows = rows + self.arguments = None + self.query = None + + async def fetch(self, query: str, *arguments): + self.query = query + self.arguments = arguments + return self.rows + + @pytest.mark.anyio async def test_semantic_candidate_post_ids_is_bounded_deduplicated_and_indexable( monkeypatch, @@ -169,3 +181,82 @@ async def test_semantic_candidate_post_ids_skips_empty_or_zero_budget(monkeypatc assert await retrieval.semantic_candidate_post_ids(connection, "", maximum_candidates=8) == [] assert await retrieval.semantic_candidate_post_ids(connection, "Apollo", maximum_candidates=0) == [] assert connection.query is None + + +def test_verified_organization_label_fact_keeps_raw_and_canonical_separate() -> None: + assert ( + retrieval.verified_organization_label_fact("DC", "Demo Corp") + == "verified organization label: DC → Demo Corp" + ) + assert retrieval.VERIFIED_ORGANIZATION_LABEL_NEXT_ACTION == ( + "Corroborated organization labels are current. Open a cited post to read Event Lineage." + ) + + +@pytest.mark.anyio +async def test_verified_organization_label_facts_disclose_corroborated_pairs_only() -> None: + connection = _LabelConnection( + [ + { + "post_id": "11111111-1111-1111-1111-111111111111", + "raw_organization_name": "DC", + "resolved_organization_name": "Demo Corp", + }, + { + "post_id": "11111111-1111-1111-1111-111111111111", + "raw_organization_name": "DC", + "resolved_organization_name": "Demo Corp", + }, + { + "post_id": "22222222-2222-2222-2222-222222222222", + "raw_organization_name": "AGP", + "resolved_organization_name": "Aurora Grid Power", + }, + ] + ) + + facts = await retrieval.verified_organization_label_facts( + connection, + "Which DC posts mention Demo Corp?", + [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + ], + ) + + assert facts == { + "11111111-1111-1111-1111-111111111111": ( + "verified organization label: DC → Demo Corp", + ), + "22222222-2222-2222-2222-222222222222": ( + "verified organization label: AGP → Aurora Grid Power", + ), + } + query = connection.query.casefold() + assert "matched_organization_label" in query + assert "organization_name_resolution" in query + assert "resolution.verification_status_code = 'verify_corroborated'" in query + assert "verify_pending" not in query + assert "verify_uncorroborated" not in query + assert "resolution.raw_organization_name ilike" in query + assert "resolution.resolved_organization_name ilike" in query + assert "post_organization_mention" in query + assert "person_affiliation" in query + assert "concat_ws" not in query + assert connection.arguments[0] == ["dc", "mention", "demo", "corp"] + assert connection.arguments[1] == [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + ] + + +@pytest.mark.anyio +async def test_verified_organization_label_facts_skip_empty_question_or_posts() -> None: + connection = _LabelConnection([]) + assert await retrieval.verified_organization_label_facts(connection, "DC", []) == {} + assert await retrieval.verified_organization_label_facts( + connection, + "", + ["11111111-1111-1111-1111-111111111111"], + ) == {} + assert connection.query is None diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index ba31767f9..b160d268c 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -418,3 +418,43 @@ async def fetch(self, query: str, *args): ) assert [source.post_id for source in sources] == ["visible-match"] + + +def test_global_sources_disclose_corroborated_organization_labels() -> None: + rows = [ + { + "post_id": "11111111-1111-1111-1111-111111111111", + "post_title": "Demo Corp shipment", + "post_body": "DC delayed the synthetic shipment.", + "visibility_code": "public", + "corporate_entity_id": None, + "matched_in": "title", + } + ] + + class FakeConnection: + async def fetch(self, query: str, *args): + if "from source_post" in query or "array_position($2::uuid[], post_id)" in query: + return rows + if "matched_organization_label" in query: + return [ + { + "post_id": "11111111-1111-1111-1111-111111111111", + "raw_organization_name": "DC", + "resolved_organization_name": "Demo Corp", + } + ] + return [] + + sources = asyncio.run( + gather_global_chat_sources( + FakeConnection(), + lambda row: True, + question="DC", + limit=1, + ) + ) + + assert len(sources) == 1 + assert "verified organization label: DC → Demo Corp" in sources[0].evidence_facts + assert all("verify_pending" not in fact for fact in sources[0].evidence_facts) diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index 7c7cd46be..11c57a775 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -191,6 +191,36 @@ def test_cited_post_evidence_hides_prompt_metadata_but_keeps_semantic_facts() -> ] +def test_cited_post_evidence_discloses_verified_organization_labels() -> None: + source = ChatSourceDocument( + "post-label", + "Demo Corp shipment", + "body", + evidence_facts=( + "verified organization label: DC → Demo Corp", + "project: Semantic project | evidence: Body evidence | ontology_iri: https://example.test/ontology#Project [provenance=post_project_mention]", + ), + ) + + evidence = cited_post_evidence((source,), ("post-label",)) + + assert evidence == [ + { + "post_id": "post-label", + "facts": [ + { + "kind": "verified_organization_label", + "text": "verified organization label: DC → Demo Corp", + }, + { + "kind": "semantic_project", + "text": "project: Semantic project | evidence: Body evidence", + }, + ], + } + ] + + def test_chat_render_includes_persisted_graph_facts_with_source_evidence() -> None: source = ChatSourceDocument( "post-graph", diff --git a/uv.lock b/uv.lock index 52e5dd21a..d7ead652b 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.19.0" +version = "2.21.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From b50e51f733983478937c3c8304f4828ca8e8083c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:15:37 +0900 Subject: [PATCH 2/7] test: track verified organization label lookup --- tests/test_global_ask_public_integration.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_global_ask_public_integration.py b/tests/test_global_ask_public_integration.py index fb7a6263c..f3d803215 100644 --- a/tests/test_global_ask_public_integration.py +++ b/tests/test_global_ask_public_integration.py @@ -46,6 +46,11 @@ async def fetch(self, query: str, *arguments: Any) -> list[dict[str, Any]]: return self.lexical_rows if "select child_post_id as other_id" in query: return [] + if "matched_organization_label" in query: + # The integration fixture has no corroborated label rows. Keep + # the new post-ABAC evidence lookup explicit so this double tracks + # the production query contract instead of rejecting it. + return [] if "select post_id, post_title, post_body" in query: self.final_query_calls += 1 return self.final_rows From 79b2bd3d6324f721115b7bbc3c5e591f9ade96bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:28:15 +0900 Subject: [PATCH 3/7] docs: record verified organization CI repair --- docs/product-technical-gap-baseline.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 0a30d0a4e..f2c94f636 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -239,3 +239,19 @@ ADRs remain normative. This document is the product/technical traceability projection: update the affected FR/NFR row and Gap closure evidence when an ADR or PR changes product behavior. Never turn a PR title, green unit test, or old runtime note into a shipped/live claim. + +## Exact-head CI repair: PR #318, 2026-08-21 KST + +The previous exact head `63ebb11ce2dfb39209d163ad90eff4deff0cb80d` failed the +Full test suite in `tests/test_global_ask_public_integration.py` because its +test connection double rejected the new `matched_organization_label` query. +The production query was already the intended post-ABAC lookup; the fixture +had not been updated to return the valid empty result for a synthetic post +without a corroborated label. + +Commit `b50e51f733983478937c3c8304f4828ca8e8083c` adds only that explicit +empty-result branch. The exact-head local evidence is Python `781 passed, 16 +skipped`, frontend `168 passed`, lint, production build, and Storybook build. +GitHub's two required checks are queued for this exact head, and no formal +independent approval has been observed; this is therefore a repaired PR, not +protected-main or release evidence. From 87611ff843659ebeebf5bfcdf3b05938a385c566 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:24:13 +0900 Subject: [PATCH 4/7] fix: link verified organization labels by entity id --- ....0-verified-organization-label-evidence.md | 3 ++ CHANGELOG.md | 3 ++ backend/app/global_ask_retrieval.py | 4 +- backend/app/keyman_ingestion.py | 12 ++++- .../organization_name_resolution_ingestion.py | 27 ++++++++++ backend/tests/test_api.py | 6 +++ docker/postgres-init/migrate.sh | 4 +- ...07-verified-organization-label-evidence.md | 5 +- ...stable-organization-resolution-identity.md | 49 +++++++++++++++++++ ...0103_organization_resolution_entity_id.sql | 10 ++++ tests/test_global_ask_retrieval.py | 4 ++ tests/test_migration_replay.py | 21 ++++++++ ..._organization_name_resolution_ingestion.py | 19 +++++++ 13 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0122-stable-organization-resolution-identity.md create mode 100644 migrations/0103_organization_resolution_entity_id.sql diff --git a/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md b/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md index def076a1d..43392c5ea 100644 --- a/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md +++ b/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md @@ -5,5 +5,8 @@ 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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 845b18114..ca5897a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ All notable changes to this project are documented here. Format follows 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 diff --git a/backend/app/global_ask_retrieval.py b/backend/app/global_ask_retrieval.py index 861775e48..29374c08a 100644 --- a/backend/app/global_ask_retrieval.py +++ b/backend/app/global_ask_retrieval.py @@ -92,7 +92,7 @@ async def verified_organization_label_facts( resolution.resolved_organization_name 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 || '%' @@ -186,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 || '%' diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py index ade1044a1..7ac93496b 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -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]: @@ -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, + ) if resolved_name not in resolved_names: resolved_names.append(resolved_name) normalized_mentions.append( diff --git a/backend/app/organization_name_resolution_ingestion.py b/backend/app/organization_name_resolution_ingestion.py index fe6520389..f23656c2c 100644 --- a/backend/app/organization_name_resolution_ingestion.py +++ b/backend/app/organization_name_resolution_ingestion.py @@ -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), + ) + + async def resolve_organization_name( conn: asyncpg.Connection, resolution_client: OrganizationNameResolutionClient, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index d19f3de1c..21502ba38 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -105,6 +105,11 @@ / "migrations" / "0051_context_scoped_organization_name_resolution.sql" ) +_ORGANIZATION_ENTITY_ID_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0103_organization_resolution_entity_id.sql" +) _GLOBAL_ASK_CONTEXT_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -224,6 +229,7 @@ def seeded_db(demo_analyst_token): cur.execute(_SUMMARY_FIVE_W1H_MIGRATION.read_text()) cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) cur.execute(_ORGANIZATION_CONTEXT_MIGRATION.read_text()) + cur.execute(_ORGANIZATION_ENTITY_ID_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_CONTEXT_MIGRATION.read_text()) cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute( diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 6fad01496..2c26c97ce 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,8 +18,8 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0051_*|0052_*|0053_*|0054_*) ;; - 0060_*|0100_*) ;; + 0051_*|0052_*|0053_*|0054_*|0055_*) ;; + 0060_*|0100_*|0103_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0107-verified-organization-label-evidence.md b/docs/adr/0107-verified-organization-label-evidence.md index f745571a1..bba5a00e0 100644 --- a/docs/adr/0107-verified-organization-label-evidence.md +++ b/docs/adr/0107-verified-organization-label-evidence.md @@ -22,9 +22,10 @@ translation or an unverified LLM expansion is not evidence. After the ordinary source-visibility/ABAC predicate has selected visible posts, Global Ask SHALL load corroborated `organization_name_resolution` rows whose raw or resolved label matches -the bounded query terms and whose canonical name joins a +the bounded query terms and whose `resolved_corporate_entity_id` joins a `corporate_entity` already mentioned on, or affiliated to a person on, -those visible posts. +those visible posts. The stable-identity rule is defined in ADR 0122; +historical rows without that foreign key remain excluded. Each match is disclosed as cited-post evidence of kind `verified_organization_label` with the raw label and canonical label diff --git a/docs/adr/0122-stable-organization-resolution-identity.md b/docs/adr/0122-stable-organization-resolution-identity.md new file mode 100644 index 000000000..cd1abbc98 --- /dev/null +++ b/docs/adr/0122-stable-organization-resolution-identity.md @@ -0,0 +1,49 @@ +# ADR 0122 — Link verified organization resolutions by catalog identity + +- Status: Accepted +- Date: 2026-08-21 +- Owners: LineageWeave ingestion / Global Ask retrieval +- Depends on: [0008](0008-organization-abbreviation-resolution.md), [0107](0107-verified-organization-label-evidence.md) +- Figma File ID: N/A (backend persistence and query-integrity decision; no UI change) + +## Context + +`organization_name_resolution.resolved_organization_name` is a label, not a +stable identity. Joining it to `corporate_entity.entity_name` can attach a +verified alias to the wrong organization when two catalog entities share the +same display name. Existing rows also need an additive migration path because +Compose volumes can predate the feature migration. + +## Decision + +Persist the resolved catalog identity in +`organization_name_resolution.resolved_corporate_entity_id` with a foreign-key +constraint. Global Ask nomination and evidence disclosure join through that +identifier. The ingestion path links only the exact raw-name/context cache row +after corroboration and catalog resolution. Historical rows remain unlinked +and are excluded until a future authorized ingestion can establish the stable +relationship; no name-based backfill is permitted. + +`docker/postgres-init/migrate.sh` replays both the verified-label indexes and +this identity migration on existing Compose volumes. + +## Consequences + +- Same-named organizations cannot cross-match through a display-label join. +- Foreign-key integrity prevents references to a missing catalog entity. +- Historical cache rows may be temporarily unavailable to verified-label search + until they are safely re-linked. +- The schema remains normalized: the resolution stores a foreign key, while the + catalog remains the owner of the organization label. + +## Buyer next action + +Open the cited post from a verified label result; if the alias has not yet been +linked to a catalog identity, continue with the ordinary organization search +flow instead of treating the label as authoritative. + +## References + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ diff --git a/migrations/0103_organization_resolution_entity_id.sql b/migrations/0103_organization_resolution_entity_id.sql new file mode 100644 index 000000000..a42b947c4 --- /dev/null +++ b/migrations/0103_organization_resolution_entity_id.sql @@ -0,0 +1,10 @@ +-- Keep verified organization resolutions linked by catalog identity, not name. +alter table organization_name_resolution + add column if not exists resolved_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + +create index if not exists organization_name_resolution_entity_id_idx + on organization_name_resolution (resolved_corporate_entity_id); + +comment on column organization_name_resolution.resolved_corporate_entity_id is + 'Stable catalog identity for a corroborated resolution; null means the historical cache row has not been linked yet.'; diff --git a/tests/test_global_ask_retrieval.py b/tests/test_global_ask_retrieval.py index baa4cbffc..ab5404bbe 100644 --- a/tests/test_global_ask_retrieval.py +++ b/tests/test_global_ask_retrieval.py @@ -141,6 +141,8 @@ async def test_semantic_candidate_post_ids_is_bounded_deduplicated_and_indexable assert "post_organization_mention" in query assert "organization_name_resolution" in query assert "resolution.verification_status_code = 'verify_corroborated'" in query + assert "resolution.resolved_corporate_entity_id = entity.corporate_entity_id" in query + assert "entity.entity_name = resolution.resolved_organization_name" not in query assert "resolution.raw_organization_name ilike" in query assert "resolution.resolved_organization_name ilike" in query assert "person_affiliation" in query @@ -236,6 +238,8 @@ async def test_verified_organization_label_facts_disclose_corroborated_pairs_onl assert "matched_organization_label" in query assert "organization_name_resolution" in query assert "resolution.verification_status_code = 'verify_corroborated'" in query + assert "resolution.resolved_corporate_entity_id = entity.corporate_entity_id" in query + assert "entity.entity_name = resolution.resolved_organization_name" not in query assert "verify_pending" not in query assert "verify_uncorroborated" not in query assert "resolution.raw_organization_name ilike" in query diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29098f0a5..0ed46902e 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -56,3 +56,24 @@ def test_migrate_sh_replays_global_ask_context_migration() -> None: ).read_text(encoding="utf-8") assert "0052_*" in script + + +def test_migrate_sh_replays_verified_label_and_catalog_identity_migrations() -> None: + """Existing Compose volumes receive both Global Ask schema upgrades.""" + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0055_*" in script + assert "0103_*" in script + + migration = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0103_organization_resolution_entity_id.sql" + ).read_text(encoding="utf-8") + assert "resolved_corporate_entity_id" in migration + assert "references corporate_entity" in migration diff --git a/tests/test_organization_name_resolution_ingestion.py b/tests/test_organization_name_resolution_ingestion.py index 5a41a5db1..d3ad581f5 100644 --- a/tests/test_organization_name_resolution_ingestion.py +++ b/tests/test_organization_name_resolution_ingestion.py @@ -85,3 +85,22 @@ def test_new_resolution_is_persisted_but_only_verified_name_is_returned( assert result == expected assert len(conn.executed) == 1 assert "organization_name_resolution" in conn.executed[0][0] + + +def test_verified_resolution_links_to_the_stable_corporate_entity_id() -> None: + """Persist the catalog identity instead of rejoining by display name.""" + conn = _Connection() + + asyncio.run( + ingestion.link_verified_organization_entity( + conn, + "AGP", + "context", + "entity-id", + ) + ) + + query, args = conn.executed[0] + assert "resolved_corporate_entity_id" in query + assert "verification_status_code = 'verify_corroborated'" in query + assert args == ("entity-id", "AGP", ingestion._context_sha256("context")) From 8e67050b4cf6ea0a205f7cbafb5492f0a6c164fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:35:59 +0900 Subject: [PATCH 5/7] docs: record stable organization identity repair --- docs/product-technical-gap-baseline.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f2c94f636..e288fad2b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -255,3 +255,20 @@ skipped`, frontend `168 passed`, lint, production build, and Storybook build. GitHub's two required checks are queued for this exact head, and no formal independent approval has been observed; this is therefore a repaired PR, not protected-main or release evidence. + +## Exact-head organization identity repair: PR #318, 2026-08-21 KST + +The verified organization-label path previously joined the resolved display +name to `corporate_entity.entity_name`. That could cross-match two catalog +entities with the same label. The current head `a64bdf0a3cee71d79ca3af882ad50ee5aa1f46f2`, +restacked on PR #316 current head `1d4e65707abbdea2d0d131cbc03742fe9cfb8ab0`, +adds the normalized `resolved_corporate_entity_id` foreign key, links only a +corroborated raw-name/context row inside the Keyman write transaction, and +replays migrations `0055_*` and `0103_*` for existing Compose volumes. No +historical row is backfilled by display name (ADR 0122). + +The exact-head local evidence is Python `783 passed, 16 skipped, 4 warnings`, +frontend `169 passed`, lint, production build, Storybook build, `actionlint`, +and `git diff --check`. Hosted Checks remain queued and no formal independent +approval has been observed; this is proposed PR evidence, not protected-main +or release evidence. From 2329b69f87fac73f5a236a2b33e22198b699ebf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:11:45 +0900 Subject: [PATCH 6/7] fix: make organization identity migration reversible --- .../0122-stable-organization-resolution-identity.md | 2 ++ migrations/0103_organization_resolution_entity_id.sql | 4 ++++ .../0103_organization_resolution_entity_id.sql | 7 +++++++ tests/test_migration_replay.py | 10 ++++++++++ 4 files changed, 23 insertions(+) create mode 100644 migrations/rollback/0103_organization_resolution_entity_id.sql diff --git a/docs/adr/0122-stable-organization-resolution-identity.md b/docs/adr/0122-stable-organization-resolution-identity.md index cd1abbc98..a508e4be7 100644 --- a/docs/adr/0122-stable-organization-resolution-identity.md +++ b/docs/adr/0122-stable-organization-resolution-identity.md @@ -26,6 +26,8 @@ relationship; no name-based backfill is permitted. `docker/postgres-init/migrate.sh` replays both the verified-label indexes and this identity migration on existing Compose volumes. +The paired rollback removes only the new index and nullable foreign-key column; +it assumes the application has first been rolled back to the prior contract. ## Consequences diff --git a/migrations/0103_organization_resolution_entity_id.sql b/migrations/0103_organization_resolution_entity_id.sql index a42b947c4..bfecf59e8 100644 --- a/migrations/0103_organization_resolution_entity_id.sql +++ b/migrations/0103_organization_resolution_entity_id.sql @@ -1,3 +1,5 @@ +begin; + -- Keep verified organization resolutions linked by catalog identity, not name. alter table organization_name_resolution add column if not exists resolved_corporate_entity_id uuid @@ -8,3 +10,5 @@ create index if not exists organization_name_resolution_entity_id_idx comment on column organization_name_resolution.resolved_corporate_entity_id is 'Stable catalog identity for a corroborated resolution; null means the historical cache row has not been linked yet.'; + +commit; diff --git a/migrations/rollback/0103_organization_resolution_entity_id.sql b/migrations/rollback/0103_organization_resolution_entity_id.sql new file mode 100644 index 000000000..b08e189e4 --- /dev/null +++ b/migrations/rollback/0103_organization_resolution_entity_id.sql @@ -0,0 +1,7 @@ +begin; + +drop index if exists organization_name_resolution_entity_id_idx; +alter table organization_name_resolution + drop column if exists resolved_corporate_entity_id; + +commit; diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 0ed46902e..e422134e4 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -75,5 +75,15 @@ def test_migrate_sh_replays_verified_label_and_catalog_identity_migrations() -> / "migrations" / "0103_organization_resolution_entity_id.sql" ).read_text(encoding="utf-8") + rollback = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "rollback" + / "0103_organization_resolution_entity_id.sql" + ).read_text(encoding="utf-8") + assert migration.strip().casefold().startswith("begin;") + assert migration.strip().casefold().endswith("commit;") assert "resolved_corporate_entity_id" in migration assert "references corporate_entity" in migration + assert "drop index if exists organization_name_resolution_entity_id_idx" in rollback + assert "drop column if exists resolved_corporate_entity_id" in rollback From 69b6052429a51ccc8a019a2097e1250e198a10d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 14:20:45 +0900 Subject: [PATCH 7/7] style: normalize organization evidence tests --- .../test_organization_name_resolution_ingestion.py | 5 ++++- tests/test_tied_organization_no_create.py | 14 +++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_organization_name_resolution_ingestion.py b/tests/test_organization_name_resolution_ingestion.py index d3ad581f5..8386b3475 100644 --- a/tests/test_organization_name_resolution_ingestion.py +++ b/tests/test_organization_name_resolution_ingestion.py @@ -6,7 +6,10 @@ import pytest import backend.app.organization_name_resolution_ingestion as ingestion -from lineageweave.relation_verification import STATUS_CORROBORATED, STATUS_UNCORROBORATED +from lineageweave.relation_verification import ( + STATUS_CORROBORATED, + STATUS_UNCORROBORATED, +) class _Connection: diff --git a/tests/test_tied_organization_no_create.py b/tests/test_tied_organization_no_create.py index 55041715c..cc1209eb6 100644 --- a/tests/test_tied_organization_no_create.py +++ b/tests/test_tied_organization_no_create.py @@ -4,15 +4,14 @@ import asyncio import uuid -from types import SimpleNamespace -from typing import Any +from types import SimpleNamespace, TracebackType +from typing import Any, Self from backend.app import corporate_entity_ingestion, keyman_ingestion from lineageweave.corporate_hierarchy_inference import HierarchyProposal from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate from lineageweave.relation_verification import STATUS_CORROBORATED - _TIED_CANDIDATES = [ CorporateEntityCandidate("tied-a", "Tied Energy"), CorporateEntityCandidate("tied-b", "Tied Energy"), @@ -57,10 +56,15 @@ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: class _Transaction: """Minimal async transaction context manager.""" - async def __aenter__(self) -> "_Transaction": + async def __aenter__(self) -> Self: return self - async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> bool: return False