From d09884369ed3e51d3f9850b1c39cc4365c745619 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 00:35:12 +0000 Subject: [PATCH 01/27] feat(ask): persist public-claim envelopes for Global Ask verification Issue #272 / ADR 0224. Admit only typed, egress-eligible public claims. Opt-in verify_external. Never force mode=verify. External URLs stay off cited_post_ids. --- AGENTS.md | 8 + ARCHITECTURE.md | 5 + CHANGELOG.d/2.22.0-public-claim-envelope.md | 20 + CHANGELOG.md | 9 + CLAUDE.md | 2 +- backend/app/global_ask_queue.py | 101 ++++- backend/app/main.py | 2 + docs/adr/0224-public-claim-envelope.md | 66 ++++ docs/adr/README.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- docs/storybook-inventory.md | 1 + frontend/src/App.test.tsx | 42 ++ frontend/src/App.tsx | 24 +- frontend/src/api.test.ts | 60 ++- frontend/src/api.ts | 30 +- .../PublicClaimList.stories.test.ts | 12 + .../components/PublicClaimList.stories.tsx | 54 +++ .../src/components/PublicClaimList.test.tsx | 64 ++++ frontend/src/components/PublicClaimList.tsx | 70 ++++ frontend/src/i18n.test.ts | 18 + frontend/src/i18n.ts | 92 +++++ lineageweave/public_claim_verification.py | 362 ++++++++++++++++++ migrations/0224_public_claim_envelope.sql | 90 +++++ .../rollback/0224_public_claim_envelope.sql | 31 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 14 + tests/test_global_ask_queue.py | 153 ++++++++ tests/test_migration_replay.py | 17 + tests/test_public_claim_verification.py | 257 +++++++++++++ tests/test_schema.py | 103 +++++ uv.lock | 2 +- 31 files changed, 1701 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.d/2.22.0-public-claim-envelope.md create mode 100644 docs/adr/0224-public-claim-envelope.md create mode 100644 frontend/src/components/PublicClaimList.stories.test.ts create mode 100644 frontend/src/components/PublicClaimList.stories.tsx create mode 100644 frontend/src/components/PublicClaimList.test.tsx create mode 100644 frontend/src/components/PublicClaimList.tsx create mode 100644 lineageweave/public_claim_verification.py create mode 100644 migrations/0224_public_claim_envelope.sql create mode 100644 migrations/rollback/0224_public_claim_envelope.sql create mode 100644 tests/test_public_claim_verification.py diff --git a/AGENTS.md b/AGENTS.md index c927f9e61..5fa2560f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,6 +295,14 @@ when the event instant is missing. Cited evidence names **Time axis** so the reader can open that post and see which clock matched. Do not invent an event date or a theta. +Public-claim verification (ADR 0224 / issue #272) is opt-in +(`verify_external`). Admission is a persisted `public_claim_envelope` +bound to a public post. Question-token overlap is not admission. +Person, Keyman, TEPP, and fast-mlsirm kinds cannot be stored. +External URLs stay off `cited_post_ids`. Missing SearXNG is +unavailable, not a guessed query. Never force +`contextual-orchestrator` `mode="verify"`. + Organization chips show a unique search-corroborated SKOS companion (`Demo Corp (DC)`) and stay unlabeled on a miss or tie (ADR 0008 / ADR 0170). Do not invent an abbreviation from letters. Synthetic diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 399894584..483e32b85 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,6 +79,7 @@ flowchart LR | `commitment_extraction.py` | Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; `Null` default, `ContextualOrchestrator` real impl | | `temporal_expressions.py` | Pure Korean relative-time resolver for Global Ask (ADR 0150) | | `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) | +| `public_claim_verification.py` | Typed public-claim envelopes for Global Ask; opt-in SearXNG URLs stay off `cited_post_ids` (ADR 0224) | | `ontology.py` | Loads `docs/ontology/lineageweave-kg.ttl`, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) | | `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge | | `ontology_source_cursor.py` | Opaque HMAC source-window continuation (ADR 0124); keyset pagination, never OFFSET | @@ -234,6 +235,10 @@ Each direct edge includes `interval_relation_code` / Global Ask merges cited threads from one post/edge fetch pair and caps the payload at the landing node bound, keeping cited posts first (ADR 0169). Open a cited post to read the focused thread. +Opt-in public-claim verification (ADR 0224) loads persisted +egress-eligible envelopes only. External URLs stay off +`cited_post_ids`. Missing search is unavailable, not a question-token +query, and this repository never forces `mode="verify"`. `POST /api/lineage/rebuild` (`post_admin`) re-runs `reconstruct()` over every `source_post` and atomically rewrites edges, channel signals, and Allen interval relations. Reconstruct grouping is diff --git a/CHANGELOG.d/2.22.0-public-claim-envelope.md b/CHANGELOG.d/2.22.0-public-claim-envelope.md new file mode 100644 index 000000000..6d05604ba --- /dev/null +++ b/CHANGELOG.d/2.22.0-public-claim-envelope.md @@ -0,0 +1,20 @@ +# 2.22.0 — Persisted public-claim envelope + +## Added + +- Typed `public_claim_envelope` rows admit Global Ask public verification + (issue #272 / ADR 0224). Closed kinds are organization presence, public + event, and public relationship. Person, Keyman, TEPP, and fast-mlsirm + evidence cannot be stored. +- Ask Agent `verify_external` is opt-in. Off omits the projection. On + loads currently authorized egress-eligible envelopes and never nominates + a claim from question tokens. SearXNG URLs stay on + `external_evidence_urls` and never enter `cited_post_ids`. +- Organization-presence distinctive-token footprint can read **Supported**. + Other polarity stays unavailable until contextual-orchestrator classifies + retrieved evidence. This repository does not force `mode="verify"`. +- After `make seed`, the Demo public post envelope sits above the Ask + answer. A click opens that post. Web verification is unavailable when + SearXNG is unset. + +[ADR 0224]: docs/adr/0224-public-claim-envelope.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..3da50e42c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project are documented here. Format follows ### Added +- Persist typed public-claim envelopes for Global Ask web verification + (issue #272 / ADR 0224). Ask Agent can opt into `verify_external`; + only egress-eligible public organization-presence, public-event, and + public-relationship claims are admitted. Person, Keyman, TEPP, and + fast-mlsirm evidence stay inside the workspace. SearXNG URLs never + enter `cited_post_ids`, and this repository does not force + `mode="verify"`. After `make seed`, the Demo public post envelope sits + above the answer; a click opens that post. + - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent diff --git a/CLAUDE.md b/CLAUDE.md index eb9e85eab..e4a7cf165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ cutoff. Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0201), the text-channel embedding swap and cosine +(ADR 0048–0164 / 0182 / 0201), public-claim envelopes (ADR 0224), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), migration replay (ADR 0166), docstring coverage, and the measurement boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index c7e570d81..c847af2de 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -37,6 +37,14 @@ cited_post_evidence, cited_post_summaries, ) +from lineageweave.public_claim_verification import ( + NullPublicClaimSearchClient, + PublicClaimSearchClient, + SearxngPublicClaimSearchClient, + cited_post_ids_exclude_external, + envelope_from_authorized_row, + verify_public_claims, +) from lineageweave.temporal_expressions import resolve_korean_relative_time from .config import GLOBAL_ASK_JOB_DEADLINE_SECONDS @@ -94,6 +102,7 @@ async def enqueue_global_ask_job( question_text: str, corporate_entity_ids: frozenset[str], process_unit_ids: frozenset[str], + verify_external: bool = False, ) -> str: """Persist one Ask job and wake the worker; return the new job id. @@ -104,11 +113,14 @@ async def enqueue_global_ask_job( async with conn.transaction(): job_id = await conn.fetchval( """ - insert into global_ask_job (requesting_account_id, question_text) - values ($1, $2) returning global_ask_job_id + insert into global_ask_job ( + requesting_account_id, question_text, verify_external + ) + values ($1, $2, $3) returning global_ask_job_id """, requesting_account_id, question_text, + verify_external, ) await conn.executemany( """ @@ -215,6 +227,8 @@ async def compute_global_ask_answer( process_scope_limited: bool, chat_client: PostChatClient, embedding_client: EmbeddingClient | None = None, + verify_external: bool = False, + claim_search_client: PublicClaimSearchClient | None = None, ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -256,7 +270,7 @@ def can_see(row: asyncpg.Record) -> bool: ) from exc if not sources: delivery = build_ask_delivery("", (), ()) - return { + payload: dict[str, Any] = { "answer_text": "", "cited_post_ids": [], "cited_posts": [], @@ -267,6 +281,14 @@ def can_see(row: asyncpg.Record) -> bool: "next_action": "No authorized source posts are available for this question.", "delivery": delivery, } + if verify_external: + search_client = claim_search_client or NullPublicClaimSearchClient() + async with pool.acquire() as conn: + envelopes = await load_authorized_public_claim_envelopes(conn, can_see) + verification = verify_public_claims(envelopes, search_client) + cited_post_ids_exclude_external([], verification) + payload["public_claim_verification"] = verification + return payload try: answer = await asyncio.to_thread( chat_client.answer, _temporally_grounded_question(question_text, today=today), sources @@ -305,7 +327,7 @@ def can_see(row: asyncpg.Record) -> bool: images = await cited_post_images(conn, cited_ids) cited_posts = cited_post_summaries(sources, cited_ids) cited_evidence = cited_post_evidence(sources, cited_ids) - return { + payload: dict[str, Any] = { "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_posts, @@ -315,6 +337,73 @@ def can_see(row: asyncpg.Record) -> bool: "lineage_graph": lineage_graph, "delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence), } + if verify_external: + search_client = claim_search_client or NullPublicClaimSearchClient() + async with pool.acquire() as conn: + envelopes = await load_authorized_public_claim_envelopes(conn, can_see) + verification = verify_public_claims(envelopes, search_client) + cited_post_ids_exclude_external(cited_ids, verification) + payload["public_claim_verification"] = verification + if not payload.get("next_action"): + payload["next_action"] = verification["next_action"] + return payload + + +async def load_authorized_public_claim_envelopes( + conn: asyncpg.Connection, + can_see: Callable[[asyncpg.Record], bool], +) -> tuple: + """Re-read egress-eligible public envelopes through the current ABAC gate. + + A missing table is unavailable, not an invented claim. Private or + ineligible rows never reach SearXNG. + """ + try: + rows = await conn.fetch( + """ + select envelope.public_claim_envelope_id, + envelope.source_post_id, + post.post_title as source_post_title, + envelope.claim_kind_code, + envelope.subject_label, + envelope.claim_text, + envelope.truth_status_code, + envelope.event_occurred_at, + envelope.egress_eligible, + post.visibility_code, + post.corporate_entity_id, + post.process_unit_id + from public_claim_envelope envelope + join source_post post + on post.post_id = envelope.source_post_id + where envelope.egress_eligible + and post.visibility_code = 'public' + order by envelope.created_at, envelope.public_claim_envelope_id + """ + ) + except asyncpg.UndefinedTableError: + return () + envelopes = [] + for row in rows: + if not can_see(row): + continue + envelope = envelope_from_authorized_row(row) + if envelope is not None: + envelopes.append(envelope) + return tuple(envelopes) + + +def _public_claim_search_client() -> PublicClaimSearchClient: + """Live SearXNG client when configured; otherwise unavailable.""" + from backend.app.config import load_settings + + settings = load_settings() + if not settings.searxng_base_url: + return NullPublicClaimSearchClient() + try: + return SearxngPublicClaimSearchClient(settings.searxng_base_url) + except ValueError: + return NullPublicClaimSearchClient() def _temporally_grounded_question(question_text: str, *, today: date | None = None) -> str: @@ -363,7 +452,7 @@ async def process_global_ask_job( """ update global_ask_job set job_status_code = $2, updated_at = now() where global_ask_job_id = $1 and job_status_code = $3 - returning requesting_account_id, question_text + returning requesting_account_id, question_text, verify_external """, job_id, RUNNING, @@ -397,6 +486,8 @@ async def process_global_ask_job( process_scope_limited=process_scope_limited, chat_client=chat_client, embedding_client=embedding_factory(), + verify_external=bool(row.get("verify_external", False)), + claim_search_client=_public_claim_search_client(), ), timeout=JOB_DEADLINE_SECONDS, ) diff --git a/backend/app/main.py b/backend/app/main.py index 08ff32428..74454f921 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2960,6 +2960,7 @@ class GlobalAskRequest(BaseModel): """JSON body for the buyer's source-grounded Global Ask Agent.""" question: str + verify_external: bool = False @app.get("/api/posts/{post_id}/chat") @@ -3119,6 +3120,7 @@ async def ask_agent( question_text=question, corporate_entity_ids=account.corporate_entity_ids, process_unit_ids=account.process_unit_ids, + verify_external=request.verify_external, ) return {"ask_job_id": job_id, "job_status_code": "queued"} diff --git a/docs/adr/0224-public-claim-envelope.md b/docs/adr/0224-public-claim-envelope.md new file mode 100644 index 000000000..60d608c16 --- /dev/null +++ b/docs/adr/0224-public-claim-envelope.md @@ -0,0 +1,66 @@ +# ADR 0224: Persisted public-claim envelope for Global Ask verification + +**Status:** Accepted +**Date:** 2026-08-26 + +## Context + +Issue #272 requires FEVER-style public verification of Global Ask claims +(Thorne, Vlachos, Christodoulopoulos, & Mittal, 2018). A closed stack +selected claims by question-token overlap and forced +`contextual-orchestrator` `mode="verify"`. That is heuristic admission and +it contradicts ADR 0076: orchestration policy belongs to the upstream +gateway, with `reasoning_effort="auto"`. + +Until a typed, persisted, egress-eligible envelope exists, the honest +product state is **unavailable**, not a search query invented from the +question string. + +## Decision + +1. Persist `public_claim_envelope` in 3NF: one row names one claim kind, + the exact public `source_post`, subject label, claim text, ontology + truth status, optional event time, and `egress_eligible`. +2. Admitted claim kinds are a closed lookup: `claim_organization_presence`, + `claim_public_event`, `claim_public_relationship`. Person, Keyman, TEPP, + and fast-mlsirm kinds are not in the vocabulary and cannot be stored. +3. `egress_eligible` may be true only when the source post is `public`. A + trigger fail-closes private or missing posts. Application code re-checks + visibility and ABAC before any SearXNG dispatch. +4. Global Ask `verify_external` is opt-in. Off omits the projection. On + loads currently authorized egress-eligible envelopes and never nominates + a claim from question tokens. +5. SearXNG may retrieve a bounded list of public HTTP(S) URLs for the + persisted claim text. Search pages, localhost, and literal + private-network hosts are dropped. Those URLs are `external_evidence_urls` + and must never enter `cited_post_ids`. +6. Classification: + - search channel unavailable → `claim_unavailable`; + - no usable URL → `claim_not_enough_information`; + - `claim_organization_presence` with a distinctive-token footprint on a + retrieved URL → `claim_supported` (the same FEVER presence subset + ADR 0005 already ships); + - `claim_refuted` and NLI polarity for other kinds stay unavailable + until contextual-orchestrator classifies retrieved evidence. This + repository does not force `mode="verify"`. +7. Private source text, raw source hints, credentials, PII, TEPP payloads, + and fast-mlsirm respondent or item data never leave the trust boundary. + +## Consequences + +- After `make seed`, Ask Agent can opt into public-claim verification. + The Demo public post envelope sits above the answer; a click opens that + post. Web verification is unavailable when SearXNG is unset. +- A later orchestrator-owned polarity slice can fill `claim_refuted` + without rewriting admission. +- Heuristic token-overlap claim selection remains forbidden. + +## References + +Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). +FEVER: A large-scale dataset for fact extraction and verification. +*Proceedings of NAACL-HLT 2018*, 809–819. +https://doi.org/10.18653/v1/N18-1074 + +World Wide Web Consortium. (2013, April 30). *PROV-O: The PROV ontology* +(W3C Recommendation). https://www.w3.org/TR/2013/REC-prov-o-20130430/ diff --git a/docs/adr/README.md b/docs/adr/README.md index eadef2874..3c1cf49f5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,7 +11,7 @@ decision from them. |---|---| | [`product-requirements.md`](../product-requirements.md) | Product requirements projection across the ADR set; ADRs remain normative | | [`product-technical-gap-baseline.md`](../product-technical-gap-baseline.md) | Product/technical traceability projection across the ADR set; ADRs remain normative | -| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md) | +| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md), [0224](0224-public-claim-envelope.md) | | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 974bef3ac..e2c44c71f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -353,7 +353,7 @@ this file per §3.5 of the prior snapshot). | #87 | Milestone 2.1 normalized runtime-analysis schema bridge | related analysis-run work | | #269 | Authenticated Global Ask MCP browser-safe and admission-bounded | Ask stack | | #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack | +| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Typed public-claim envelope (ADR 0224) on this branch; not protected-main evidence | | #274 | Persist and explain Event Lineage channel evidence | #387 | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 7ec497477..0050daf22 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -8,6 +8,7 @@ operator-facing control you can click before changing product CSS. | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | +| `Evidence/PublicClaimList` | Read the public-claim status, then open that source post. External URLs stay links and never become cited post ids. Compare supported, unavailable-search, and empty states. | `--color-chip-border`, `PublicClaimList` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..00e2d5f6e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -146,6 +146,7 @@ describe("App, authenticated", () => { let createdPendingTepp: Record | null = null; let resolvedHintCode: string | null = null; let contentRequests = 0; + let lastAskVerifyExternal = false; let releaseMe = () => {}; const demoOrgAlias = options?.organizationAliases ? { organization_alias: "DC" } : {}; @@ -1724,6 +1725,8 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/ask") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")); + lastAskVerifyExternal = Boolean(body.verify_external); return Promise.resolve( jsonResponse({ ask_job_id: "ask-job-1", job_status_code: "queued" }), ); @@ -1807,6 +1810,25 @@ describe("App, authenticated", () => { eligible: true, watched_resource_uris: ["lineageweave://posts/post-2"], }, } : undefined, + public_claim_verification: lastAskVerifyExternal + ? { + status_code: "claim_supported", + next_action: "Public web evidence supports this claim. Open that post.", + claims: [ + { + public_claim_envelope_id: "env-demo-public", + source_post_id: "post-1", + source_post_title: "Public post", + claim_kind_code: "claim_organization_presence", + subject_label: "Northridge Grid", + claim_text: "Northridge Grid is a power utility named on the Demo public post.", + status_code: "claim_supported", + external_evidence_urls: ["https://northridgegrid.example/about"], + next_action: "Public web evidence supports this claim. Open that post.", + }, + ], + } + : undefined, }, }), ); @@ -2051,6 +2073,26 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "View evidence" })).toBeInTheDocument(); }); + it("opts into public-claim verification and opens that source post", async () => { + stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + + const verify = screen.getByRole("checkbox", { name: "Verify public claims on the web" }); + expect(verify).not.toBeChecked(); + await userEvent.click(verify); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Does Northridge Grid exist?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByLabelText("Public claims")).toBeInTheDocument(); + const claim = screen.getByRole("button", { name: "Open public claim: Public post" }); + expect(claim).toHaveTextContent("Organization presence: Public post · Northridge Grid"); + expect(claim).toHaveTextContent("Supported"); + await userEvent.click(claim); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76ff51dec..4d0a6edad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { AdminPanel } from "./components/AdminPanel"; import { LeftoverPairList } from "./components/LeftoverPairList"; +import { PublicClaimList } from "./components/PublicClaimList"; import { WorkspaceCalendar } from "./components/WorkspaceCalendar"; import { focusedGraphMustReset } from "./focusedGraphSelection"; @@ -4824,6 +4825,7 @@ function AskAgentPanel({ const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); + const [verifyExternal, setVerifyExternal] = useState(false); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); async function handleAsk() { @@ -4832,7 +4834,7 @@ function AskAgentPanel({ setAsking(true); setError(null); try { - setAnswer(await askAgent(accessToken, normalized)); + setAnswer(await askAgent(accessToken, normalized, { verifyExternal })); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); @@ -4856,12 +4858,32 @@ function AskAgentPanel({ rows={4} /> + +

