From 8a6c807524977f29b5f3dad23150fc31270a8d05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:33:18 +0000 Subject: [PATCH] feat: show Keyverse identity fail-closed (v0.76.0) After login, Identity names keyverse_not_available when the port is down and names readiness when Keyverse healthz returns status ok. Never invent an issuer, account, or token. Demo login stays on synthetic Keycloak. Independent exact-head APPROVE required. Do not mix into #74 or #92. --- .env.example | 5 + AGENTS.md | 3 + ARCHITECTURE.md | 6 + .../0.76.0-keyverse-identity-fail-closed.md | 9 ++ CHANGELOG.md | 10 ++ README.md | 6 +- backend/app/config.py | 4 + backend/app/main.py | 17 +++ backend/tests/test_config.py | 8 ++ .../adr/0025-keyverse-identity-fail-closed.md | 51 ++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 44 +++++++ frontend/src/App.tsx | 41 ++++++ frontend/src/api.ts | 11 ++ lineageweave/__init__.py | 2 +- lineageweave/keyverse_client.py | 116 +++++++++++++++++ pyproject.toml | 2 +- tests/test_keyverse_client.py | 119 ++++++++++++++++++ uv.lock | 2 +- 19 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/0.76.0-keyverse-identity-fail-closed.md create mode 100644 docs/adr/0025-keyverse-identity-fail-closed.md create mode 100644 lineageweave/keyverse_client.py create mode 100644 tests/test_keyverse_client.py diff --git a/.env.example b/.env.example index 03b0fb45e..7048c2419 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,8 @@ BACKEND_PORT=18420 ORCHESTRATOR_BASE_URL= ORCHESTRATOR_API_KEY= VISION_MODEL= + +# Optional. Empty = Keyverse identity port is unavailable +# (KeyverseNotAvailable, never an invented issuer or account). Point at +# a running Keyverse admin-service host to probe GET /healthz (ADR 0025). +KEYVERSE_BASE_URL= diff --git a/AGENTS.md b/AGENTS.md index c790995c1..f10df2a63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,9 @@ reimplementing them: - [TEPP](https://github.com/ContextualWisdomLab/TEPP)'s published wire contract for calibrated measurement (`tepp_client.py`) -- never reimplement TEPP's model here. +- [Keyverse](https://github.com/ContextualWisdomLab/keyverse) for the + ecosystem IdP readiness port (`keyverse_client.py`) -- never invent + an issuer, account, or token. Demo login stays on synthetic Keycloak. - [contextual-orchestrator](https://github.com/ContextualWisdomLab/contextual-orchestrator) for LLM adjudication (`adjudication_client.py`) -- never call a raw LLM API directly from this repo; go through the orchestrator so diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a83ceb1..16578f4ad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,6 +67,7 @@ flowchart LR | `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) | +| `keyverse_client.py` | Fail-closed Keyverse identity port (`GET /healthz`; never invent an issuer, account, or token) | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) | | `knowledge_graph.py` | Random-walk-with-restart relevance + per-node adaptive related-node cutoff (Tong et al., 2006) -- pure graph math, no Postgres | @@ -123,6 +124,11 @@ flowchart LR `RankWeaveNotAvailable`. `GET /api/rankings` then returns `rankweave_not_available` and an empty ranking list. Hidden posts are omitted from every channel. See ADR 0024. +- **Keyverse is a published healthz, not a second login.** + `keyverse_client.py`'s default transport raises + `KeyverseNotAvailable`. `GET /api/identity` then returns + `keyverse_not_available` and `ready=false`. Demo login stays on + the synthetic Keycloak realm. See ADR 0025. ## Standards and citations diff --git a/CHANGELOG.d/0.76.0-keyverse-identity-fail-closed.md b/CHANGELOG.d/0.76.0-keyverse-identity-fail-closed.md new file mode 100644 index 000000000..d926a5bb4 --- /dev/null +++ b/CHANGELOG.d/0.76.0-keyverse-identity-fail-closed.md @@ -0,0 +1,9 @@ +# 0.76.0 — Fail-closed Keyverse identity + +## Added + +- Home Identity panel probes Keyverse through `KeyverseClient`. + After login with the port unconfigured or healthz down, Demo + Analyst sees **Identity · Keyverse not available**. An accepted + probe names readiness only. Never invent an issuer, account, or + token. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28f..aebfd0df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.76.0] - 2026-08-17 + +### Added + +- Home Identity panel probes Keyverse through `KeyverseClient` + (ADR 0025). After login with the port unconfigured or healthz + down, Demo Analyst sees **Identity · Keyverse not available**. + An accepted probe names readiness only. Never invent an issuer, + account, or token. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/README.md b/README.md index a3626d9f2..fdac95224 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,11 @@ The optional LLM-adjudication channel calls [ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) (JWZ message threading) and channel fusion reuses [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) (weighted -score fusion for reconstruction and the fail-closed Rankings port) -- both real dependencies, not reimplemented here. +score fusion for reconstruction and the fail-closed Rankings port) -- +both real dependencies, not reimplemented here. The fail-closed Identity +port probes [Keyverse](https://github.com/ContextualWisdomLab/keyverse) +healthz and never invents an issuer or account -- demo login stays on +synthetic Keycloak. ## Run it diff --git a/backend/app/config.py b/backend/app/config.py index 68cad3439..11360b23a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -52,6 +52,9 @@ class Settings: # RankWeaveNotAvailable -- never invent a fused score. Default false # uses the in-process library already required by reconstruct.py. rankweave_disabled: bool + # Keyverse identity port (ADR 0025). Empty = fail-closed + # KeyverseNotAvailable -- never invent an issuer or account. + keyverse_base_url: str @property def keycloak_jwks_uri(self) -> str: @@ -88,4 +91,5 @@ def load_settings() -> Settings: .strip() .lower() in {"1", "true", "yes", "on"}, + keyverse_base_url=os.environ.get("KEYVERSE_BASE_URL", ""), ) diff --git a/backend/app/main.py b/backend/app/main.py index 27f679119..0d7d16a08 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -55,6 +55,7 @@ from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient from lineageweave.rankweave_client import build_rankweave_client +from lineageweave.keyverse_client import build_keyverse_client from backend.app.activity_stream import ( create_valkey_client, @@ -242,6 +243,11 @@ def _rankweave_client(): return build_rankweave_client(disabled=load_settings().rankweave_disabled) +def _keyverse_client(): + """Live Keyverse healthz client when configured; otherwise fail-closed.""" + return build_keyverse_client(base_url=load_settings().keyverse_base_url) + + def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool: """ABAC: public rows are visible; private rows require same-corp affiliation.""" if post["visibility_code"] == "public": @@ -1150,3 +1156,14 @@ async def read_rankings( return _rankweave_client().as_api_payload( posts, can_see_post=lambda _row: True ) +@app.get("/api/identity") +async def read_identity( + account: CurrentAccount = Depends(get_current_account), +) -> dict[str, Any]: + """Keyverse readiness (ADR 0025). + + Never invents an issuer, account, or token. Fail-closed when + Keyverse is unconfigured or healthz is down. + """ + _require_post_read(account) + return _keyverse_client().as_api_payload() diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index c2f3994d5..2266b299e 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -31,3 +31,11 @@ def test_rankweave_disabled_defaults_off(monkeypatch) -> None: def test_rankweave_disabled_flag_is_opt_in(monkeypatch) -> None: monkeypatch.setenv("RANKWEAVE_DISABLED", "1") assert load_settings().rankweave_disabled is True +def test_keyverse_base_url_defaults_empty(monkeypatch) -> None: + monkeypatch.delenv("KEYVERSE_BASE_URL", raising=False) + assert load_settings().keyverse_base_url == "" + + +def test_keyverse_base_url_is_opt_in(monkeypatch) -> None: + monkeypatch.setenv("KEYVERSE_BASE_URL", "https://keyverse.example") + assert load_settings().keyverse_base_url == "https://keyverse.example" diff --git a/docs/adr/0025-keyverse-identity-fail-closed.md b/docs/adr/0025-keyverse-identity-fail-closed.md new file mode 100644 index 000000000..731d0e60c --- /dev/null +++ b/docs/adr/0025-keyverse-identity-fail-closed.md @@ -0,0 +1,51 @@ +# ADR 0025 — Fail-closed Keyverse identity port + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +LineageWeave's demo login is a local Keycloak realm with synthetic +accounts (ADR 0001). The organization's production IdP is +[Keyverse](https://github.com/ContextualWisdomLab/keyverse): passwordless +OIDC on Keycloak plus an account-unification admin service that +publishes `GET /healthz` as `{status: "ok"}`. Until this slice, Demo +Analyst had no buyer-facing identity-port status: a down Keyverse was +silent, and nothing stopped a later writer from inventing an issuer, +account, or corp code. + +This ADR does not replace the synthetic demo Keycloak login, does not +register LineageWeave as a production relying party, and does not bind +demo tokens to a production Keyverse tenant. + +## Decision + +1. Consume Keyverse only through `KeyverseClient` and the published + `GET /healthz` envelope. Never read Keyverse tables. Never copy an + issuer, account, token, or client registration. +2. The default transport raises `KeyverseNotAvailable`. HTTP 4xx/5xx, + timeout, network, non-https, and an unknown envelope fail closed. +3. Project only `ready=true` when `status` is exactly `ok`. Extra + healthz fields are dropped. +4. `GET /api/identity` (`post_read`) returns `unavailable` + + `keyverse_not_available` + `ready=false` when the port is down. +5. After login, Identity sits above Calendar. Unavailable copy is + **Identity · Keyverse not available**. An accepted probe names + readiness only — click does not invent a login. + +## Consequences + +`KEYVERSE_BASE_URL` empty keeps the fail-closed transport. Demo login +stays on the synthetic Keycloak realm. Rankings stay on ADR 0024 / +#220. Mailbox stays on ADR 0020 / #217. Conversations stay on ADR 0021 +/ #219. TEPP stays on #214. Registering LineageWeave as a Keyverse RP +is a later slice. + +## References + +Contextual Wisdom Lab. (2026). *cwl-idp — ecosystem central IdP* +[Software documentation]. https://github.com/ContextualWisdomLab/keyverse + +Contextual Wisdom Lab. (2026). *Relying-party onboarding* [Keyverse +documentation]. +https://github.com/ContextualWisdomLab/keyverse/blob/main/docs/rp-onboarding.md diff --git a/frontend/package.json b/frontend/package.json index 575b7c586..9acf4850d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.76.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a26403..1c87659d1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -65,6 +65,11 @@ describe("App, authenticated", () => { fused_rank: number; }[]; }; + identity?: { + status?: "accepted" | "unavailable"; + status_reason?: string | null; + ready?: boolean; + }; chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; @@ -178,6 +183,21 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/identity")) { + const identity = options?.identity ?? { + status: "unavailable" as const, + status_reason: "keyverse_not_available", + ready: false, + }; + return Promise.resolve( + jsonResponse({ + port: "keyverse", + status: identity.status, + status_reason: identity.status_reason, + ready: identity.ready ?? false, + }), + ); + } if (url.endsWith("/api/calendar")) { return Promise.resolve( jsonResponse({ @@ -1307,6 +1327,30 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("names Keyverse unavailability on home identity instead of inventing an account", async () => { + stubBackend(); + render(); + + expect(await screen.findByText("Identity · Keyverse not available")).toBeInTheDocument(); + expect(screen.queryByText("Keyverse admin service is ready.")).not.toBeInTheDocument(); + }); + + it("names accepted Keyverse readiness without inventing an issuer", async () => { + stubBackend({ + identity: { + status: "accepted", + status_reason: null, + ready: true, + }, + }); + render(); + + expect(await screen.findByText("Keyverse admin service is ready.")).toBeInTheDocument(); + expect(screen.getByText("Identity · keyverse")).toBeInTheDocument(); + expect(screen.queryByText(/issuer/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /keyverse/i })).not.toBeInTheDocument(); + }); + it("shows upcoming commitments on the home page calendar and opens the post on click", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb4..81c75242c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { fetchPeriodReportIndex, fetchPeriodReports, fetchPosts, + fetchIdentity, fetchRankings, fetchRelatedEntity, fetchRelatedKeymen, @@ -50,6 +51,7 @@ import { type PeriodReports, type PostLineage, type PostSummary, + type IdentityStatus, type RankingList, type RelatedNode, type VocEvidence, @@ -1371,6 +1373,44 @@ function RankingsPanel({ ); } +function IdentityPanel({ accessToken }: { accessToken: string }) { + const [identity, setIdentity] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setError(null); + fetchIdentity(accessToken) + .then(setIdentity) + .catch((err) => setError(String(err))); + }, [accessToken]); + + return ( +
+
+

