From 886d7e6bdb96248d7c3f2e8656afc20d543a6829 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:07:59 +0900 Subject: [PATCH 1/9] fix: enforce TEPP request and search evidence boundaries --- lineageweave/relation_verification.py | 7 ++++ lineageweave/tepp_client.py | 40 +++++++++++++----- tests/test_relation_verification.py | 60 ++++++++++++++++++++++++++- tests/test_tepp_client.py | 25 +++++++++++ 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index acba7b225..e486013b8 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -56,6 +56,13 @@ "group", "holdings", "limited", + # Fixture descriptors are search vocabulary, not organization identity. + "fictitious", + "nonexistent", + "placeholder", + "sample", + "example", + "demo", "foundation", "the", "and", diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index 9ffe3c4a3..374a47c88 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -8,12 +8,12 @@ lineage scores as TEPP's calibrated psychometric measurement (they answer different questions -- see docs/lineage-bi-research-notes.md). -TEPP does not expose a live HTTP endpoint yet (as of this writing it is -Rust-crate-only; see ``docs/API_CONTRACT.md`` in that repo). This client -builds and validates the exact wire shape TEPP has published -(``schemas/analysis_run_request_v1.json``) so wiring in a real transport is -a one-line change (:meth:`TeppClient.__init__`'s ``transport`` argument) once -that endpoint exists, instead of a redesign. +TEPP's current protected main exposes Rust library/domain contracts and an +accepted target API contract, not a deployed HTTP service (see +``docs/API_CONTRACT.md`` in that repo). This client builds and validates the +exact wire shape TEPP has published +(``schemas/analysis_run_request_v1.json``), so an executable transport can be +added through :meth:`TeppClient.__init__` without a consumer redesign. """ from __future__ import annotations @@ -23,14 +23,15 @@ class TeppNotAvailable(RuntimeError): - """Raised by the default transport: TEPP has no live REST API yet.""" + """Raised when no executable TEPP transport is configured.""" def _no_transport(request: dict[str, Any]) -> dict[str, Any]: + """Fail closed while TEPP exposes no executable transport.""" raise TeppNotAvailable( - "TEPP has no live HTTP endpoint yet (Rust-crate-only as of this writing). " - "Pass a transport= callable to TeppClient once one exists, or consume TEPP " - "as a Rust crate directly per its own docs/API_CONTRACT.md." + "No executable TEPP transport is configured. TEPP currently publishes " + "Rust library/domain contracts and an accepted target API contract; " + "configure transport= when an executable service is available." ) @@ -50,7 +51,26 @@ class AnalysisRunRequest: output_profile: str contract_version: int = 1 + def __post_init__(self) -> None: + """Reject payloads that violate TEPP's v1 schema before transport.""" + if type(self.contract_version) is not int or self.contract_version != 1: + raise ValueError("TEPP AnalysisRunRequest requires contract_version=1") + for field_name in ( + "idempotency_key", + "tenant_workspace_id", + "snapshot_id", + "knowledge_cutoff", + "model_contract_version", + "output_profile", + ): + value = getattr(self, field_name) + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"TEPP AnalysisRunRequest field {field_name} must be non-blank text" + ) + def to_json(self) -> dict[str, Any]: + """Serialize the validated request into TEPP's v1 wire representation.""" return { "contract_version": self.contract_version, "idempotency_key": self.idempotency_key, diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index f515d5d25..33ebb992f 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -18,6 +18,7 @@ import pytest from lineageweave.relation_verification import ( + RelationVerificationClient, STATUS_CORROBORATED, STATUS_UNCORROBORATED, NullRelationVerificationClient, @@ -33,7 +34,16 @@ def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API parsed = urlparse(self.path) query = parse_qs(parsed.query) type(self).received_query = query.get("q", [""])[0] - if "Acme" in type(self).received_query: + if "NoList" in type(self).received_query: + payload = {"query": type(self).received_query, "results": {}} + elif "Skip" in type(self).received_query: + payload = {"query": type(self).received_query, "results": [None]} + elif "NoEvidence" in type(self).received_query: + payload = { + "query": type(self).received_query, + "results": [{"url": "https://example.com/item", "content": ""}], + } + elif "Acme" in type(self).received_query: payload = { "query": type(self).received_query, "results": [ @@ -72,6 +82,12 @@ def test_null_client_is_unavailable_not_silently_uncorroborated() -> None: client.verify("Acme Corp", "Voice of Customer") +def test_protocol_stub_raises_instead_of_returning_a_fake_result() -> None: + """The protocol's executable stub must fail if called directly.""" + with pytest.raises(NotImplementedError): + RelationVerificationClient.verify(object(), "Acme", "Voice of Customer") + + def test_searxng_client_reports_corroborated_with_evidence_url() -> None: server, base = _serve() try: @@ -98,6 +114,21 @@ def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_ assert result.evidence_url is None +@pytest.mark.parametrize("organization_name", ["NoList", "Skip", "NoEvidence"]) +def test_searxng_client_fails_closed_for_unusable_results(organization_name: str) -> None: + """Malformed and unciting search results remain explicitly uncorroborated.""" + server, base = _serve() + try: + result = SearxngRelationVerificationClient(base_url=base).verify( + organization_name, "Voice of Customer" + ) + finally: + server.shutdown() + + assert result.status_code == STATUS_UNCORROBORATED + assert result.evidence_url is None + + def test_query_echo_on_a_search_host_is_not_corroboration() -> None: assert ( corroborating_evidence_url( @@ -133,6 +164,33 @@ def test_legal_suffix_alone_is_not_corroboration() -> None: ) +def test_fixture_descriptors_do_not_corrobate_an_unrelated_search_hit() -> None: + """Generic synthetic-data words must not stand in for organization identity.""" + assert ( + corroborating_evidence_url( + "Zzqxvthorp Fictitious Nonexistent Org", + { + "url": "https://learn.microsoft.com/writing-style", + "title": "Fictitious names and addresses", + "content": "Documentation explains fictitious and nonexistent examples.", + }, + ) + is None + ) + + +def test_missing_url_and_all_generic_tokens_are_not_evidence() -> None: + """Missing URLs and names made only of fixture words cannot cite evidence.""" + assert corroborating_evidence_url("Acme Corp", {"content": "Acme"}) is None + assert ( + corroborating_evidence_url( + "Fictitious Nonexistent Org", + {"url": "https://example.com/item", "content": "Fictitious"}, + ) + is None + ) + + def test_hangul_org_name_token_is_corroboration() -> None: assert ( corroborating_evidence_url( diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 8c2509fb9..69334b7e9 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import replace + import pytest from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable @@ -30,6 +32,29 @@ def test_to_json_matches_tepp_published_schema_shape() -> None: } +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("idempotency_key", " "), + ("tenant_workspace_id", None), + ("snapshot_id", "\t"), + ("knowledge_cutoff", ""), + ("model_contract_version", "\n"), + ("output_profile", None), + ], +) +def test_request_rejects_non_blank_schema_fields(field_name: str, value: object) -> None: + """A v1 request must not send blank or non-text required fields.""" + with pytest.raises(ValueError, match=field_name): + replace(_sample_request(), **{field_name: value}) + + +def test_request_rejects_unknown_contract_version() -> None: + """The adapter must not silently emit a request for another contract.""" + with pytest.raises(ValueError, match="contract_version=1"): + replace(_sample_request(), contract_version=2) + + def test_default_transport_fails_closed_until_tepp_ships_http() -> None: client = TeppClient() with pytest.raises(TeppNotAvailable): From 1a27efec6863cd3439a4c6023e1c625ce4d7abf2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:09:23 +0900 Subject: [PATCH 2/9] docs: record TEPP and search evidence guards --- docs/adr/0005-relation-verification-agent.md | 6 ++++++ docs/adr/0022-authorized-tepp-start.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/docs/adr/0005-relation-verification-agent.md b/docs/adr/0005-relation-verification-agent.md index ed5bd8108..98645e24d 100644 --- a/docs/adr/0005-relation-verification-agent.md +++ b/docs/adr/0005-relation-verification-agent.md @@ -55,6 +55,12 @@ keeps the channel unavailable (never fabricates a verification result) when `SEARXNG_BASE_URL` is unset, same discipline as every other pluggable client in this repo. +Evidence token selection excludes generic fixture descriptors such as +`fictitious`, `nonexistent`, `placeholder`, `sample`, `example`, and `demo`. +Those words can appear in unrelated search results and are not organization +identity. This keeps synthetic demo data out of the corroboration signal while +retaining distinctive organization tokens and cited URLs. + Persistence: `post_counterparty_entity` gains `verification_status_code` (`common_lookup_value` category `relation_verification_status`: `verify_pending` / `verify_corroborated` diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index a6517ae53..b6a3cb4c2 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -37,6 +37,8 @@ authorized transaction: 4. submits through `TeppClient`. An empty `TEPP_TRANSPORT_URL` keeps the default unavailable transport. A set URL POSTs the published wire payload through the http(s)-only helper. File URLs stay unavailable; + `AnalysisRunRequest` rejects non-v1 versions and blank/non-text required + fields before the transport is called, matching TEPP's v1 JSON Schema; 5. appends Failed / `tepp_not_available` when the transport is missing or refused, or Failed / `tepp_result_not_persisted` when TEPP accepts an envelope this product cannot store yet. @@ -94,3 +96,7 @@ World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C Recommendation). https://www.w3.org/TR/prov-o/ + +ContextualWisdomLab. (2026). *TEPP API and modular integration contract* +([Computer software documentation]). GitHub. +https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/API_CONTRACT.md From 3b57e1cad490b47496d0c25553d28a0c1e3e2ca3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:02:06 +0900 Subject: [PATCH 3/9] test: guard TEPP contract booleans --- tests/test_tepp_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 69334b7e9..6283e2e08 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -55,6 +55,12 @@ def test_request_rejects_unknown_contract_version() -> None: replace(_sample_request(), contract_version=2) +def test_request_rejects_boolean_contract_version() -> None: + """JSON booleans must not pass Python's integer type relationship.""" + with pytest.raises(ValueError, match="contract_version=1"): + replace(_sample_request(), contract_version=True) + + def test_default_transport_fails_closed_until_tepp_ships_http() -> None: client = TeppClient() with pytest.raises(TeppNotAvailable): From 061e62130e3d6fc3e6bb3a5c0d941a0c7aac85cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:40:38 +0900 Subject: [PATCH 4/9] fix: require whole tokens for search corroboration --- lineageweave/relation_verification.py | 8 +++++++- tests/test_relation_verification.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index e486013b8..d7a94f506 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -184,6 +184,12 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - if not tokens: return None haystack = f"{host} {result.get('content') or ''}".lower() - if any(token in haystack for token in tokens): + # Substring matches turn ``Alpha`` into a false hit for ``alphabetical``. + # Word boundaries keep host labels, punctuation, and Hangul names usable + # without accepting a token embedded inside an unrelated word. + if any( + re.search(rf"(? None: ) +def test_short_name_token_inside_another_word_is_not_corroboration() -> None: + """A search snippet must contain the organization token as a word.""" + assert ( + corroborating_evidence_url( + "Alpha Corp", + { + "url": "https://unrelated.example/news", + "title": "Alphabetical index", + "content": "An alphabetical index of sample terms.", + }, + ) + is None + ) + + def test_legal_suffix_alone_is_not_corroboration() -> None: """'Corp' is in almost every corporate host; it is not evidence.""" assert ( From eb1fc2a473ea2401a7cd259f08a22c6e257438ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:00:10 +0900 Subject: [PATCH 5/9] fix: preserve Korean relation evidence boundaries --- CHANGELOG.d/2.12.7-korean-search-boundary.md | 7 +++++++ lineageweave/relation_verification.py | 18 ++++++++++++++---- tests/test_relation_verification.py | 20 ++++++++++++++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/2.12.7-korean-search-boundary.md diff --git a/CHANGELOG.d/2.12.7-korean-search-boundary.md b/CHANGELOG.d/2.12.7-korean-search-boundary.md new file mode 100644 index 000000000..9ea0359d8 --- /dev/null +++ b/CHANGELOG.d/2.12.7-korean-search-boundary.md @@ -0,0 +1,7 @@ +# 2.12.7 — Preserve Korean search corroboration boundaries + +## Fixed + +- Accept a Korean organization token followed by a grammatical particle in a + natural Searxng snippet while still rejecting the token inside a longer + unrelated Hangul word. diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index d7a94f506..723bcd49c 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -68,12 +68,25 @@ "and", } ) +# Korean postpositions attach directly to nouns. They are allowed only when +# the suffix itself ends at a non-word boundary; a longer Hangul word must not +# turn a substring into corroborating evidence. +_HANGUL_PARTICLE = r"(?:으로|에서|에게|한테|까지|부터|처럼|보다|마다|조차|마저|밖에|이랑|랑|은|는|이|가|을|를|에|와|과|로|의|도|만|뿐)" STATUS_PENDING = "verify_pending" STATUS_CORROBORATED = "verify_corroborated" STATUS_UNCORROBORATED = "verify_uncorroborated" +def _contains_org_token(token: str, haystack: str) -> bool: + """Match one organization token without breaking Korean particle syntax.""" + if re.search(r"[가-힣]", token): + pattern = rf"(? None: ) -def test_hangul_org_name_token_is_corroboration() -> None: +@pytest.mark.parametrize("particle", ["가", "에서", "으로"]) +def test_hangul_org_name_with_attached_particle_is_corroboration(particle: str) -> None: assert ( corroborating_evidence_url( "한빛그리드", { "url": "https://news.example/item", "title": "News", - "content": "한빛그리드 announced a delivery window.", + "content": f"한빛그리드{particle} 발표했다.", }, ) == "https://news.example/item" ) +def test_hangul_org_name_inside_a_larger_word_is_not_corroboration() -> None: + """A company token must not match an unrelated longer Hangul word.""" + assert ( + corroborating_evidence_url( + "한빛그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": "한빛그리드산업의 발표.", + }, + ) + is None + ) + + def test_searxng_client_refuses_non_http_scheme() -> None: with pytest.raises(ValueError, match="unsupported Searxng base URL scheme"): SearxngRelationVerificationClient(base_url="file:///etc/passwd") From 4e92ef20034a315365b3e439844b15df1550539c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 14:10:34 +0900 Subject: [PATCH 6/9] fix: sanitize TEPP transport failures --- backend/app/analysis_run_start.py | 50 ++++++++++++++----------------- lineageweave/tepp_client.py | 10 +++++-- tests/test_tepp_client.py | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+), 29 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 2387d940b..180a660cf 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -11,7 +11,7 @@ import hashlib import json -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from uuid import UUID @@ -21,15 +21,15 @@ AnalysisRunCreateError, fetch_visible_analysis_run, ) -from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from backend.app.analysis_run_outbox import ( latest_outbox_delivery_is_claimed, latest_outbox_delivery_is_delivered, outbox_request_digest, ) from backend.app.lineage_ingestion import records_from_source_posts +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from lineageweave.adjudication_client import AdjudicationClient -from lineageweave.http_client import HttpClientError, post_json +from lineageweave.http_client import post_json from lineageweave.lineage_persistence import lineage_edge_specs from lineageweave.models import Edge from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable @@ -103,8 +103,8 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]: try: headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} return post_json(url, payload, headers=headers, timeout=30.0) - except (HttpClientError, OSError, ValueError, TypeError) as exc: - raise TeppNotAvailable(str(exc)) from exc + except Exception as exc: + raise TeppNotAvailable("TEPP transport request failed") from exc return TeppClient(transport=transport) @@ -119,12 +119,12 @@ def tepp_run_request( """Build TEPP's published request from the frozen run, never a theta.""" cutoff = knowledge_cutoff if cutoff.tzinfo is None: - cutoff = cutoff.replace(tzinfo=timezone.utc) + cutoff = cutoff.replace(tzinfo=UTC) return AnalysisRunRequest( idempotency_key=idempotency_key, tenant_workspace_id=str(corporate_entity_id), snapshot_id=snapshot_sha256, - knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + knowledge_cutoff=cutoff.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), model_contract_version=_TEPP_MODEL_CONTRACT, output_profile=_TEPP_OUTPUT_PROFILE, ) @@ -610,7 +610,7 @@ async def deliver_queued_analysis_run( return await _visible_or_404( conn, analysis_run_id, account_id, affiliated_entity_ids ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) try: if not latest_outbox_delivery_is_claimed(latest): await _append_outbox_delivery( @@ -636,9 +636,8 @@ async def deliver_queued_analysis_run( affiliated_entity_ids=affiliated_entity_ids, adjudication_client=adjudication_client, ) - finished = datetime.now(timezone.utc) - if finished < now: - finished = now + finished = datetime.now(UTC) + finished = max(finished, now) await _append_outbox_delivery( conn, analysis_run_id, @@ -699,7 +698,7 @@ async def _deliver_lineage_reconstruction( adjudication_client: AdjudicationClient | None = None, ) -> None: """Persist ThreadWeave parent choices for the frozen bag.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) member_rows = await _snapshot_member_posts( conn, locked["analysis_source_snapshot_id"], @@ -715,9 +714,8 @@ async def _deliver_lineage_reconstruction( ) edges = lineage_edge_specs(records_from_source_posts(rows), llm=adjudication_client) digest = reconstruction_result_digest(edges) - finished = datetime.now(timezone.utc) - if finished < now: - finished = now + finished = datetime.now(UTC) + finished = max(finished, now) await conn.execute( """ insert into analysis_run_reconstruction @@ -759,7 +757,7 @@ async def _deliver_tepp_measurement( tepp_client: TeppClient, ) -> None: """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) request = tepp_run_request( idempotency_key=str(locked["idempotency_key"]), snapshot_sha256=str(locked["snapshot_sha256"]), @@ -767,17 +765,15 @@ async def _deliver_tepp_measurement( corporate_entity_id=str(locked["corporate_entity_id"]), ) status_code, failure_code, envelope = _tepp_submission(tepp_client, request) - if status_code == _SUCCEEDED and envelope is not None: - if not await _persist_tepp_result( - conn, - analysis_run_id=analysis_run_id, - envelope=envelope, - ): - status_code = _FAILED - failure_code = "tepp_result_not_persisted" - finished = datetime.now(timezone.utc) - if finished < now: - finished = now + if status_code == _SUCCEEDED and envelope is not None and not await _persist_tepp_result( + conn, + analysis_run_id=analysis_run_id, + envelope=envelope, + ): + status_code = _FAILED + failure_code = "tepp_result_not_persisted" + finished = datetime.now(UTC) + finished = max(finished, now) await _append_status( conn, analysis_run_id, diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index cb48d2cbe..72e996fb2 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -18,8 +18,9 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any class TeppNotAvailable(RuntimeError): @@ -98,4 +99,9 @@ def __init__(self, transport: Callable[[dict[str, Any]], dict[str, Any]] = _no_t def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, Any]: """Submit a request; returns TEPP's ``AnalysisRunAccepted`` envelope.""" - return self._transport(request.to_json()) + try: + return self._transport(request.to_json()) + except TeppNotAvailable: + raise + except Exception as exc: + raise TeppNotAvailable("TEPP transport request failed") from exc diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index a6a805faa..aa6b7c433 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -83,6 +83,18 @@ def fake_transport(payload: dict) -> dict: assert received["snapshot_id"] == "demo-snapshot-1" +def test_custom_transport_provider_errors_are_not_exposed() -> None: + """Provider response text stays behind the stable unavailable error.""" + + def broken_transport(_payload: dict) -> dict: + raise RuntimeError("provider secret response body") + + with pytest.raises(TeppNotAvailable, match="transport request failed") as error: + TeppClient(transport=broken_transport).submit_analysis_run(_sample_request()) + + assert "provider secret" not in str(error.value) + + def test_configured_transport_sends_optional_bearer_key(monkeypatch: pytest.MonkeyPatch) -> None: received = {} @@ -97,3 +109,22 @@ def fake_post_json(url: str, payload: dict, *, headers: dict, timeout: float) -> assert received["headers"] == {"authorization": "Bearer test-key"} assert received["payload"] == _sample_request().to_json() + + +def test_configured_transport_provider_errors_are_not_exposed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The configured provider boundary does not return raw transport text.""" + + def broken_post_json(*args, **kwargs): + del args, kwargs + raise RuntimeError("provider secret response body") + + monkeypatch.setattr("backend.app.analysis_run_start.post_json", broken_post_json) + + with pytest.raises(TeppNotAvailable, match="transport request failed") as error: + configured_tepp_client("https://tepp.example/v1/analysis-runs").submit_analysis_run( + _sample_request() + ) + + assert "provider secret" not in str(error.value) From 553cf4947425ac9328e468157755c5ceadcfa529 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:01:00 +0900 Subject: [PATCH 7/9] fix: require complete external organization evidence --- lineageweave/relation_verification.py | 51 +++++++++++++++++++-------- tests/test_relation_verification.py | 42 ++++++++++++++++++---- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index c67fd31e2..0dc58c373 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -108,7 +108,9 @@ class RelationVerificationClient(Protocol): available: bool - def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + def verify( + self, organization_name: str, relationship_label: str + ) -> RelationVerificationResult: """Search for corroborating evidence of ``organization_name`` having the relationship ``relationship_label`` describes. @@ -127,7 +129,9 @@ class NullRelationVerificationClient: available = False - def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: # pragma: no cover + def verify( + self, organization_name: str, relationship_label: str + ) -> RelationVerificationResult: # pragma: no cover """Verify whether the relationship has supporting external evidence.""" raise RuntimeError( "NullRelationVerificationClient has no search channel; check .available first" @@ -154,11 +158,15 @@ class SearxngRelationVerificationClient: 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'}") + raise ValueError( + f"unsupported Searxng base URL scheme: {parsed.scheme or 'missing'}" + ) self._base_url = base_url.rstrip("/") self._timeout = timeout - def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + def verify( + self, organization_name: str, relationship_label: str + ) -> RelationVerificationResult: """Verify whether the relationship has supporting external evidence.""" query = f"{organization_name} {relationship_label}" body = get_json( @@ -167,33 +175,46 @@ def verify(self, organization_name: str, relationship_label: str) -> RelationVer ) results = body.get("results") if not isinstance(results, list): - return RelationVerificationResult(status_code=STATUS_UNCORROBORATED, evidence_url=None) + return RelationVerificationResult( + status_code=STATUS_UNCORROBORATED, evidence_url=None + ) for result in results: if not isinstance(result, dict): continue evidence_url = corroborating_evidence_url(organization_name, result) if evidence_url is not None: - return RelationVerificationResult(status_code=STATUS_CORROBORATED, evidence_url=evidence_url) - return RelationVerificationResult(status_code=STATUS_UNCORROBORATED, evidence_url=None) + return RelationVerificationResult( + status_code=STATUS_CORROBORATED, evidence_url=evidence_url + ) + return RelationVerificationResult( + status_code=STATUS_UNCORROBORATED, evidence_url=None + ) -def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) -> str | None: +def corroborating_evidence_url( + organization_name: str, result: dict[str, Any] +) -> str | None: """Return ``result['url']`` when it is a real-world footprint of ``organization_name``. Search engines echo the query in result titles, so "any hit" is not corroboration. A single distinctive token is not enough either -- an invented name can still contain an ordinary dictionary word (e.g. "Fictitious", "Nonexistent") that coincidentally appears on an - unrelated page, so a genuine multi-token name requires a majority of - its tokens to co-occur in the same result; a one-token name has no - majority to require and falls back to that single token. The host - must also not itself be a search page. Missing or empty URLs are not - evidence. + unrelated page, so every distinctive token in a multi-token name must + co-occur in the same result. A one-token name falls back to that single + token. The host must also not itself be a search page. Missing or empty + URLs are not evidence. """ url = result.get("url") if not isinstance(url, str) or not url.strip(): return None - host = urlparse(url).netloc.lower() + try: + parsed_url = urlparse(url) + host = (parsed_url.hostname or "").lower() + except ValueError: + return None + if parsed_url.scheme not in {"http", "https"}: + return None if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS): return None tokens = [ @@ -207,6 +228,6 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - # Substring matches turn ``Alpha`` into a false hit for ``alphabetical``. # Word boundaries keep host labels, punctuation, and Hangul names usable # without accepting a token embedded inside an unrelated word. - if any(_contains_org_token(token, haystack) for token in tokens): + if all(_contains_org_token(token, haystack) for token in tokens): return url return None diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index d08d14374..94add86fc 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -18,10 +18,10 @@ import pytest from lineageweave.relation_verification import ( - RelationVerificationClient, STATUS_CORROBORATED, STATUS_UNCORROBORATED, NullRelationVerificationClient, + RelationVerificationClient, SearxngRelationVerificationClient, corroborating_evidence_url, ) @@ -30,7 +30,7 @@ class _ResultsHandler(BaseHTTPRequestHandler): received_query: str = "" - def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API + def do_GET(self) -> None: parsed = urlparse(self.path) query = parse_qs(parsed.query) type(self).received_query = query.get("q", [""])[0] @@ -63,7 +63,7 @@ def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + def log_message(self, format: str, *args) -> None: return @@ -102,11 +102,15 @@ def test_searxng_client_reports_corroborated_with_evidence_url() -> None: assert "Voice of Customer" in _ResultsHandler.received_query -def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_is_empty() -> None: +def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_is_empty() -> ( + None +): server, base = _serve() try: client = SearxngRelationVerificationClient(base_url=base) - result = client.verify("Totally Fictitious Nonexistent Org", "Voice of Customer") + result = client.verify( + "Totally Fictitious Nonexistent Org", "Voice of Customer" + ) finally: server.shutdown() @@ -115,7 +119,9 @@ def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_ @pytest.mark.parametrize("organization_name", ["NoList", "Skip", "NoEvidence"]) -def test_searxng_client_fails_closed_for_unusable_results(organization_name: str) -> None: +def test_searxng_client_fails_closed_for_unusable_results( + organization_name: str, +) -> None: """Malformed and unciting search results remain explicitly uncorroborated.""" server, base = _serve() try: @@ -206,6 +212,30 @@ def test_missing_url_and_all_generic_tokens_are_not_evidence() -> None: ) +def test_one_common_token_is_not_multi_token_corroboration() -> None: + """An unrelated page mentioning one name token is insufficient evidence.""" + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://news.example/item", + "content": "The power outage affected the region.", + }, + ) + is None + ) + + +@pytest.mark.parametrize( + "url", ["file://acme.example/item", "javascript://acme.example/item"] +) +def test_non_http_evidence_url_is_not_accepted(url: str) -> None: + """Evidence links must be browser-safe HTTP(S) resources.""" + assert ( + corroborating_evidence_url("Acme Corp", {"url": url, "content": "Acme"}) is None + ) + + @pytest.mark.parametrize("particle", ["가", "에서", "으로"]) def test_hangul_org_name_with_attached_particle_is_corroboration(particle: str) -> None: assert ( From cc203bdbb004cac801f1e3fff8713140943ac7e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:10:08 +0900 Subject: [PATCH 8/9] fix: accept bounded Korean particle sequences --- lineageweave/relation_verification.py | 2 +- tests/test_relation_verification.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 0dc58c373..22c66cd24 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -81,7 +81,7 @@ def _contains_org_token(token: str, haystack: str) -> bool: """Match one organization token without breaking Korean particle syntax.""" if re.search(r"[가-힣]", token): - pattern = rf"(? None: + """Stacked Korean particles after a complete name remain bounded evidence.""" + assert ( + corroborating_evidence_url( + "한빛그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": f"한빛그리드{particles} 발표했다.", + }, + ) + == "https://news.example/item" + ) + + def test_hangul_org_name_inside_a_larger_word_is_not_corroboration() -> None: """A company token must not match an unrelated longer Hangul word.""" assert ( From eddd995db1f31db724075cf51be1ed88aec74517 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:19:21 +0900 Subject: [PATCH 9/9] fix: accept Korean particles after Latin org names --- lineageweave/relation_verification.py | 5 +---- tests/test_relation_verification.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 22c66cd24..a377e47b1 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -80,10 +80,7 @@ def _contains_org_token(token: str, haystack: str) -> bool: """Match one organization token without breaking Korean particle syntax.""" - if re.search(r"[가-힣]", token): - pattern = rf"(? None: + """Latin organization tokens also accept directly attached particles.""" + assert ( + corroborating_evidence_url( + "Acme", + { + "url": "https://news.example/item", + "title": "News", + "content": f"Acme{particle} 발표했다.", + }, + ) + == "https://news.example/item" + ) + + def test_hangul_org_name_inside_a_larger_word_is_not_corroboration() -> None: """A company token must not match an unrelated longer Hangul word.""" assert (