+ {t( + "Uses authorized public claims only. Person, Keyman, TEPP, and measurement data stay inside the workspace.", + )} +

{answer && (

{t("Answer")}

+ {answer.public_claim_verification ? ( + + ) : null} {answer.answer_text ?

{answer.answer_text}

: null} {answer.next_action ?

{t(answer.next_action)}

: null} {answer.delivery ? ( diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 3182afb04..fac31aaf0 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { BackendError, fetchMe, fetchOperationsDashboard, updateTenantConfig } from "./api"; +import { askAgent, BackendError, fetchMe, fetchOperationsDashboard, updateTenantConfig } from "./api"; afterEach(() => { vi.unstubAllGlobals(); @@ -63,3 +63,61 @@ describe("backendFetch provider-error boundary", () => { }); }); }); + +describe("askAgent public-claim opt-in", () => { + it("sends verify_external only when the reader opts in", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ask_job_id: "ask-job-1", job_status_code: "queued" }), + { headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ask_job_id: "ask-job-1", + job_status_code: "succeeded", + answer: { answer_text: "synthetic", cited_post_ids: [], source_post_ids: [] }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await askAgent("access-token", "Does Northridge Grid exist?", { verifyExternal: true }); + + expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).toEqual({ + question: "Does Northridge Grid exist?", + verify_external: true, + }); + }); + + it("defaults verify_external to false", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ask_job_id: "ask-job-1", job_status_code: "queued" }), + { headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ask_job_id: "ask-job-1", + job_status_code: "succeeded", + answer: { answer_text: "synthetic", cited_post_ids: [], source_post_ids: [] }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await askAgent("access-token", "Which project?"); + + expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).toEqual({ + question: "Which project?", + verify_external: false, + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2c812ccaa..210221f49 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -390,6 +390,25 @@ export interface AskAgentResponse { watched_resource_uris: string[]; }; }; + public_claim_verification?: PublicClaimVerification; +} + +export interface PublicClaimVerdict { + public_claim_envelope_id: string; + source_post_id: string; + source_post_title: string; + claim_kind_code: string; + subject_label: string; + claim_text: string; + status_code: string; + external_evidence_urls: string[]; + next_action: string; +} + +export interface PublicClaimVerification { + status_code: string; + next_action: string; + claims: PublicClaimVerdict[]; } export interface IssueTicket { @@ -1153,10 +1172,17 @@ interface AskJobStatus { * The signature and resolved value are unchanged from the old synchronous * call, so callers (AskAgentPanel) keep their existing pending/complete * states without modification. */ -export async function askAgent(accessToken: string, question: string): Promise { +export async function askAgent( + accessToken: string, + question: string, + options?: { verifyExternal?: boolean }, +): Promise { const submitted = await backendFetch("/api/ask", accessToken, { method: "POST", - body: JSON.stringify({ question }), + body: JSON.stringify({ + question, + verify_external: Boolean(options?.verifyExternal), + }), }); const deadline = Date.now() + ASK_POLL_CEILING_MS; for (;;) { diff --git a/frontend/src/components/PublicClaimList.stories.test.ts b/frontend/src/components/PublicClaimList.stories.test.ts new file mode 100644 index 000000000..5e7632d59 --- /dev/null +++ b/frontend/src/components/PublicClaimList.stories.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import meta, { Empty, UnavailableSearch } from "./PublicClaimList.stories"; + +describe("PublicClaimList Storybook contract", () => { + it("exports CSF metadata for the public-claim evidence states", () => { + expect(meta.title).toBe("Evidence/PublicClaimList"); + expect(meta.component).toBeDefined(); + expect(meta.args.claims[0].status_code).toBe("claim_supported"); + expect(UnavailableSearch.args?.claims?.[0]?.external_evidence_urls).toEqual([]); + expect(Empty.args?.claims).toEqual([]); + }); +}); diff --git a/frontend/src/components/PublicClaimList.stories.tsx b/frontend/src/components/PublicClaimList.stories.tsx new file mode 100644 index 000000000..a4b3c3319 --- /dev/null +++ b/frontend/src/components/PublicClaimList.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PublicClaimList } from "./PublicClaimList"; + +const meta = { + title: "Evidence/PublicClaimList", + component: PublicClaimList, + args: { + onSelectPost: () => undefined, + claims: [ + { + public_claim_envelope_id: "env-demo-public", + source_post_id: "post-demo-public", + source_post_title: "Public post", + claim_kind_code: "claim_organization_presence", + subject_label: "Northridge Grid", + claim_text: "Northridge Grid is a power utility named on the Demo public post.", + status_code: "claim_supported", + external_evidence_urls: ["https://northridgegrid.example/about"], + next_action: "Public web evidence supports this claim. Open that post.", + }, + ], + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const SupportedPresence: Story = {}; + +export const UnavailableSearch: Story = { + args: { + claims: [ + { + public_claim_envelope_id: "env-demo-public", + source_post_id: "post-demo-public", + source_post_title: "Public post", + claim_kind_code: "claim_organization_presence", + subject_label: "Northridge Grid", + claim_text: "Northridge Grid is a power utility named on the Demo public post.", + status_code: "claim_unavailable", + external_evidence_urls: [], + next_action: + "Web verification is unavailable until the search service is connected. Open that post.", + }, + ], + }, +}; + +export const Empty: Story = { + args: { + claims: [], + }, +}; diff --git a/frontend/src/components/PublicClaimList.test.tsx b/frontend/src/components/PublicClaimList.test.tsx new file mode 100644 index 000000000..60743490c --- /dev/null +++ b/frontend/src/components/PublicClaimList.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PublicClaimVerdict } from "../api"; +import { PublicClaimList } from "./PublicClaimList"; + +const SUPPORTED: PublicClaimVerdict = { + public_claim_envelope_id: "env-demo-public", + source_post_id: "post-demo-public", + source_post_title: "Public post", + claim_kind_code: "claim_organization_presence", + subject_label: "Northridge Grid", + claim_text: "Northridge Grid is a power utility named on the Demo public post.", + status_code: "claim_supported", + external_evidence_urls: ["https://northridgegrid.example/about"], + next_action: "Public web evidence supports this claim. Open that post.", +}; + +describe("PublicClaimList", () => { + it("names the public claim so the next click opens that post", async () => { + const onSelectPost = vi.fn(); + render(); + + expect(screen.getByLabelText("Public claims")).toBeInTheDocument(); + const row = screen.getByRole("button", { name: "Open public claim: Public post" }); + expect(row).toHaveTextContent("Organization presence: Public post · Northridge Grid"); + expect(row).toHaveTextContent("Supported"); + expect(row).toHaveTextContent("Public web evidence supports this claim. Open that post."); + expect(screen.getByRole("link", { name: "https://northridgegrid.example/about" })).toHaveAttribute( + "href", + "https://northridgegrid.example/about", + ); + + await userEvent.click(row); + expect(onSelectPost).toHaveBeenCalledWith("post-demo-public"); + }); + + it("keeps unavailable search guidance without inventing a URL", () => { + render( + , + ); + + expect(screen.getByRole("button")).toHaveTextContent( + "Web verification is unavailable until the search service is connected. Open that post.", + ); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("renders nothing when no authorized claim is present", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/components/PublicClaimList.tsx b/frontend/src/components/PublicClaimList.tsx new file mode 100644 index 000000000..412af6d51 --- /dev/null +++ b/frontend/src/components/PublicClaimList.tsx @@ -0,0 +1,70 @@ +import type { PublicClaimVerdict } from "../api"; +import { t, tf } from "../i18n"; + +export type PublicClaimListProps = { + claims: PublicClaimVerdict[]; + onSelectPost: (postId: string) => void; +}; + +const KIND_LABELS: Record = { + claim_organization_presence: "Organization presence", + claim_public_event: "Public event", + claim_public_relationship: "Public relationship", +}; + +const STATUS_LABELS: Record = { + claim_supported: "Supported", + claim_refuted: "Refuted", + claim_not_enough_information: "Not enough information", + claim_unavailable: "Unavailable", +}; + +/** + * Authorized public-claim verdicts for an opted-in Global Ask. + * + * Each row opens the exact source post. External URLs stay links and + * never become cited post ids (ADR 0224). + */ +export function PublicClaimList({ claims, onSelectPost }: PublicClaimListProps) { + if (claims.length === 0) { + return null; + } + return ( +
    + {claims.map((claim) => { + const kindLabel = t(KIND_LABELS[claim.claim_kind_code] ?? claim.claim_kind_code); + const statusLabel = t(STATUS_LABELS[claim.status_code] ?? claim.status_code); + return ( +
  • + + {claim.external_evidence_urls.length > 0 ? ( +
      + {claim.external_evidence_urls.map((url) => ( +
    • + + {url} + +
    • + ))} +
    + ) : null} +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 18485aba9..84f5c8f82 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -78,6 +78,24 @@ describe("i18n", () => { "Observed calendar events", "No observed calendar events are available.", "Open this observed occurrence. It is not a LineageWeave commitment.", + "Verify public claims on the web", + "Uses authorized public claims only. Person, Keyman, TEPP, and measurement data stay inside the workspace.", + "Public claims", + "Open public claim: {title}", + "Open this post so the public claim is current.", + "Organization presence", + "Public event", + "Public relationship", + "Supported", + "Refuted", + "Not enough information", + "Unavailable", + "Public web evidence", + "Web verification is unavailable until the search service is connected. Open that post.", + "No usable public web evidence. Open that post.", + "Public web evidence supports this claim. Open that post.", + "Public claim is on {title}. Open that post.", + "Public-claim verification is unavailable: no egress-eligible public claim is authorized.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 2d19b1cb8..a420d9122 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -179,6 +179,29 @@ const TRANSLATIONS: Partial>> = { "Asking...": "질의 중...", Answer: "답변", "Cited posts": "인용된 글", + "Verify public claims on the web": "웹에서 공개 주장을 검증", + "Uses authorized public claims only. Person, Keyman, TEPP, and measurement data stay inside the workspace.": + "권한이 있는 공개 주장만 사용합니다. Person, Keyman, TEPP, 측정 데이터는 작업공간 안에 남습니다.", + "Public claims": "공개 주장", + "Open public claim: {title}": "공개 주장 열기: {title}", + "Open this post so the public claim is current.": "공개 주장이 현재 글이 되도록 이 글을 여세요.", + "Organization presence": "조직 존재", + "Public event": "공개 사건", + "Public relationship": "공개 관계", + Supported: "지지됨", + Refuted: "반박됨", + "Not enough information": "정보 부족", + Unavailable: "사용할 수 없음", + "Public web evidence": "공개 웹 근거", + "Web verification is unavailable until the search service is connected. Open that post.": + "검색 서비스가 연결되기 전까지 웹 검증을 사용할 수 없습니다. 그 글을 여세요.", + "No usable public web evidence. Open that post.": + "사용할 수 있는 공개 웹 근거가 없습니다. 그 글을 여세요.", + "Public web evidence supports this claim. Open that post.": + "공개 웹 근거가 이 주장을 지지합니다. 그 글을 여세요.", + "Public claim is on {title}. Open that post.": "공개 주장은 {title}에 있습니다. 그 글을 여세요.", + "Public-claim verification is unavailable: no egress-eligible public claim is authorized.": + "공개 주장 검증을 사용할 수 없습니다. 반출 가능한 공개 주장이 없습니다.", "Report · alert · MCP": "리포트 · 알림 · MCP", "{count} evidence documents are linked to this report.": "근거 문서 {count}건이 리포트에 연결됐습니다.", "You can subscribe to evidence-change alerts.": "근거 변경 알림을 구독할 수 있습니다.", @@ -684,6 +707,28 @@ const TRANSLATIONS: Partial>> = { "Asking...": "正在提问...", Answer: "回答", "Cited posts": "引用文章", + "Verify public claims on the web": "在网上核验公开主张", + "Uses authorized public claims only. Person, Keyman, TEPP, and measurement data stay inside the workspace.": + "仅使用已授权的公开主张。Person、Keyman、TEPP 和测量数据留在工作区内。", + "Public claims": "公开主张", + "Open public claim: {title}": "打开公开主张:{title}", + "Open this post so the public claim is current.": "打开这篇文章,使该公开主张成为当前项。", + "Organization presence": "组织存在", + "Public event": "公开事件", + "Public relationship": "公开关系", + Supported: "支持", + Refuted: "反驳", + "Not enough information": "信息不足", + Unavailable: "不可用", + "Public web evidence": "公开网络证据", + "Web verification is unavailable until the search service is connected. Open that post.": + "在连接搜索服务之前无法进行网络核验。打开那篇文章。", + "No usable public web evidence. Open that post.": "没有可用的公开网络证据。打开那篇文章。", + "Public web evidence supports this claim. Open that post.": + "公开网络证据支持这一主张。打开那篇文章。", + "Public claim is on {title}. Open that post.": "公开主张在 {title}。打开那篇文章。", + "Public-claim verification is unavailable: no egress-eligible public claim is authorized.": + "无法核验公开主张:没有可出境的已授权公开主张。", "Search related posts": "搜索相关文章", "Search related posts for: {name}": "搜索与{name}相关的文章", "Evidence facts": "证据事实", @@ -1205,6 +1250,29 @@ const TRANSLATIONS: Partial>> = { "Asking...": "質問中...", Answer: "回答", "Cited posts": "引用された投稿", + "Verify public claims on the web": "ウェブで公開主張を検証", + "Uses authorized public claims only. Person, Keyman, TEPP, and measurement data stay inside the workspace.": + "権限のある公開主張だけを使います。Person、Keyman、TEPP、測定データはワークスペース内に残ります。", + "Public claims": "公開主張", + "Open public claim: {title}": "公開主張を開く: {title}", + "Open this post so the public claim is current.": "この公開主張が現在の投稿になるように開いてください。", + "Organization presence": "組織の存在", + "Public event": "公開イベント", + "Public relationship": "公開関係", + Supported: "支持", + Refuted: "反駁", + "Not enough information": "情報不足", + Unavailable: "利用不可", + "Public web evidence": "公開ウェブ証拠", + "Web verification is unavailable until the search service is connected. Open that post.": + "検索サービスが接続されるまでウェブ検証は利用できません。その投稿を開いてください。", + "No usable public web evidence. Open that post.": + "利用できる公開ウェブ証拠がありません。その投稿を開いてください。", + "Public web evidence supports this claim. Open that post.": + "公開ウェブ証拠がこの主張を支持します。その投稿を開いてください。", + "Public claim is on {title}. Open that post.": "公開主張は {title} にあります。その投稿を開いてください。", + "Public-claim verification is unavailable: no egress-eligible public claim is authorized.": + "公開主張の検証は利用できません。持ち出せる公開主張がありません。", "Search related posts": "関連投稿を検索", "Search related posts for: {name}": "{name}の関連投稿を検索", "Evidence facts": "証拠の事実", @@ -1705,6 +1773,30 @@ const TRANSLATIONS: Partial>> = { "Asking...": "Đang hỏi...", Answer: "Câu trả lời", "Cited posts": "Bài viết được trích dẫn", + "Verify public claims on the web": "Xác minh tuyên bố công khai trên web", + "Uses authorized public claims only. Person, Keyman, TEPP, and measurement data stay inside the workspace.": + "Chỉ dùng tuyên bố công khai được cấp quyền. Person, Keyman, TEPP và dữ liệu đo lường ở lại trong không gian làm việc.", + "Public claims": "Tuyên bố công khai", + "Open public claim: {title}": "Mở tuyên bố công khai: {title}", + "Open this post so the public claim is current.": + "Mở bài viết này để tuyên bố công khai trở thành mục hiện tại.", + "Organization presence": "Sự hiện diện của tổ chức", + "Public event": "Sự kiện công khai", + "Public relationship": "Quan hệ công khai", + Supported: "Được hỗ trợ", + Refuted: "Bị bác bỏ", + "Not enough information": "Không đủ thông tin", + Unavailable: "Không khả dụng", + "Public web evidence": "Bằng chứng web công khai", + "Web verification is unavailable until the search service is connected. Open that post.": + "Không thể xác minh trên web cho đến khi dịch vụ tìm kiếm được kết nối. Mở bài viết đó.", + "No usable public web evidence. Open that post.": + "Không có bằng chứng web công khai khả dụng. Mở bài viết đó.", + "Public web evidence supports this claim. Open that post.": + "Bằng chứng web công khai hỗ trợ tuyên bố này. Mở bài viết đó.", + "Public claim is on {title}. Open that post.": "Tuyên bố công khai nằm ở {title}. Mở bài viết đó.", + "Public-claim verification is unavailable: no egress-eligible public claim is authorized.": + "Không thể xác minh tuyên bố công khai: không có tuyên bố công khai được phép đưa ra ngoài.", "Search related posts": "Tìm bài viết liên quan", "Search related posts for: {name}": "Tìm bài viết liên quan đến {name}", "Evidence facts": "Sự kiện bằng chứng", diff --git a/lineageweave/public_claim_verification.py b/lineageweave/public_claim_verification.py new file mode 100644 index 000000000..d606c358c --- /dev/null +++ b/lineageweave/public_claim_verification.py @@ -0,0 +1,362 @@ +"""Typed public-claim envelopes for Global Ask external verification. + +Implements ADR 0224 / issue #272. A claim is admitted only when a +persisted envelope names a public post, a governed claim kind, and +egress eligibility. Question-token overlap is not admission. Person, +Keyman, TEPP, and fast-mlsirm evidence cannot be stored or dispatched. + +Grounded in FEVER (Thorne, Vlachos, Christodoulopoulos, & Mittal, 2018): +retrieve public web evidence, then classify supported / refuted / +not-enough-information. Polarity other than organization-presence +footprint stays unavailable until contextual-orchestrator classifies +retrieved passages. This module never forces ``mode="verify"``. +""" + +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass +from typing import Any, Protocol +from urllib.parse import quote, urlparse + +from .http_client import get_json +from .relation_verification import _SEARCH_HOST_MARKERS, corroborating_evidence_url + +KIND_ORGANIZATION_PRESENCE = "claim_organization_presence" +KIND_PUBLIC_EVENT = "claim_public_event" +KIND_PUBLIC_RELATIONSHIP = "claim_public_relationship" +ADMITTED_CLAIM_KINDS = frozenset( + { + KIND_ORGANIZATION_PRESENCE, + KIND_PUBLIC_EVENT, + KIND_PUBLIC_RELATIONSHIP, + } +) +INELIGIBLE_CLAIM_KINDS = frozenset( + { + "person", + "keyman", + "cataloged_person", + "tepp", + "fast_mlsirm", + "fast-mlsirm", + } +) + +STATUS_UNAVAILABLE = "claim_unavailable" +STATUS_SUPPORTED = "claim_supported" +STATUS_REFUTED = "claim_refuted" +STATUS_NOT_ENOUGH_INFORMATION = "claim_not_enough_information" + +_PRIVATE_HOSTS = frozenset({"localhost", "localhost.localdomain", "ip6-localhost"}) +_SEARCH_URL_LIMIT = 5 + + +@dataclass(frozen=True) +class PublicClaimEnvelope: + """One persisted public claim bound to an exact source post.""" + + public_claim_envelope_id: str + source_post_id: str + source_post_title: str + claim_kind_code: str + subject_label: str + claim_text: str + truth_status_code: str + event_occurred_at: str | None + egress_eligible: bool + visibility_code: str + + +@dataclass(frozen=True) +class PublicClaimVerdict: + """Buyer-visible verification of one envelope.""" + + public_claim_envelope_id: str + source_post_id: str + source_post_title: str + claim_kind_code: str + subject_label: str + claim_text: str + status_code: str + external_evidence_urls: tuple[str, ...] + next_action: str + + +class PublicClaimSearchClient(Protocol): + """Retrieves public HTTP(S) URLs for a persisted claim text.""" + + available: bool + + def search_urls(self, claim_text: str, *, limit: int = _SEARCH_URL_LIMIT) -> tuple[str, ...]: + """Return bounded public evidence URLs, never search or private hosts.""" + raise NotImplementedError + + +class NullPublicClaimSearchClient: + """No SearXNG channel — verification stays unavailable.""" + + available = False + + def search_urls(self, claim_text: str, *, limit: int = _SEARCH_URL_LIMIT) -> tuple[str, ...]: # pragma: no cover + """Search is not a missing-channel placeholder.""" + raise RuntimeError( + "NullPublicClaimSearchClient has no search channel; check .available first" + ) + + +class SearxngPublicClaimSearchClient: + """Queries self-hosted SearXNG JSON search for a persisted claim text.""" + + available = True + + def __init__(self, base_url: str, *, timeout: float = 15.0) -> None: + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"unsupported Searxng base URL scheme: {parsed.scheme or 'missing'}") + self._base_url = base_url.rstrip("/") + self._timeout = timeout + + def search_urls(self, claim_text: str, *, limit: int = _SEARCH_URL_LIMIT) -> tuple[str, ...]: + """Return bounded public URLs for ``claim_text``.""" + query = claim_text.strip() + if not query: + return () + body = get_json( + f"{self._base_url}/search?q={quote(query, safe='')}&format=json", + timeout=self._timeout, + service_peer_name="searxng", + ) + results = body.get("results") + if not isinstance(results, list): + return () + urls: list[str] = [] + for result in results: + if not isinstance(result, dict): + continue + url = public_evidence_url(result.get("url")) + if url is None or url in urls: + continue + urls.append(url) + if len(urls) >= limit: + break + return tuple(urls) + + +def envelope_from_authorized_row(row: Any) -> PublicClaimEnvelope | None: + """Build an envelope from a visibility-checked database row. + + Hidden, private, ineligible-kind, or non-egress rows are dropped + rather than repaired. Callers must already have applied ABAC. + """ + kind = str(row["claim_kind_code"] or "") + if kind in INELIGIBLE_CLAIM_KINDS or kind not in ADMITTED_CLAIM_KINDS: + return None + if str(row["visibility_code"] or "") != "public": + return None + if not bool(row["egress_eligible"]): + return None + subject = str(row["subject_label"] or "").strip() + claim_text = str(row["claim_text"] or "").strip() + title = str(row["source_post_title"] or "").strip() + post_id = str(row["source_post_id"] or "").strip() + envelope_id = str(row["public_claim_envelope_id"] or "").strip() + if not (subject and claim_text and title and post_id and envelope_id): + return None + event_at = row["event_occurred_at"] + return PublicClaimEnvelope( + public_claim_envelope_id=envelope_id, + source_post_id=post_id, + source_post_title=title, + claim_kind_code=kind, + subject_label=subject, + claim_text=claim_text, + truth_status_code=str(row["truth_status_code"] or ""), + event_occurred_at=None if event_at is None else str(event_at), + egress_eligible=True, + visibility_code="public", + ) + + +def public_evidence_url(raw: object) -> str | None: + """Accept only public http(s) URLs; drop search, localhost, and literal private hosts.""" + if not isinstance(raw, str) or not raw.strip(): + return None + parsed = urlparse(raw.strip()) + if parsed.scheme not in {"http", "https"}: + return None + if parsed.username or parsed.password: + return None + host = (parsed.hostname or "").lower().rstrip(".") + if not host or host in _PRIVATE_HOSTS or host.endswith(".localhost") or host.endswith(".local"): + return None + if any(marker in host for marker in _SEARCH_HOST_MARKERS): + return None + try: + ip = ipaddress.ip_address(host) + except ValueError: + return raw.strip() + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + return None + return raw.strip() + + +def classify_public_claim( + envelope: PublicClaimEnvelope, + urls: tuple[str, ...], + *, + search_available: bool, +) -> PublicClaimVerdict: + """Classify one authorized envelope against retrieved public URLs. + + Missing search is unavailable, not not-enough-information. Other + kinds with retrieved URLs stay unavailable until orchestrator-owned + polarity exists — this function does not invent refutation. + """ + title = envelope.source_post_title + if not search_available: + return _verdict( + envelope, + STATUS_UNAVAILABLE, + (), + f"Web verification is unavailable until the search service is connected. Open that post.", + ) + if not urls: + return _verdict( + envelope, + STATUS_NOT_ENOUGH_INFORMATION, + (), + "No usable public web evidence. Open that post.", + ) + if envelope.claim_kind_code == KIND_ORGANIZATION_PRESENCE: + supporting = tuple( + url + for url in urls + if corroborating_evidence_url( + envelope.subject_label, {"url": url, "content": url} + ) + is not None + ) + if supporting: + return _verdict( + envelope, + STATUS_SUPPORTED, + supporting, + "Public web evidence supports this claim. Open that post.", + ) + return _verdict( + envelope, + STATUS_NOT_ENOUGH_INFORMATION, + urls, + "No usable public web evidence. Open that post.", + ) + return _verdict( + envelope, + STATUS_UNAVAILABLE, + urls, + f"Public claim is on {title}. Open that post.", + ) + + +def verify_public_claims( + envelopes: tuple[PublicClaimEnvelope, ...], + search_client: PublicClaimSearchClient, +) -> dict[str, Any]: + """Project authorized envelopes into the Ask Agent verification contract. + + An empty authorized set is unavailable and never searches. External + URLs stay off ``cited_post_ids``. + """ + if not envelopes: + return { + "status_code": STATUS_UNAVAILABLE, + "next_action": ( + "Public-claim verification is unavailable: no egress-eligible " + "public claim is authorized." + ), + "claims": [], + } + verdicts: list[PublicClaimVerdict] = [] + for envelope in envelopes: + if not search_client.available: + urls: tuple[str, ...] = () + else: + urls = search_client.search_urls(envelope.claim_text) + verdicts.append( + classify_public_claim( + envelope, urls, search_available=search_client.available + ) + ) + overall = _overall_status(tuple(verdict.status_code for verdict in verdicts)) + first = verdicts[0] + return { + "status_code": overall, + "next_action": first.next_action, + "claims": [ + { + "public_claim_envelope_id": item.public_claim_envelope_id, + "source_post_id": item.source_post_id, + "source_post_title": item.source_post_title, + "claim_kind_code": item.claim_kind_code, + "subject_label": item.subject_label, + "claim_text": item.claim_text, + "status_code": item.status_code, + "external_evidence_urls": list(item.external_evidence_urls), + "next_action": item.next_action, + } + for item in verdicts + ], + } + + +def cited_post_ids_exclude_external(cited_post_ids: list[str], verification: dict[str, Any]) -> None: + """Fail closed if an external URL leaked into internal citation ids.""" + external = { + url + for claim in verification.get("claims") or () + for url in (claim.get("external_evidence_urls") or ()) + } + overlap = external.intersection(cited_post_ids) + if overlap: + raise ValueError("external evidence URLs cannot become cited_post_ids") + + +def _overall_status(codes: tuple[str, ...]) -> str: + """Prefer a decisive label when every envelope agrees; else unavailable.""" + unique = set(codes) + if unique == {STATUS_SUPPORTED}: + return STATUS_SUPPORTED + if unique == {STATUS_REFUTED}: + return STATUS_REFUTED + if unique == {STATUS_NOT_ENOUGH_INFORMATION}: + return STATUS_NOT_ENOUGH_INFORMATION + if unique == {STATUS_UNAVAILABLE}: + return STATUS_UNAVAILABLE + return STATUS_UNAVAILABLE + + +def _verdict( + envelope: PublicClaimEnvelope, + status_code: str, + urls: tuple[str, ...], + next_action: str, +) -> PublicClaimVerdict: + """Pack one buyer-visible verdict.""" + return PublicClaimVerdict( + public_claim_envelope_id=envelope.public_claim_envelope_id, + source_post_id=envelope.source_post_id, + source_post_title=envelope.source_post_title, + claim_kind_code=envelope.claim_kind_code, + subject_label=envelope.subject_label, + claim_text=envelope.claim_text, + status_code=status_code, + external_evidence_urls=urls, + next_action=next_action, + ) diff --git a/migrations/0224_public_claim_envelope.sql b/migrations/0224_public_claim_envelope.sql new file mode 100644 index 000000000..689f11f61 --- /dev/null +++ b/migrations/0224_public_claim_envelope.sql @@ -0,0 +1,90 @@ +-- ADR 0224: typed public-claim envelope for Global Ask verification. +-- Replay-safe (ADR 0166). Lookup rows and the envelope table are the +-- owning contract; private posts cannot be marked egress-eligible. + +insert into common_lookup_value + (lookup_category, lookup_code, lookup_label, display_order) +values + ('public_claim_kind', 'claim_organization_presence', 'Organization presence', 0), + ('public_claim_kind', 'claim_public_event', 'Public event', 1), + ('public_claim_kind', 'claim_public_relationship', 'Public relationship', 2), + ('public_claim_verification_status', 'claim_unavailable', 'Unavailable', 0), + ('public_claim_verification_status', 'claim_supported', 'Supported', 1), + ('public_claim_verification_status', 'claim_refuted', 'Refuted', 2), + ('public_claim_verification_status', 'claim_not_enough_information', 'Not enough information', 3), + ('ontology_truth_status', 'truth_authoritative', 'Authoritative', 0), + ('ontology_truth_status', 'truth_observed', 'Observed', 1), + ('ontology_truth_status', 'truth_inferred', 'Inferred', 2), + ('ontology_truth_status', 'truth_proposed', 'Proposed', 3), + ('ontology_truth_status', 'truth_superseded', 'Superseded', 4), + ('ontology_truth_status', 'truth_rejected', 'Rejected', 5) +on conflict (lookup_code) do nothing; + +create table if not exists public_claim_envelope ( + public_claim_envelope_id uuid primary key default uuid_generate_v4(), + source_post_id uuid not null references source_post (post_id), + claim_kind_code text not null references common_lookup_value (lookup_code), + subject_label text not null, + claim_text text not null, + truth_status_code text not null references common_lookup_value (lookup_code), + event_occurred_at timestamptz, + egress_eligible boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (source_post_id, claim_kind_code, claim_text), + check (char_length(btrim(subject_label)) > 0), + check (char_length(btrim(claim_text)) > 0) +); + +create index if not exists public_claim_envelope_post_idx + on public_claim_envelope (source_post_id); + +create index if not exists public_claim_envelope_egress_idx + on public_claim_envelope (egress_eligible) + where egress_eligible; + +comment on table public_claim_envelope is + 'Typed public claim bound to one source post. Egress-eligible rows may ' + 'leave the trust boundary for SearXNG; person/Keyman/TEPP/fast-mlsirm ' + 'kinds are not in the lookup and cannot be stored.'; + +create or replace function public_claim_envelope_require_public_post() +returns trigger +language plpgsql +as $$ +declare + vis text; +begin + select visibility_code into vis + from source_post + where post_id = new.source_post_id; + if vis is distinct from 'public' then + if new.egress_eligible then + raise exception 'egress_eligible requires a public source_post'; + end if; + new.egress_eligible := false; + end if; + return new; +end; +$$; + +drop trigger if exists public_claim_envelope_require_public_post + on public_claim_envelope; +create trigger public_claim_envelope_require_public_post + before insert or update on public_claim_envelope + for each row + execute function public_claim_envelope_require_public_post(); + +do $$ +begin + if exists ( + select 1 from information_schema.tables + where table_schema = 'public' and table_name = 'global_ask_job' + ) then + alter table global_ask_job + add column if not exists verify_external boolean not null default false; + comment on column global_ask_job.verify_external is + 'Opt-in public-claim verification. Off omits the projection; on ' + 'loads authorized egress-eligible envelopes only.'; + end if; +end $$; diff --git a/migrations/rollback/0224_public_claim_envelope.sql b/migrations/rollback/0224_public_claim_envelope.sql new file mode 100644 index 000000000..9d7a7434d --- /dev/null +++ b/migrations/rollback/0224_public_claim_envelope.sql @@ -0,0 +1,31 @@ +begin; + +drop trigger if exists public_claim_envelope_require_public_post + on public_claim_envelope; +drop function if exists public_claim_envelope_require_public_post(); +drop table if exists public_claim_envelope; + +do $$ +begin + if exists ( + select 1 from information_schema.columns + where table_schema = 'public' + and table_name = 'global_ask_job' + and column_name = 'verify_external' + ) then + alter table global_ask_job drop column verify_external; + end if; +end $$; + +delete from common_lookup_value + where lookup_code in ( + 'claim_organization_presence', + 'claim_public_event', + 'claim_public_relationship', + 'claim_unavailable', + 'claim_supported', + 'claim_refuted', + 'claim_not_enough_information' +); + +commit; diff --git a/pyproject.toml b/pyproject.toml index 84a6e7566..850226465 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.18.0" +version = "2.22.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/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 092d28fa5..40cfdb3f3 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -127,6 +127,7 @@ def seed( cur.execute((migrations / "0182_report_leftover_map_unexplained.sql").read_text()) cur.execute((migrations / "0185_report_leftover_map_cross_share.sql").read_text()) cur.execute((migrations / "0206_report_leftover_map_reconstruction.sql").read_text()) + cur.execute((migrations / "0224_public_claim_envelope.sql").read_text()) cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text()) cur.execute((migrations / "0013_person_job_title.sql").read_text()) cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text()) @@ -329,6 +330,19 @@ def seed( "where post_id = %s", (demo_public_post_id,), ) + cur.execute( + "insert into public_claim_envelope (" + "source_post_id, claim_kind_code, subject_label, claim_text, " + "truth_status_code, event_occurred_at, egress_eligible" + ") values (%s, 'claim_organization_presence', %s, %s, " + "'truth_observed', '2026-01-10T12:00:00Z', true) " + "on conflict (source_post_id, claim_kind_code, claim_text) do nothing", + ( + demo_public_post_id, + "Northridge Grid", + "Northridge Grid is a power utility named on the Demo public post.", + ), + ) from base64 import b64encode from io import BytesIO diff --git a/tests/test_global_ask_queue.py b/tests/test_global_ask_queue.py index 76b07170c..d2a08e318 100644 --- a/tests/test_global_ask_queue.py +++ b/tests/test_global_ask_queue.py @@ -173,3 +173,156 @@ async def fetchval(self, query: str, *args): assert processes == {"queued-process"} assert process_scope_limited is True assert has_post_read is True + + +def test_process_global_ask_job_forwards_opt_in_verify_external(monkeypatch) -> None: + """Opt-in is persisted on the job row and must reach answer assembly.""" + connection = _Connection({**_queued_row(), "verify_external": True}) + pool = _Pool(connection) + captured: dict[str, object] = {} + + async def _fake_load_job_visibility(_conn, _job_id, _account_id): + return {"corp-1"}, set(), False, True + + async def _fake_compute_global_ask_answer(*_args, **kwargs): + captured.update(kwargs) + return { + "answer_text": "synthetic", + "cited_post_ids": [], + "cited_posts": [], + "source_post_ids": [], + } + + monkeypatch.setattr(global_ask_queue, "load_job_visibility", _fake_load_job_visibility) + monkeypatch.setattr( + global_ask_queue, "compute_global_ask_answer", _fake_compute_global_ask_answer + ) + + asyncio.run( + global_ask_queue.process_global_ask_job( + pool, + job_id="job-1", + chat_factory=_AvailableClient, + ) + ) + + assert captured["verify_external"] is True + assert captured["claim_search_client"] is not None + + +def test_missing_public_claim_table_is_unavailable_not_an_invented_claim() -> None: + """A volume that has not replayed 0224 fails closed.""" + import asyncpg + + class _MissingTable: + async def fetch(self, *_args: object, **_kwargs: object): + raise asyncpg.UndefinedTableError("public_claim_envelope") + + envelopes = asyncio.run( + global_ask_queue.load_authorized_public_claim_envelopes( + _MissingTable(), lambda _row: True + ) + ) + assert envelopes == () + + +def test_public_claim_loader_drops_unauthorized_and_ineligible_rows() -> None: + class _Rows: + async def fetch(self, *_args: object, **_kwargs: object): + return [ + { + "public_claim_envelope_id": "env-1", + "source_post_id": "post-demo-public", + "source_post_title": "Demo public post", + "claim_kind_code": "claim_organization_presence", + "subject_label": "Northridge Grid", + "claim_text": "Northridge Grid is a power utility.", + "truth_status_code": "truth_observed", + "event_occurred_at": None, + "egress_eligible": True, + "visibility_code": "public", + "corporate_entity_id": "corp-1", + "process_unit_id": "pu-1", + }, + { + "public_claim_envelope_id": "env-hidden", + "source_post_id": "post-hidden", + "source_post_title": "Hidden post", + "claim_kind_code": "claim_organization_presence", + "subject_label": "Northridge Grid", + "claim_text": "should not dispatch", + "truth_status_code": "truth_observed", + "event_occurred_at": None, + "egress_eligible": True, + "visibility_code": "public", + "corporate_entity_id": "corp-hidden", + "process_unit_id": "pu-hidden", + }, + ] + + envelopes = asyncio.run( + global_ask_queue.load_authorized_public_claim_envelopes( + _Rows(), + lambda row: row["source_post_id"] == "post-demo-public", + ) + ) + assert len(envelopes) == 1 + assert envelopes[0].source_post_id == "post-demo-public" + + +def test_public_claim_search_client_is_null_when_searxng_is_unset(monkeypatch) -> None: + monkeypatch.setattr( + "backend.app.config.load_settings", + lambda: type("Settings", (), {"searxng_base_url": ""})(), + ) + client = global_ask_queue._public_claim_search_client() + assert client.available is False + + +def test_empty_sources_still_attach_opt_in_public_claim_verification(monkeypatch) -> None: + class _PoolWithConn: + @asynccontextmanager + async def acquire(self): + yield object() + + async def _no_sources(*_args, **_kwargs): + return [] + + async def _envelopes(*_args, **_kwargs): + from lineageweave.public_claim_verification import ( + KIND_ORGANIZATION_PRESENCE, + PublicClaimEnvelope, + ) + + return ( + PublicClaimEnvelope( + public_claim_envelope_id="env-1", + source_post_id="post-demo-public", + source_post_title="Demo public post", + claim_kind_code=KIND_ORGANIZATION_PRESENCE, + subject_label="Northridge Grid", + claim_text="Northridge Grid is a power utility named on the Demo public post.", + truth_status_code="truth_observed", + event_occurred_at=None, + egress_eligible=True, + visibility_code="public", + ), + ) + + monkeypatch.setattr(global_ask_queue, "gather_global_chat_sources", _no_sources) + monkeypatch.setattr( + global_ask_queue, "load_authorized_public_claim_envelopes", _envelopes + ) + payload = asyncio.run( + global_ask_queue.compute_global_ask_answer( + _PoolWithConn(), + question_text="Does Northridge Grid exist?", + corporate_entity_ids=set(), + process_unit_ids=set(), + process_scope_limited=False, + chat_client=_AvailableClient(), + verify_external=True, + ) + ) + assert payload["public_claim_verification"]["status_code"] == "claim_unavailable" + assert payload["public_claim_verification"]["claims"] diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 0c7fccfd2..d05843e78 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -194,3 +194,20 @@ def test_topic_lineage_result_migration_is_idempotent_for_replay() -> None: assert "create table if not exists analysis_run_topic_lineage_result" in migration assert "create index if not exists" in migration + + +def test_public_claim_envelope_migration_is_replay_safe() -> None: + """Volumes created before 0224 must survive migrate.sh's every-start replay.""" + sql = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0224_public_claim_envelope.sql" + ).read_text(encoding="utf-8").casefold() + + assert "create table if not exists public_claim_envelope" in sql + assert "on conflict (lookup_code) do nothing" in sql + assert "create or replace function public_claim_envelope_require_public_post" in sql + assert "add column if not exists verify_external" in sql + assert "claim_organization_presence" in sql + assert "claim_public_event" in sql + assert "claim_public_relationship" in sql diff --git a/tests/test_public_claim_verification.py b/tests/test_public_claim_verification.py new file mode 100644 index 000000000..78adca934 --- /dev/null +++ b/tests/test_public_claim_verification.py @@ -0,0 +1,257 @@ +"""Unit tests for ADR 0224 public-claim envelopes. Synthetic fixtures only.""" + +from __future__ import annotations + +import pytest + +from lineageweave.public_claim_verification import ( + KIND_ORGANIZATION_PRESENCE, + KIND_PUBLIC_EVENT, + NullPublicClaimSearchClient, + PublicClaimEnvelope, + STATUS_NOT_ENOUGH_INFORMATION, + STATUS_SUPPORTED, + STATUS_UNAVAILABLE, + SearxngPublicClaimSearchClient, + cited_post_ids_exclude_external, + classify_public_claim, + envelope_from_authorized_row, + public_evidence_url, + verify_public_claims, +) + + +def _envelope(**overrides: object) -> PublicClaimEnvelope: + payload = dict( + public_claim_envelope_id="env-1", + source_post_id="post-demo-public", + source_post_title="Demo public post", + claim_kind_code=KIND_ORGANIZATION_PRESENCE, + subject_label="Northridge Grid", + claim_text="Northridge Grid is a power utility named on the Demo public post.", + truth_status_code="truth_observed", + event_occurred_at="2026-01-10T12:00:00+00:00", + egress_eligible=True, + visibility_code="public", + ) + payload.update(overrides) + return PublicClaimEnvelope(**payload) # type: ignore[arg-type] + + +def test_private_or_ineligible_rows_are_dropped() -> None: + assert envelope_from_authorized_row( + { + "public_claim_envelope_id": "env-1", + "source_post_id": "post-private", + "source_post_title": "Demo private post", + "claim_kind_code": KIND_ORGANIZATION_PRESENCE, + "subject_label": "Northridge Grid", + "claim_text": "secret", + "truth_status_code": "truth_observed", + "event_occurred_at": None, + "egress_eligible": True, + "visibility_code": "private", + } + ) is None + assert envelope_from_authorized_row( + { + "public_claim_envelope_id": "env-2", + "source_post_id": "post-demo-public", + "source_post_title": "Demo public post", + "claim_kind_code": "keyman", + "subject_label": "Ada West", + "claim_text": "Ada West is a Keyman", + "truth_status_code": "truth_observed", + "event_occurred_at": None, + "egress_eligible": True, + "visibility_code": "public", + } + ) is None + assert envelope_from_authorized_row( + { + "public_claim_envelope_id": "env-3", + "source_post_id": "post-demo-public", + "source_post_title": "Demo public post", + "claim_kind_code": KIND_ORGANIZATION_PRESENCE, + "subject_label": "Northridge Grid", + "claim_text": "Northridge Grid is a power utility.", + "truth_status_code": "truth_observed", + "event_occurred_at": None, + "egress_eligible": False, + "visibility_code": "public", + } + ) is None + + +def test_authorized_public_row_becomes_an_envelope() -> None: + envelope = envelope_from_authorized_row( + { + "public_claim_envelope_id": "env-1", + "source_post_id": "post-demo-public", + "source_post_title": "Demo public post", + "claim_kind_code": KIND_ORGANIZATION_PRESENCE, + "subject_label": "Northridge Grid", + "claim_text": "Northridge Grid is a power utility.", + "truth_status_code": "truth_observed", + "event_occurred_at": None, + "egress_eligible": True, + "visibility_code": "public", + } + ) + assert envelope is not None + assert envelope.source_post_title == "Demo public post" + + +def test_public_evidence_url_rejects_search_localhost_and_private_hosts() -> None: + assert public_evidence_url("https://www.google.com/search?q=x") is None + assert public_evidence_url("https://localhost/secret") is None + assert public_evidence_url("http://127.0.0.1/x") is None + assert public_evidence_url("http://10.0.0.8/intranet") is None + assert public_evidence_url("http://192.168.1.4/x") is None + assert public_evidence_url("file:///etc/passwd") is None + assert public_evidence_url("https://user:pass@example.test/x") is None + assert public_evidence_url("https://northridgegrid.example/about") == ( + "https://northridgegrid.example/about" + ) + + +def test_empty_authorized_set_is_unavailable_and_does_not_search() -> None: + class _Exploding: + available = True + + def search_urls(self, claim_text: str, *, limit: int = 5) -> tuple[str, ...]: + raise AssertionError("must not search without an authorized envelope") + + payload = verify_public_claims((), _Exploding()) + assert payload["status_code"] == STATUS_UNAVAILABLE + assert payload["claims"] == [] + assert "no egress-eligible" in payload["next_action"] + + +def test_missing_search_channel_is_unavailable_not_not_enough_information() -> None: + payload = verify_public_claims((_envelope(),), NullPublicClaimSearchClient()) + assert payload["status_code"] == STATUS_UNAVAILABLE + assert payload["claims"][0]["external_evidence_urls"] == [] + assert "search service" in payload["next_action"] + + +def test_no_usable_urls_are_not_enough_information() -> None: + class _Empty: + available = True + + def search_urls(self, claim_text: str, *, limit: int = 5) -> tuple[str, ...]: + assert "Northridge Grid" in claim_text + return () + + payload = verify_public_claims((_envelope(),), _Empty()) + assert payload["status_code"] == STATUS_NOT_ENOUGH_INFORMATION + assert payload["claims"][0]["status_code"] == STATUS_NOT_ENOUGH_INFORMATION + + +def test_organization_presence_with_distinctive_url_is_supported() -> None: + url = "https://northridgegrid.example/about" + verdict = classify_public_claim( + _envelope(), + (url,), + search_available=True, + ) + assert verdict.status_code == STATUS_SUPPORTED + assert verdict.external_evidence_urls == (url,) + assert "supports" in verdict.next_action + + +def test_other_kinds_stay_unavailable_even_with_urls() -> None: + verdict = classify_public_claim( + _envelope(claim_kind_code=KIND_PUBLIC_EVENT), + ("https://events.example/northridge",), + search_available=True, + ) + assert verdict.status_code == STATUS_UNAVAILABLE + assert verdict.external_evidence_urls == ("https://events.example/northridge",) + + +def test_external_urls_cannot_become_cited_post_ids() -> None: + verification = { + "claims": [ + { + "external_evidence_urls": ["https://northridgegrid.example/about"], + } + ] + } + cited_post_ids_exclude_external(["post-demo-public"], verification) + with pytest.raises(ValueError, match="cited_post_ids"): + cited_post_ids_exclude_external( + ["https://northridgegrid.example/about"], verification + ) + + +def test_searxng_client_filters_private_and_search_hits(monkeypatch) -> None: + def _fake_get_json(_url: str, **_kwargs: object) -> dict[str, object]: + return { + "results": [ + {"url": "https://www.bing.com/search?q=Northridge"}, + {"url": "http://127.0.0.1/x"}, + None, + {"url": "https://northridgegrid.example/about"}, + {"url": "https://northridgegrid.example/about"}, + ] + } + + monkeypatch.setattr( + "lineageweave.public_claim_verification.get_json", _fake_get_json + ) + client = SearxngPublicClaimSearchClient("http://searxng:8080") + assert client.search_urls( + "Northridge Grid is a power utility named on the Demo public post." + ) == ("https://northridgegrid.example/about",) + + +def test_searxng_client_refuses_non_http_scheme() -> None: + with pytest.raises(ValueError, match="unsupported Searxng base URL scheme"): + SearxngPublicClaimSearchClient("file:///etc/passwd") + + +def test_searxng_malformed_results_are_empty(monkeypatch) -> None: + monkeypatch.setattr( + "lineageweave.public_claim_verification.get_json", + lambda *_args, **_kwargs: {"results": {"not": "a list"}}, + ) + client = SearxngPublicClaimSearchClient("http://searxng") + assert client.search_urls("Northridge Grid is a power utility.") == () + + +def test_blank_row_fields_are_dropped() -> None: + assert envelope_from_authorized_row( + { + "public_claim_envelope_id": "env-1", + "source_post_id": "post-demo-public", + "source_post_title": "Demo public post", + "claim_kind_code": KIND_ORGANIZATION_PRESENCE, + "subject_label": " ", + "claim_text": "Northridge Grid is a power utility.", + "truth_status_code": "truth_observed", + "event_occurred_at": None, + "egress_eligible": True, + "visibility_code": "public", + } + ) is None + + +def test_unrelated_url_is_not_enough_information_for_organization_presence() -> None: + verdict = classify_public_claim( + _envelope(), + ("https://unrelated.example/generic",), + search_available=True, + ) + assert verdict.status_code == STATUS_NOT_ENOUGH_INFORMATION + + +def test_blank_claim_text_does_not_search(monkeypatch) -> None: + def _fake_get_json(_url: str, **_kwargs: object) -> dict[str, object]: + raise AssertionError("blank claim must not hit SearXNG") + + monkeypatch.setattr( + "lineageweave.public_claim_verification.get_json", _fake_get_json + ) + client = SearxngPublicClaimSearchClient("https://searxng.example") + assert client.search_urls(" ") == () diff --git a/tests/test_schema.py b/tests/test_schema.py index d88d07bd2..f63085b2d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -68,6 +68,14 @@ / "migrations" / "0206_report_leftover_map_reconstruction.sql" ) +_GLOBAL_ASK_JOB_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0165_global_ask_job.sql" +) +_PUBLIC_CLAIM_ENVELOPE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0224_public_claim_envelope.sql" +) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -131,6 +139,8 @@ def schema_db(): cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) + cur.execute(_PUBLIC_CLAIM_ENVELOPE_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -185,6 +195,8 @@ def test_migration_applies_cleanly(schema_db) -> None: "post_summary_action", "post_chat_result", "post_chat_citation", + "global_ask_job", + "public_claim_envelope", } assert expected <= tables @@ -579,3 +591,94 @@ def test_cataloged_team_null_affiliation_is_unique(schema_db) -> None: count = cursor.fetchone()[0] assert ids[0] == ids[1] assert count == 1 + + +def test_public_claim_envelope_requires_a_public_post_for_egress(schema_db) -> None: + """Private posts cannot be marked egress-eligible (ADR 0224).""" + with schema_db.cursor() as cur: + cur.execute( + "select lookup_code from common_lookup_value " + "where lookup_category = 'public_claim_kind' order by display_order" + ) + assert [row[0] for row in cur.fetchall()] == [ + "claim_organization_presence", + "claim_public_event", + "claim_public_relationship", + ] + cur.execute( + "select column_name from information_schema.columns " + "where table_name = 'global_ask_job' and column_name = 'verify_external'" + ) + assert cur.fetchone()[0] == "verify_external" + cur.execute( + "insert into common_lookup_value " + "(lookup_category, lookup_code, lookup_label) values " + "('corporate_entity_level', 'company', 'Company'), " + "('voc_type', 'vom', 'VOM'), " + "('post_visibility', 'public', 'Public'), " + "('post_visibility', 'private', 'Private')" + ) + cur.execute( + "insert into corporate_entity " + "(corporate_entity_code, entity_name, entity_level_code) " + "values ('DEMO-CLAIM', 'Demo Claim Corp', 'company') " + "returning corporate_entity_id" + ) + entity_id = cur.fetchone()[0] + cur.execute( + "insert into user_account (external_subject_id, display_name, email_address) " + "values ('demo-claim-author', 'Demo Claim Author', 'demo.claim@example.test') " + "returning user_account_id" + ) + account_id = cur.fetchone()[0] + cur.execute( + "insert into source_post (" + "author_account_id, corporate_entity_id, post_title, post_body, " + "voc_type_code, visibility_code" + ") values (%s, %s, 'Demo public post', 'Northridge Grid is named here.', " + "'vom', 'public') returning post_id", + (account_id, entity_id), + ) + public_post_id = cur.fetchone()[0] + cur.execute( + "insert into source_post (" + "author_account_id, corporate_entity_id, post_title, post_body, " + "voc_type_code, visibility_code" + ") values (%s, %s, 'Demo private post', 'Internal only.', " + "'vom', 'private') returning post_id", + (account_id, entity_id), + ) + private_post_id = cur.fetchone()[0] + cur.execute( + "insert into public_claim_envelope (" + "source_post_id, claim_kind_code, subject_label, claim_text, " + "truth_status_code, egress_eligible" + ") values (%s, 'claim_organization_presence', 'Northridge Grid', " + "'Northridge Grid is a power utility named on the Demo public post.', " + "'truth_observed', true)", + (public_post_id,), + ) + cur.execute("savepoint before_private_egress") + with pytest.raises(psycopg2.errors.RaiseError): + cur.execute( + "insert into public_claim_envelope (" + "source_post_id, claim_kind_code, subject_label, claim_text, " + "truth_status_code, egress_eligible" + ") values (%s, 'claim_organization_presence', 'Northridge Grid', " + "'A private claim must not leave the trust boundary.', " + "'truth_observed', true)", + (private_post_id,), + ) + cur.execute("rollback to savepoint before_private_egress") + cur.execute("savepoint before_ineligible_kind") + with pytest.raises(psycopg2.errors.ForeignKeyViolation): + cur.execute( + "insert into public_claim_envelope (" + "source_post_id, claim_kind_code, subject_label, claim_text, " + "truth_status_code, egress_eligible" + ") values (%s, 'keyman', 'Ada West', 'Ada West is a Keyman', " + "'truth_observed', false)", + (public_post_id,), + ) + cur.execute("rollback to savepoint before_ineligible_kind") + schema_db.rollback() \ No newline at end of file diff --git a/uv.lock b/uv.lock index b714796ba..67aef5949 100644 --- a/uv.lock +++ b/uv.lock @@ -645,7 +645,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.18.0" +version = "2.22.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From c60dcb3a49a337b9645f6d7160bd31a178e4c660 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:45:54 +0900 Subject: [PATCH 02/27] docs: assign unique public claim ADR number --- AGENTS.md | 2 +- ARCHITECTURE.md | 4 ++-- CHANGELOG.d/2.22.0-public-claim-envelope.md | 4 ++-- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- ...public-claim-envelope.md => 0229-public-claim-envelope.md} | 2 +- docs/adr/README.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- frontend/src/components/PublicClaimList.tsx | 2 +- lineageweave/public_claim_verification.py | 2 +- migrations/0224_public_claim_envelope.sql | 2 +- tests/test_public_claim_verification.py | 2 +- tests/test_schema.py | 4 ++-- 13 files changed, 16 insertions(+), 16 deletions(-) rename docs/adr/{0224-public-claim-envelope.md => 0229-public-claim-envelope.md} (97%) diff --git a/AGENTS.md b/AGENTS.md index 5fa2560f5..8a499cd4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,7 +295,7 @@ when the event instant is missing. Cited evidence names **Time axis** so the reader can open that post and see which clock matched. Do not invent an event date or a theta. -Public-claim verification (ADR 0224 / issue #272) is opt-in +Public-claim verification (ADR 0229 / issue #272) is opt-in (`verify_external`). Admission is a persisted `public_claim_envelope` bound to a public post. Question-token overlap is not admission. Person, Keyman, TEPP, and fast-mlsirm kinds cannot be stored. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 483e32b85..167be3235 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,7 +79,7 @@ flowchart LR | `commitment_extraction.py` | Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; `Null` default, `ContextualOrchestrator` real impl | | `temporal_expressions.py` | Pure Korean relative-time resolver for Global Ask (ADR 0150) | | `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) | -| `public_claim_verification.py` | Typed public-claim envelopes for Global Ask; opt-in SearXNG URLs stay off `cited_post_ids` (ADR 0224) | +| `public_claim_verification.py` | Typed public-claim envelopes for Global Ask; opt-in SearXNG URLs stay off `cited_post_ids` (ADR 0229) | | `ontology.py` | Loads `docs/ontology/lineageweave-kg.ttl`, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) | | `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge | | `ontology_source_cursor.py` | Opaque HMAC source-window continuation (ADR 0124); keyset pagination, never OFFSET | @@ -235,7 +235,7 @@ Each direct edge includes `interval_relation_code` / Global Ask merges cited threads from one post/edge fetch pair and caps the payload at the landing node bound, keeping cited posts first (ADR 0169). Open a cited post to read the focused thread. -Opt-in public-claim verification (ADR 0224) loads persisted +Opt-in public-claim verification (ADR 0229) loads persisted egress-eligible envelopes only. External URLs stay off `cited_post_ids`. Missing search is unavailable, not a question-token query, and this repository never forces `mode="verify"`. diff --git a/CHANGELOG.d/2.22.0-public-claim-envelope.md b/CHANGELOG.d/2.22.0-public-claim-envelope.md index 6d05604ba..4a9231291 100644 --- a/CHANGELOG.d/2.22.0-public-claim-envelope.md +++ b/CHANGELOG.d/2.22.0-public-claim-envelope.md @@ -3,7 +3,7 @@ ## Added - Typed `public_claim_envelope` rows admit Global Ask public verification - (issue #272 / ADR 0224). Closed kinds are organization presence, public + (issue #272 / ADR 0229). Closed kinds are organization presence, public event, and public relationship. Person, Keyman, TEPP, and fast-mlsirm evidence cannot be stored. - Ask Agent `verify_external` is opt-in. Off omits the projection. On @@ -17,4 +17,4 @@ answer. A click opens that post. Web verification is unavailable when SearXNG is unset. -[ADR 0224]: docs/adr/0224-public-claim-envelope.md +[ADR 0229]: docs/adr/0229-public-claim-envelope.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da50e42c..63fc9b45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to this project are documented here. Format follows ### Added - Persist typed public-claim envelopes for Global Ask web verification - (issue #272 / ADR 0224). Ask Agent can opt into `verify_external`; + (issue #272 / ADR 0229). Ask Agent can opt into `verify_external`; only egress-eligible public organization-presence, public-event, and public-relationship claims are admitted. Person, Keyman, TEPP, and fast-mlsirm evidence stay inside the workspace. SearXNG URLs never diff --git a/CLAUDE.md b/CLAUDE.md index e4a7cf165..ac662650a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ cutoff. Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0201), public-claim envelopes (ADR 0224), the text-channel embedding swap and cosine +(ADR 0048–0164 / 0182 / 0201), public-claim envelopes (ADR 0229), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), migration replay (ADR 0166), docstring coverage, and the measurement boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before diff --git a/docs/adr/0224-public-claim-envelope.md b/docs/adr/0229-public-claim-envelope.md similarity index 97% rename from docs/adr/0224-public-claim-envelope.md rename to docs/adr/0229-public-claim-envelope.md index 60d608c16..9231d73b4 100644 --- a/docs/adr/0224-public-claim-envelope.md +++ b/docs/adr/0229-public-claim-envelope.md @@ -1,4 +1,4 @@ -# ADR 0224: Persisted public-claim envelope for Global Ask verification +# ADR 0229: Persisted public-claim envelope for Global Ask verification **Status:** Accepted **Date:** 2026-08-26 diff --git a/docs/adr/README.md b/docs/adr/README.md index 3c1cf49f5..5db8b7f55 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,7 +11,7 @@ decision from them. |---|---| | [`product-requirements.md`](../product-requirements.md) | Product requirements projection across the ADR set; ADRs remain normative | | [`product-technical-gap-baseline.md`](../product-technical-gap-baseline.md) | Product/technical traceability projection across the ADR set; ADRs remain normative | -| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md), [0224](0224-public-claim-envelope.md) | +| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md), [0229](0229-public-claim-envelope.md) | | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e2c44c71f..49d03a5f5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -353,7 +353,7 @@ this file per §3.5 of the prior snapshot). | #87 | Milestone 2.1 normalized runtime-analysis schema bridge | related analysis-run work | | #269 | Authenticated Global Ask MCP browser-safe and admission-bounded | Ask stack | | #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Typed public-claim envelope (ADR 0224) on this branch; not protected-main evidence | +| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Typed public-claim envelope (ADR 0229) on this branch; not protected-main evidence | | #274 | Persist and explain Event Lineage channel evidence | #387 | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | diff --git a/frontend/src/components/PublicClaimList.tsx b/frontend/src/components/PublicClaimList.tsx index 412af6d51..482184916 100644 --- a/frontend/src/components/PublicClaimList.tsx +++ b/frontend/src/components/PublicClaimList.tsx @@ -23,7 +23,7 @@ const STATUS_LABELS: Record = { * Authorized public-claim verdicts for an opted-in Global Ask. * * Each row opens the exact source post. External URLs stay links and - * never become cited post ids (ADR 0224). + * never become cited post ids (ADR 0229). */ export function PublicClaimList({ claims, onSelectPost }: PublicClaimListProps) { if (claims.length === 0) { diff --git a/lineageweave/public_claim_verification.py b/lineageweave/public_claim_verification.py index d606c358c..d2a0abb89 100644 --- a/lineageweave/public_claim_verification.py +++ b/lineageweave/public_claim_verification.py @@ -1,6 +1,6 @@ """Typed public-claim envelopes for Global Ask external verification. -Implements ADR 0224 / issue #272. A claim is admitted only when a +Implements ADR 0229 / issue #272. A claim is admitted only when a persisted envelope names a public post, a governed claim kind, and egress eligibility. Question-token overlap is not admission. Person, Keyman, TEPP, and fast-mlsirm evidence cannot be stored or dispatched. diff --git a/migrations/0224_public_claim_envelope.sql b/migrations/0224_public_claim_envelope.sql index 689f11f61..95ef6397c 100644 --- a/migrations/0224_public_claim_envelope.sql +++ b/migrations/0224_public_claim_envelope.sql @@ -1,4 +1,4 @@ --- ADR 0224: typed public-claim envelope for Global Ask verification. +-- ADR 0229: typed public-claim envelope for Global Ask verification. -- Replay-safe (ADR 0166). Lookup rows and the envelope table are the -- owning contract; private posts cannot be marked egress-eligible. diff --git a/tests/test_public_claim_verification.py b/tests/test_public_claim_verification.py index 78adca934..04a9f28dd 100644 --- a/tests/test_public_claim_verification.py +++ b/tests/test_public_claim_verification.py @@ -1,4 +1,4 @@ -"""Unit tests for ADR 0224 public-claim envelopes. Synthetic fixtures only.""" +"""Unit tests for ADR 0229 public-claim envelopes. Synthetic fixtures only.""" from __future__ import annotations diff --git a/tests/test_schema.py b/tests/test_schema.py index f63085b2d..220c59c6c 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -594,7 +594,7 @@ def test_cataloged_team_null_affiliation_is_unique(schema_db) -> None: def test_public_claim_envelope_requires_a_public_post_for_egress(schema_db) -> None: - """Private posts cannot be marked egress-eligible (ADR 0224).""" + """Private posts cannot be marked egress-eligible (ADR 0229).""" with schema_db.cursor() as cur: cur.execute( "select lookup_code from common_lookup_value " @@ -681,4 +681,4 @@ def test_public_claim_envelope_requires_a_public_post_for_egress(schema_db) -> N (public_post_id,), ) cur.execute("rollback to savepoint before_ineligible_kind") - schema_db.rollback() \ No newline at end of file + schema_db.rollback() From 640e530ca026f93d59f700131f0913c4dd00d9ac Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:47:35 +0900 Subject: [PATCH 03/27] fix(ask): keep public claim verification off event loop --- backend/app/global_ask_queue.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index c847af2de..bd28f2955 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -285,7 +285,9 @@ def can_see(row: asyncpg.Record) -> bool: search_client = claim_search_client or NullPublicClaimSearchClient() async with pool.acquire() as conn: envelopes = await load_authorized_public_claim_envelopes(conn, can_see) - verification = verify_public_claims(envelopes, search_client) + verification = await asyncio.to_thread( + verify_public_claims, envelopes, search_client + ) cited_post_ids_exclude_external([], verification) payload["public_claim_verification"] = verification return payload @@ -341,7 +343,9 @@ def can_see(row: asyncpg.Record) -> bool: search_client = claim_search_client or NullPublicClaimSearchClient() async with pool.acquire() as conn: envelopes = await load_authorized_public_claim_envelopes(conn, can_see) - verification = verify_public_claims(envelopes, search_client) + verification = await asyncio.to_thread( + verify_public_claims, envelopes, search_client + ) cited_post_ids_exclude_external(cited_ids, verification) payload["public_claim_verification"] = verification if not payload.get("next_action"): From ee5eb25f872f1d651e537fdf49b4d52f590abf1f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:58:41 +0900 Subject: [PATCH 04/27] fix(i18n): format public claim next action by locale --- .../src/components/PublicClaimList.test.tsx | 26 ++++++++++++++++++- frontend/src/components/PublicClaimList.tsx | 15 ++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PublicClaimList.test.tsx b/frontend/src/components/PublicClaimList.test.tsx index 60743490c..8d871d687 100644 --- a/frontend/src/components/PublicClaimList.test.tsx +++ b/frontend/src/components/PublicClaimList.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { PublicClaimVerdict } from "../api"; +import { setLocale } from "../i18n"; import { PublicClaimList } from "./PublicClaimList"; const SUPPORTED: PublicClaimVerdict = { @@ -16,6 +17,8 @@ const SUPPORTED: PublicClaimVerdict = { next_action: "Public web evidence supports this claim. Open that post.", }; +afterEach(() => setLocale("en")); + describe("PublicClaimList", () => { it("names the public claim so the next click opens that post", async () => { const onSelectPost = vi.fn(); @@ -57,6 +60,27 @@ describe("PublicClaimList", () => { expect(screen.queryByRole("link")).not.toBeInTheDocument(); }); + it("formats a public-event next action after locale lookup", () => { + setLocale("ko"); + render( + , + ); + + expect(screen.getByRole("button")).toHaveTextContent( + "공개 주장은 합성 공개 글에 있습니다. 그 글을 여세요.", + ); + }); + it("renders nothing when no authorized claim is present", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); diff --git a/frontend/src/components/PublicClaimList.tsx b/frontend/src/components/PublicClaimList.tsx index 482184916..c93f376d8 100644 --- a/frontend/src/components/PublicClaimList.tsx +++ b/frontend/src/components/PublicClaimList.tsx @@ -19,6 +19,19 @@ const STATUS_LABELS: Record = { claim_unavailable: "Unavailable", }; +function nextActionLabel(claim: PublicClaimVerdict): string { + if ( + claim.status_code === "claim_unavailable" && + (claim.claim_kind_code === "claim_public_event" || + claim.claim_kind_code === "claim_public_relationship") + ) { + return tf("Public claim is on {title}. Open that post.", { + title: claim.source_post_title, + }); + } + return t(claim.next_action); +} + /** * Authorized public-claim verdicts for an opted-in Global Ask. * @@ -49,7 +62,7 @@ export function PublicClaimList({ claims, onSelectPost }: PublicClaimListProps) {kindLabel}: {claim.source_post_title} · {claim.subject_label} {statusLabel} - {t(claim.next_action)} + {nextActionLabel(claim)} {claim.external_evidence_urls.length > 0 ? (