Identity

+ {identity && ( + + {identity.status === "accepted" + ? "keyverse" + : `keyverse · ${identity.status_reason ?? "unavailable"}`} + + )} +
+ {error &&

{error}

} + {identity === null && !error &&

Loading identity...

} + {identity && identity.status === "unavailable" && ( +

Identity · Keyverse not available

+ )} + {identity && identity.status === "accepted" && identity.ready && ( +

+ Keyverse admin service is ready.{" "} + Identity · keyverse +

+ )} +
+ ); +} + function CalendarPanel({ accessToken, onSelectPost, @@ -1690,6 +1730,7 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e6dfcbad2..1bbfbd3c9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -515,3 +515,14 @@ export interface RankingList { export function fetchRankings(accessToken: string): Promise { return backendFetch("/api/rankings", accessToken); } + +export interface IdentityStatus { + port: string; + status: "accepted" | "unavailable"; + status_reason: string | null; + ready: boolean; +} + +export function fetchIdentity(accessToken: string): Promise { + return backendFetch("/api/identity", accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009e..691380be0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.76.0" diff --git a/lineageweave/keyverse_client.py b/lineageweave/keyverse_client.py new file mode 100644 index 000000000..eb61a8def --- /dev/null +++ b/lineageweave/keyverse_client.py @@ -0,0 +1,116 @@ +"""Fail-closed adapter for Keyverse's published admin-service healthz. + +`Keyverse `_ is the +ecosystem IdP (passwordless OIDC on Keycloak plus an account-unification +admin service). LineageWeave consumes only the published +``GET /healthz`` envelope (``{"status": "ok"}``) and never invents an +issuer, account, corp code, token, or client registration. + +The default transport raises :class:`KeyverseNotAvailable` so a missing +Keyverse port is fail-closed, the same discipline as +:class:`lineageweave.tepp_client.TeppNotAvailable`. Wiring a live +HTTP(S) base URL is additive (``HttpKeyverseTransport``), not a +redesign. This port does not replace the synthetic demo Keycloak login. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from lineageweave.http_client import HttpClientError, get_json + + +class KeyverseNotAvailable(RuntimeError): + """Raised when the Keyverse identity port is down or unconfigured.""" + + reason = "keyverse_not_available" + + +def _no_transport() -> dict[str, Any]: + raise KeyverseNotAvailable( + "keyverse_not_available: Keyverse identity HTTP is not configured. " + "Pass KEYVERSE_BASE_URL or a transport= callable. Never invent an identity." + ) + + +@dataclass(frozen=True) +class IdentityReady: + """Accepted Keyverse readiness. No issuer, account, or token.""" + + ready: bool + + def to_json(self) -> dict[str, Any]: + return {"ready": self.ready} + + +def parse_healthz(payload: object) -> IdentityReady: + """Accept Keyverse's published ``{status: "ok"}`` envelope. + + Unknown envelopes fail closed. Extra fields are dropped, never copied + as an issuer or account. + """ + if not isinstance(payload, dict) or payload.get("status") != "ok": + raise KeyverseNotAvailable( + "keyverse_not_available: healthz envelope is not the published {status: ok} shape" + ) + return IdentityReady(ready=True) + + +class HttpKeyverseTransport: + """GET ``{base_url}/healthz`` through the http(s)-only client.""" + + def __init__(self, base_url: str, timeout: float = 5.0) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def __call__(self) -> dict[str, Any]: + try: + payload = get_json( + f"{self.base_url}/healthz", + timeout=self.timeout, + ) + except (HttpClientError, OSError, TimeoutError, ValueError) as exc: + raise KeyverseNotAvailable( + f"keyverse_not_available: identity HTTP failed ({exc})" + ) from exc + if not isinstance(payload, dict): + raise KeyverseNotAvailable( + "keyverse_not_available: healthz HTTP did not return a JSON object" + ) + return payload + + +def build_keyverse_client(base_url: str = "") -> "KeyverseClient": + """Empty base URL keeps the default fail-closed transport.""" + if not base_url.strip(): + return KeyverseClient() + return KeyverseClient(transport=HttpKeyverseTransport(base_url=base_url)) + + +class KeyverseClient: + """Probes Keyverse readiness through a pluggable transport.""" + + def __init__(self, transport: Callable[[], dict[str, Any]] = _no_transport) -> None: + self._transport = transport + + def probe_ready(self) -> IdentityReady: + return parse_healthz(self._transport()) + + def as_api_payload(self) -> dict[str, Any]: + """Buyer-visible identity status. Never invents an identity.""" + try: + identity = self.probe_ready() + except KeyverseNotAvailable: + return { + "port": "keyverse", + "status": "unavailable", + "status_reason": KeyverseNotAvailable.reason, + "ready": False, + } + return { + "port": "keyverse", + "status": "accepted", + "status_reason": None, + "ready": identity.ready, + } diff --git a/pyproject.toml b/pyproject.toml index 764ebad72..b10fc3bb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.76.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_keyverse_client.py b/tests/test_keyverse_client.py new file mode 100644 index 000000000..8d88f59f4 --- /dev/null +++ b/tests/test_keyverse_client.py @@ -0,0 +1,119 @@ +"""Fail-closed Keyverse identity port. + +Keyverse is the ecosystem IdP. LineageWeave consumes only the published +healthz envelope (GET /healthz) and never invents an issuer, account, +or token. +""" + +from __future__ import annotations + +import pytest + +from lineageweave.http_client import HttpClientError +from lineageweave.keyverse_client import ( + HttpKeyverseTransport, + KeyverseClient, + KeyverseNotAvailable, + parse_healthz, +) + + +def test_default_transport_fails_closed() -> None: + client = KeyverseClient() + with pytest.raises(KeyverseNotAvailable, match="keyverse_not_available"): + client.probe_ready() + + +def test_default_payload_never_invents_an_identity() -> None: + payload = KeyverseClient().as_api_payload() + + assert payload == { + "port": "keyverse", + "status": "unavailable", + "status_reason": "keyverse_not_available", + "ready": False, + } + assert "issuer" not in payload + assert "accounts" not in payload + assert "token" not in payload + + +def test_empty_base_url_factory_fails_closed() -> None: + client = build_client("") + payload = client.as_api_payload() + assert payload["status"] == "unavailable" + assert payload["ready"] is False + + +def build_client(base_url: str) -> KeyverseClient: + from lineageweave.keyverse_client import build_keyverse_client + + return build_keyverse_client(base_url=base_url) + + +def test_parse_healthz_accepts_published_ok() -> None: + identity = parse_healthz({"status": "ok", "issuer": "must-not-be-copied"}) + assert identity.ready is True + assert not hasattr(identity, "issuer") + + +def test_parse_healthz_rejects_unknown_envelope() -> None: + with pytest.raises(KeyverseNotAvailable, match="keyverse_not_available"): + parse_healthz({"ready": True}) + + +def test_parse_healthz_rejects_non_ok_status() -> None: + with pytest.raises(KeyverseNotAvailable, match="keyverse_not_available"): + parse_healthz({"status": "degraded"}) + + +def test_injected_transport_returns_accepted_ready() -> None: + payload = KeyverseClient(transport=lambda: {"status": "ok"}).as_api_payload() + + assert payload["status"] == "accepted" + assert payload["status_reason"] is None + assert payload["ready"] is True + assert "issuer" not in payload + + +def test_http_transport_gets_published_healthz_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def fake_get_json(url: str, *, headers: dict[str, str] | None = None, timeout: float) -> dict: + captured["url"] = url + captured["headers"] = headers + captured["timeout"] = timeout + return {"status": "ok"} + + monkeypatch.setattr("lineageweave.keyverse_client.get_json", fake_get_json) + transport = HttpKeyverseTransport(base_url="https://keyverse.example") + identity = KeyverseClient(transport=transport).probe_ready() + + assert captured["url"] == "https://keyverse.example/healthz" + assert identity.ready is True + + +@pytest.mark.parametrize( + "error", + [ + HttpClientError("HTTP 503 from keyverse.example"), + HttpClientError("HTTP 404 from keyverse.example"), + TimeoutError("timed out"), + OSError("network down"), + ValueError("refusing non-http(s) URL scheme: 'file'"), + ], +) +def test_http_transport_fail_closed_on_transport_errors( + monkeypatch: pytest.MonkeyPatch, error: Exception +) -> None: + def boom(*_args: object, **_kwargs: object) -> dict: + raise error + + monkeypatch.setattr("lineageweave.keyverse_client.get_json", boom) + client = KeyverseClient(transport=HttpKeyverseTransport(base_url="https://keyverse.example")) + with pytest.raises(KeyverseNotAvailable, match="keyverse_not_available"): + client.probe_ready() + assert client.as_api_payload()["ready"] is False + assert client.as_api_payload()["status"] == "unavailable" diff --git a/uv.lock b/uv.lock index 08eab7768..9de5184c3 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.76.0" source = { virtual = "." } dependencies = [ { name = "certifi" },