diff --git a/AGENTS.md b/AGENTS.md index c790995c..98c9b996 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,12 @@ reimplementing them: contract for calibrated measurement (`tepp_client.py`) -- never reimplement TEPP's model here. - [contextual-orchestrator](https://github.com/ContextualWisdomLab/contextual-orchestrator) - for LLM adjudication (`adjudication_client.py`) -- never call a raw LLM + for LLM adjudication (`adjudication_client.py`) and the buyer-facing + Orchestration port (`orchestrator_client.py`) -- never call a raw LLM API directly from this repo; go through the orchestrator so reasoning-effort allocation and cost attribution stay centralized. + A missing host or `invalid_mode` is fail-closed, never an invented + completion or a theta. Before adding a new dependency, check whether an existing org repo already does it (`gh repo list ContextualWisdomLab`). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a83ceb..a142d6f2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -66,6 +66,7 @@ flowchart LR | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | | `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 | +| `orchestrator_client.py` | Fail-closed portable task envelope (`auto` structured / `verify` checked; never invent a completion or a theta) | | `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) | | `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) | @@ -89,12 +90,11 @@ flowchart LR > `TaskOrchestrator.route_and_verify`, which as of this writing is still > an open, unmerged upstream PR > (`ContextualWisdomLab/contextual-orchestrator#149`). Until it merges, -> the four adjudication/chat tests that exercise `mode="verify"` against -> a real orchestrator fail with `invalid_mode` (the deployed `main` only -> accepts `auto`/`route`/`conduct`) -- confirmed by reproducing the same -> `400` directly against the orchestrator's own `/v1/chat/completions`, -> not caused by anything in this repo. `mode="route"` (every other -> pluggable client) is unaffected. +> a live `verify` submit can return `invalid_mode` (deployed `main` +> accepts `auto`/`route`/`conduct`). The buyer-facing Orchestration port +> (ADR 0025) fail-closes that as `orchestrator_invalid_mode` and never +> invents a completion or a 0.0 confidence. `mode="auto"` structured +> consumers are unaffected. ## Design decisions worth naming @@ -123,6 +123,13 @@ 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. +- **contextual-orchestrator is a fail-closed envelope, not a raw LLM.** + `orchestrator_client.py`'s default transport raises + `OrchestratorNotAvailable`. `GET /api/orchestration` then returns + `orchestrator_not_available` and an empty envelope list. Home GET + never POSTs a completion. `invalid_mode` is + `orchestrator_invalid_mode`, never a fabricated 0.0 confidence. + See ADR 0025. ## Standards and citations diff --git a/CHANGELOG.d/0.84.0-orchestrator-fail-closed-envelope.md b/CHANGELOG.d/0.84.0-orchestrator-fail-closed-envelope.md new file mode 100644 index 00000000..a85da4ee --- /dev/null +++ b/CHANGELOG.d/0.84.0-orchestrator-fail-closed-envelope.md @@ -0,0 +1,10 @@ +# 0.84.0 — Fail-closed contextual-orchestrator envelope + +## Added + +- Home Orchestration panel publishes the portable task envelope + (ADR 0025). After login with the host unset, Demo Analyst sees + **Orchestration · contextual-orchestrator not available**. When the + port is configured, the next actions are **Structured work uses auto** + and **Checked judgment uses verify**. Never invent a completion or a + theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28..4f201bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.84.0] - 2026-08-17 + +### Added + +- Home Orchestration panel publishes the portable task envelope + (ADR 0025). After login with the host unset, Demo Analyst sees + **Orchestration · contextual-orchestrator not available**. When the + port is configured, the next actions are **Structured work uses auto** + and **Checked judgment uses verify**. Never invent a completion or a + theta. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/README.md b/README.md index a3626d9f..249384d9 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ this repo. The optional LLM-adjudication channel calls [contextual-orchestrator](https://github.com/ContextualWisdomLab/contextual-orchestrator) -(`lineageweave/adjudication_client.py`). Tree assembly reuses +(`lineageweave/adjudication_client.py` and the fail-closed +Orchestration port in `orchestrator_client.py`). Tree assembly reuses [ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) (JWZ message threading) and channel fusion reuses [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) (weighted diff --git a/backend/app/main.py b/backend/app/main.py index 27f67911..3deb7539 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.orchestrator_client import build_orchestrator_client from backend.app.activity_stream import ( create_valkey_client, @@ -242,6 +243,15 @@ def _rankweave_client(): return build_rankweave_client(disabled=load_settings().rankweave_disabled) +def _orchestrator_status_client(): + """Fail-closed envelope unless URL and key are both set (ADR 0025).""" + settings = load_settings() + return build_orchestrator_client( + base_url=settings.orchestrator_base_url, + api_key=settings.orchestrator_api_key, + ) + + 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 +1160,16 @@ async def read_rankings( return _rankweave_client().as_api_payload( posts, can_see_post=lambda _row: True ) + + +@app.get("/api/orchestration") +async def read_orchestration( + account: CurrentAccount = Depends(get_current_account), +) -> dict[str, Any]: + """Portable contextual-orchestrator envelope (ADR 0025). + + Missing host or key is fail-closed. Never invents a completion, a + confidence, or a theta. Home GET does not POST a chat completion. + """ + _require_post_read(account) + return _orchestrator_status_client().as_api_payload() diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index c2f3994d..860f7db3 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_orchestrator_credentials_default_empty(monkeypatch) -> None: + monkeypatch.delenv("ORCHESTRATOR_BASE_URL", raising=False) + monkeypatch.delenv("ORCHESTRATOR_API_KEY", raising=False) + settings = load_settings() + assert settings.orchestrator_base_url == "" + assert settings.orchestrator_api_key == "" diff --git a/docs/adr/0025-orchestrator-fail-closed-envelope.md b/docs/adr/0025-orchestrator-fail-closed-envelope.md new file mode 100644 index 00000000..9d74f2dd --- /dev/null +++ b/docs/adr/0025-orchestrator-fail-closed-envelope.md @@ -0,0 +1,58 @@ +# ADR 0025 — Fail-closed contextual-orchestrator task envelope + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +Issue #79 requires a versioned fail-closed REST adapter until reviewed +upstream `contextual-orchestrator` main exposes the required portable +task envelope. Structured extraction already requests `auto` (ADR 0013). +Checked judgment (lineage adjudication, citation-bearing chat) still +requests `verify`. Deployed upstream `main` accepts +`auto` / `route` / `conduct`; `verify` can return `invalid_mode` +until `ContextualWisdomLab/contextual-orchestrator#149` lands. + +A missing host, a missing key, or `invalid_mode` must not become an +invented completion, a confidence of 0.0, or a TEPP theta. RankWeave +status stays on ADR 0024. TEPP stays on #214. This slice does not +bind demo Keycloak to production Keyverse. + +## Decision + +1. Consume the orchestrator only through `OrchestratorClient`. The + default transport raises `OrchestratorNotAvailable`. + `build_orchestrator_client` publishes the envelope only when both + `ORCHESTRATOR_BASE_URL` and `ORCHESTRATOR_API_KEY` are set. +2. The portable envelope is closed (`additionalProperties` stay out): + `contract_version`, `task_kind`, `mode`, `reasoning_effort`, + `prompt_hash`, `access_list`. Structured work uses `auto` / + `medium`. Checked judgment uses `verify` / `high`. +3. `GET /api/orchestration` (`post_read`) returns buyer status. Home + GET never POSTs `/v1/chat/completions`. An `invalid_mode` transport + error is `orchestrator_invalid_mode`, never a fabricated score. +4. After login, Orchestration sits above Rankings. Unavailable copy is + **Orchestration · contextual-orchestrator not available**. An + accepted row names **Structured work uses auto** and + **Checked judgment uses verify**. + +## Consequences + +Demo compose without orchestrator credentials fail-closes on home. +Wiring a live submit transport is additive. Adjudication still owns +pair-wise `verify` calls; this port does not invent their verdicts. + +## References + +Omidvar, H., & Akhlaghi, V. (2026). *A communication-theoretic +framework for LLM agents: Cost-aware adaptive reliability* [Preprint]. +arXiv. https://doi.org/10.48550/arXiv.2605.09121 + +Tang, Y., Cetin, E., Xu, J., Sun, Q., Nielsen, S., Richard, V., Goda, +H., Tymchenko, I., Nguyen, N., Lee, H., Ashiga, M., Kotyan, S., +Kuroki, S., & Clanuwat, T. (2026). *Sakana Fugu technical report* +[Technical report]. arXiv. https://doi.org/10.48550/arXiv.2606.21228 + +Contextual Wisdom Lab. (2026). *Contextual orchestrator* [Software +documentation]. +https://github.com/ContextualWisdomLab/contextual-orchestrator diff --git a/frontend/package.json b/frontend/package.json index 575b7c58..c21ed209 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.84.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a2640..e1a1d45b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -65,6 +65,16 @@ describe("App, authenticated", () => { fused_rank: number; }[]; }; + orchestration?: { + status?: "accepted" | "unavailable"; + status_reason?: string | null; + envelopes?: { + task_kind: string; + mode: string; + reasoning_effort: string; + next_action: string; + }[]; + }; chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; @@ -228,6 +238,21 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/orchestration")) { + const orchestration = options?.orchestration ?? { + status: "unavailable" as const, + status_reason: "orchestrator_not_available", + envelopes: [], + }; + return Promise.resolve( + jsonResponse({ + port: "contextual_orchestrator", + status: orchestration.status, + status_reason: orchestration.status_reason, + envelopes: orchestration.envelopes ?? [], + }), + ); + } if (url.includes("/api/reports/compare/") && method === "GET") { return Promise.resolve( jsonResponse({ @@ -1273,6 +1298,47 @@ describe("App, authenticated", () => { expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument(); }); + it("names orchestrator unavailability instead of inventing a completion", async () => { + stubBackend(); + render(); + + expect( + await screen.findByText("Orchestration · contextual-orchestrator not available"), + ).toBeInTheDocument(); + expect(screen.queryByText("Structured work uses auto")).not.toBeInTheDocument(); + expect(screen.queryByText("Checked judgment uses verify")).not.toBeInTheDocument(); + }); + + it("names the accepted auto and verify envelopes without inventing a completion", async () => { + stubBackend({ + orchestration: { + status: "accepted", + status_reason: null, + envelopes: [ + { + task_kind: "structured", + mode: "auto", + reasoning_effort: "medium", + next_action: "Structured work uses auto", + }, + { + task_kind: "checked_judgment", + mode: "verify", + reasoning_effort: "high", + next_action: "Checked judgment uses verify", + }, + ], + }, + }); + render(); + + expect(await screen.findByText("Structured work uses auto")).toBeInTheDocument(); + expect(screen.getByText("Checked judgment uses verify")).toBeInTheDocument(); + expect( + screen.queryByText("Orchestration · contextual-orchestrator not available"), + ).not.toBeInTheDocument(); + }); + it("opens an accepted ranking hit without inventing a fused score", async () => { stubBackend({ rankings: { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb..887d8d7c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { fetchPeriodReportIndex, fetchPeriodReports, fetchPosts, + fetchOrchestration, fetchRankings, fetchRelatedEntity, fetchRelatedKeymen, @@ -50,6 +51,7 @@ import { type PeriodReports, type PostLineage, type PostSummary, + type OrchestrationStatus, type RankingList, type RelatedNode, type VocEvidence, @@ -1313,6 +1315,51 @@ function PostDetailPopup({ ); } +function OrchestrationPanel({ accessToken }: { accessToken: string }) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setError(null); + fetchOrchestration(accessToken) + .then(setStatus) + .catch((err) => setError(String(err))); + }, [accessToken]); + + return ( +
+
+

