diff --git a/.github/workflows/repair-global-ask-pnpm-v2.yml b/.github/workflows/repair-global-ask-pnpm-v2.yml deleted file mode 100644 index a2ef5489f..000000000 --- a/.github/workflows/repair-global-ask-pnpm-v2.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Repair Global Ask pnpm provisioning deterministically - -on: - workflow_dispatch: - push: - branches: - - "feat/global-ask-public-claim-verification-v2200" - paths: - - ".github/workflows/repair-global-ask-pnpm-v2.yml" - -permissions: - contents: write - -concurrency: - group: repair-global-ask-pnpm-v2200-v2 - cancel-in-progress: false - -jobs: - repair: - name: Pin repository pnpm and re-arm product integration - runs-on: ubuntu-latest - steps: - - name: Checkout exact feature branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/global-ask-public-claim-verification-v2200 - fetch-depth: 0 - persist-credentials: true - - - name: Repair only the package-manager provisioning boundary - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - workflow = Path('.github/workflows/apply-global-ask-public-verification-v2200.yml') - text = workflow.read_text(encoding='utf-8') - - actor_guard = " github.event.pull_request.head.repo.full_name == github.repository &&\n github.actor != 'github-actions[bot]'" - if actor_guard in text: - text = text.replace( - actor_guard, - " github.event.pull_request.head.repo.full_name == github.repository", - 1, - ) - - old_install = " corepack enable\n pnpm --dir frontend install --frozen-lockfile" - new_install = ( - " corepack enable\n" - " corepack prepare pnpm@9.15.9 --activate\n" - " test \"$(pnpm --version)\" = \"9.15.9\"\n" - " pnpm --dir frontend install --frozen-lockfile" - ) - if old_install in text: - text = text.replace(old_install, new_install, 1) - elif new_install not in text: - raise SystemExit('refusing to edit an unknown pnpm provisioning shape') - - if "github.actor != 'github-actions[bot]'" in text: - raise SystemExit('actor guard remains after repair') - if new_install not in text: - raise SystemExit('pinned pnpm provisioning was not installed') - - workflow.write_text(text, encoding='utf-8') - PY - - - name: Remove repair-only workflows and publish the narrow repair - shell: bash - run: | - rm -f .github/workflows/repair-global-ask-pnpm.yml - rm .github/workflows/repair-global-ask-pnpm-v2.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A .github/workflows - git diff --cached --check - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "ci: pin Global Ask pnpm provisioning" - git push origin HEAD:feat/global-ask-public-claim-verification-v2200 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..43392c5ea --- /dev/null +++ b/CHANGELOG.d/2.21.0-verified-organization-label-evidence.md @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 486714833..0f5debf8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..29374c08a 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 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 + ) + 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.""" @@ -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 || '%' @@ -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/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/main.py b/backend/app/main.py index 332fcf3c9..796725811 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, @@ -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], @@ -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, @@ -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), } 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/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index ff24ec426..7a17bd436 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -49,6 +49,7 @@ global_ask_query_terms, public_external_claim_facts, semantic_candidate_post_ids, + verified_organization_label_facts, ) from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph @@ -707,6 +708,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"]) @@ -741,7 +743,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_api.py b/backend/tests/test_api.py index 00430364d..a98e29764 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" @@ -234,6 +239,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(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) diff --git a/backend/tests/test_global_ask_public_verification.py b/backend/tests/test_global_ask_public_verification.py index 4ff014fab..453322eb3 100644 --- a/backend/tests/test_global_ask_public_verification.py +++ b/backend/tests/test_global_ask_public_verification.py @@ -129,3 +129,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/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 422abe04c..233d17fbb 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -19,7 +19,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do 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_*|0055_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" 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..bba5a00e0 --- /dev/null +++ b/docs/adr/0107-verified-organization-label-evidence.md @@ -0,0 +1,69 @@ +# 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 `resolved_corporate_entity_id` joins a +`corporate_entity` already mentioned on, or affiliated to a person on, +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 +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/docs/adr/0122-stable-organization-resolution-identity.md b/docs/adr/0122-stable-organization-resolution-identity.md new file mode 100644 index 000000000..a508e4be7 --- /dev/null +++ b/docs/adr/0122-stable-organization-resolution-identity.md @@ -0,0 +1,51 @@ +# 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. +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 + +- 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/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6859d13ef..1b3a3e7ff 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -260,4 +260,37 @@ 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. + +## 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. + *This document is continuously updated by the hourly automated agent loop.* 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 ec24a681e..7aee59f88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -788,12 +788,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", @@ -4695,7 +4711,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")}