Orchestration

+ {status && ( + + {status.status === "accepted" + ? "contextual-orchestrator" + : `contextual-orchestrator · ${status.status_reason ?? "unavailable"}`} + + )} +
+ {error &&

{error}

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

Loading orchestration...

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

Orchestration · contextual-orchestrator not available

+ )} + {status && status.status === "accepted" && status.envelopes.length === 0 && ( +

No accepted orchestration envelope.

+ )} + {status && status.envelopes.length > 0 && ( +
    + {status.envelopes.map((envelope) => ( +
  • + {envelope.next_action} + {envelope.mode} +
  • + ))} +
+ )} +
+ ); +} + function RankingsPanel({ accessToken, onSelectPost, @@ -1689,6 +1736,7 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e6dfcbad..3ea3797b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -515,3 +515,21 @@ export interface RankingList { export function fetchRankings(accessToken: string): Promise { return backendFetch("/api/rankings", accessToken); } + +export interface OrchestrationEnvelope { + task_kind: string; + mode: string; + reasoning_effort: string; + next_action: string; +} + +export interface OrchestrationStatus { + port: string; + status: "accepted" | "unavailable"; + status_reason: string | null; + envelopes: OrchestrationEnvelope[]; +} + +export function fetchOrchestration(accessToken: string): Promise { + return backendFetch("/api/orchestration", accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009..a26504ba 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.84.0" diff --git a/lineageweave/orchestrator_client.py b/lineageweave/orchestrator_client.py new file mode 100644 index 00000000..fc434b6c --- /dev/null +++ b/lineageweave/orchestrator_client.py @@ -0,0 +1,204 @@ +"""Fail-closed adapter for contextual-orchestrator's portable task envelope. + +`contextual-orchestrator `_ +owns provider routing, workflow depth, and verification. LineageWeave +sends a versioned envelope and never invents a completion, a confidence, +or a theta when the host is missing or the mode is rejected. + +Upstream ``main`` currently accepts ``auto`` / ``route`` / ``conduct``. +Checked judgment still requests ``verify`` (ADR 0013). An +``invalid_mode`` response is fail-closed +(:class:`OrchestratorNotAvailable`), not a fabricated 0.0 score. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Sequence + +ACCEPTED_MODES = frozenset({"auto", "verify"}) +DEFAULT_CONTRACT_VERSION = 1 +# Synthetic probe only. Never a customer prompt. +SYNTHETIC_PROBE_PROMPT = "synthetic_orchestration_probe" +SYNTHETIC_PROBE_HASH = hashlib.sha256(SYNTHETIC_PROBE_PROMPT.encode("utf-8")).hexdigest() + + +class OrchestratorNotAvailable(RuntimeError): + """Raised when the orchestrator port is down, disabled, or invalid.""" + + reason = "orchestrator_not_available" + + def __init__(self, message: str, *, reason: str | None = None) -> None: + super().__init__(message) + self.reason = reason or type(self).reason + + +def classify_orchestrator_error(detail: str) -> OrchestratorNotAvailable: + """Map transport text to a fail-closed reason. Never invent a score.""" + text = detail.casefold() + if "invalid_mode" in text: + return OrchestratorNotAvailable( + "orchestrator_invalid_mode: contextual-orchestrator rejected " + "the published mode. Never invent a completion.", + reason="orchestrator_invalid_mode", + ) + return OrchestratorNotAvailable( + f"orchestrator_not_available: {detail}. Never invent a completion." + ) + + +def _no_transport(_envelope: "TaskEnvelope") -> dict[str, Any]: + raise OrchestratorNotAvailable( + "orchestrator_not_available: contextual-orchestrator is not configured. " + "Set ORCHESTRATOR_BASE_URL and ORCHESTRATOR_API_KEY. " + "Never invent a completion." + ) + + +@dataclass(frozen=True) +class TaskEnvelope: + """Portable task envelope. ``additionalProperties`` stay closed.""" + + task_kind: str + mode: str + reasoning_effort: str + contract_version: int = DEFAULT_CONTRACT_VERSION + prompt_hash: str = SYNTHETIC_PROBE_HASH + access_list: tuple[str, ...] = ("user_message",) + + def to_json(self) -> dict[str, Any]: + return { + "contract_version": self.contract_version, + "task_kind": self.task_kind, + "mode": self.mode, + "reasoning_effort": self.reasoning_effort, + "prompt_hash": self.prompt_hash, + "access_list": list(self.access_list), + } + + +def published_task_envelopes() -> tuple[TaskEnvelope, TaskEnvelope]: + """Structured work uses auto. Checked judgment uses verify.""" + return ( + TaskEnvelope( + task_kind="structured", + mode="auto", + reasoning_effort="medium", + ), + TaskEnvelope( + task_kind="checked_judgment", + mode="verify", + reasoning_effort="high", + ), + ) + + +def _next_action(task_kind: str) -> str: + if task_kind == "structured": + return "Structured work uses auto" + if task_kind == "checked_judgment": + return "Checked judgment uses verify" + return "Open Orchestration for the next accepted mode" + + +def _project_envelope(envelope: TaskEnvelope) -> dict[str, Any]: + return { + "task_kind": envelope.task_kind, + "mode": envelope.mode, + "reasoning_effort": envelope.reasoning_effort, + "next_action": _next_action(envelope.task_kind), + } + + +class LocalAcceptTransport: + """Accept a published envelope locally. Home GET never POSTs a completion.""" + + def __call__(self, envelope: TaskEnvelope) -> dict[str, Any]: + if envelope.mode not in ACCEPTED_MODES: + raise OrchestratorNotAvailable( + f"orchestrator_invalid_mode: {envelope.mode!r} is not a " + "published LineageWeave mode. Never invent a completion.", + reason="orchestrator_invalid_mode", + ) + return envelope.to_json() + + +def build_orchestrator_client( + base_url: str = "", + api_key: str = "", + submit: Callable[[TaskEnvelope], Mapping[str, Any]] | None = None, +) -> "OrchestratorClient": + """Empty credentials stay fail-closed. Set URL + key to publish envelopes.""" + if not str(base_url or "").strip() or not str(api_key or "").strip(): + return OrchestratorClient() + return OrchestratorClient(transport=LocalAcceptTransport(), submit=submit) + + +class OrchestratorClient: + """Publishes the portable envelope. Never invents a completion.""" + + def __init__( + self, + transport: Callable[[TaskEnvelope], Mapping[str, Any]] = _no_transport, + submit: Callable[[TaskEnvelope], Mapping[str, Any]] | None = None, + ) -> None: + self._transport = transport + self._submit = submit + + def submit_task_envelope(self, envelope: TaskEnvelope) -> dict[str, Any]: + if envelope.mode not in ACCEPTED_MODES: + raise OrchestratorNotAvailable( + f"orchestrator_invalid_mode: {envelope.mode!r} is not a " + "published LineageWeave mode. Never invent a completion.", + reason="orchestrator_invalid_mode", + ) + sender = self._submit or self._transport + try: + raw = sender(envelope) + except OrchestratorNotAvailable: + raise + except Exception as exc: + raise classify_orchestrator_error(str(exc)) from exc + if not isinstance(raw, Mapping): + raise OrchestratorNotAvailable( + "orchestrator_not_available: envelope reply is not an object" + ) + return dict(raw) + + def as_api_payload(self) -> dict[str, Any]: + """Buyer-visible orchestration status. Never invents a completion.""" + accepted: list[dict[str, Any]] = [] + try: + for envelope in published_task_envelopes(): + self._transport(envelope) + accepted.append(_project_envelope(envelope)) + except OrchestratorNotAvailable as exc: + return { + "port": "contextual_orchestrator", + "status": "unavailable", + "status_reason": exc.reason, + "envelopes": [], + } + except Exception as exc: + mapped = classify_orchestrator_error(str(exc)) + return { + "port": "contextual_orchestrator", + "status": "unavailable", + "status_reason": mapped.reason, + "envelopes": [], + } + return { + "port": "contextual_orchestrator", + "status": "accepted", + "status_reason": None, + "envelopes": accepted, + } + + +def envelope_modes(payload: Mapping[str, Any]) -> Sequence[str]: + """Test helper: modes the buyer can act on. Empty when unavailable.""" + rows = payload.get("envelopes") + if not isinstance(rows, list): + return () + return tuple(str(row.get("mode") or "") for row in rows if isinstance(row, Mapping)) diff --git a/pyproject.toml b/pyproject.toml index 764ebad7..a86380c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.84.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_orchestrator_client.py b/tests/test_orchestrator_client.py new file mode 100644 index 00000000..126f6419 --- /dev/null +++ b/tests/test_orchestrator_client.py @@ -0,0 +1,144 @@ +"""Fail-closed contextual-orchestrator task envelope (ADR 0025). + +The portable envelope is a versioned REST adapter. A missing host, a +missing key, or an upstream ``invalid_mode`` must not become an invented +completion, a confidence of 0.0, or a theta. +""" + +from __future__ import annotations + +import hashlib + +import pytest + +from lineageweave.orchestrator_client import ( + ACCEPTED_MODES, + OrchestratorClient, + OrchestratorNotAvailable, + SYNTHETIC_PROBE_PROMPT, + TaskEnvelope, + build_orchestrator_client, + classify_orchestrator_error, + published_task_envelopes, +) + + +def test_default_transport_fails_closed() -> None: + client = OrchestratorClient() + with pytest.raises(OrchestratorNotAvailable, match="orchestrator_not_available"): + client.submit_task_envelope(published_task_envelopes()[0]) + + +def test_default_payload_never_invents_a_completion() -> None: + payload = OrchestratorClient().as_api_payload() + + assert payload == { + "port": "contextual_orchestrator", + "status": "unavailable", + "status_reason": OrchestratorNotAvailable.reason, + "envelopes": [], + } + assert "theta" not in payload + assert "completion" not in payload + assert "choices" not in payload + + +def test_empty_credentials_factory_fails_closed() -> None: + client = build_orchestrator_client(base_url="", api_key="") + payload = client.as_api_payload() + assert payload["status"] == "unavailable" + assert payload["envelopes"] == [] + + +def test_whitespace_credentials_factory_fails_closed() -> None: + client = build_orchestrator_client(base_url=" ", api_key=" ") + assert client.as_api_payload()["status"] == "unavailable" + + +def test_configured_factory_publishes_auto_and_verify_without_calling_http() -> None: + calls: list[object] = [] + + def boom(envelope: TaskEnvelope) -> dict[str, object]: + calls.append(envelope) + raise AssertionError("home status must not POST a completion") + + client = build_orchestrator_client( + base_url="https://orchestrator.test", + api_key="token", + submit=boom, + ) + payload = client.as_api_payload() + + assert calls == [] + assert payload["status"] == "accepted" + assert payload["status_reason"] is None + kinds = {item["task_kind"]: item for item in payload["envelopes"]} + assert kinds["structured"]["mode"] == "auto" + assert kinds["structured"]["next_action"] == "Structured work uses auto" + assert kinds["checked_judgment"]["mode"] == "verify" + assert kinds["checked_judgment"]["next_action"] == "Checked judgment uses verify" + assert "theta" not in payload + for item in payload["envelopes"]: + assert "completion" not in item + assert item["mode"] in ACCEPTED_MODES + + +def test_published_envelopes_are_the_portable_contract() -> None: + structured, checked = published_task_envelopes() + assert structured.mode == "auto" + assert structured.task_kind == "structured" + assert structured.reasoning_effort == "medium" + assert checked.mode == "verify" + assert checked.task_kind == "checked_judgment" + assert checked.reasoning_effort == "high" + probe_hash = hashlib.sha256(SYNTHETIC_PROBE_PROMPT.encode("utf-8")).hexdigest() + for envelope in (structured, checked): + body = envelope.to_json() + assert body["contract_version"] == 1 + assert body["prompt_hash"] == probe_hash + assert body["access_list"] == ["user_message"] + assert set(body) == { + "contract_version", + "task_kind", + "mode", + "reasoning_effort", + "prompt_hash", + "access_list", + } + + +def test_submit_rejects_an_unknown_mode() -> None: + client = build_orchestrator_client( + base_url="https://orchestrator.test", + api_key="token", + submit=lambda envelope: {"mode": envelope.mode}, + ) + with pytest.raises(OrchestratorNotAvailable, match="orchestrator_invalid_mode"): + client.submit_task_envelope( + TaskEnvelope( + task_kind="structured", + mode="conduct", + reasoning_effort="medium", + ) + ) + + +def test_http_invalid_mode_fails_closed_instead_of_inventing_zero() -> None: + def invalid_mode(_envelope: TaskEnvelope) -> dict[str, object]: + raise classify_orchestrator_error("HTTP 400 invalid_mode") + + client = OrchestratorClient(transport=invalid_mode) + with pytest.raises(OrchestratorNotAvailable, match="orchestrator_invalid_mode"): + client.submit_task_envelope(published_task_envelopes()[1]) + + payload = client.as_api_payload() + assert payload["status"] == "unavailable" + assert payload["status_reason"] == "orchestrator_invalid_mode" + assert payload["envelopes"] == [] + + +def test_classify_maps_invalid_mode_and_leaves_other_errors_generic() -> None: + invalid = classify_orchestrator_error("HTTP 400 from orchestrator.test: invalid_mode") + assert invalid.reason == "orchestrator_invalid_mode" + generic = classify_orchestrator_error("connection refused") + assert generic.reason == "orchestrator_not_available" diff --git a/uv.lock b/uv.lock index 08eab776..c2c5ad1f 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.84.0" source = { virtual = "." } dependencies = [ { name = "certifi" },