From cebc06a33c6ce2f0fd5730c4b882257652b64fff Mon Sep 17 00:00:00 2001 From: Yurii214 <216080096+Yurii214@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:57:31 +0000 Subject: [PATCH] fix(jsonl): map LifecycleError to invalid_request, not internal_error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the jsonl/http dispatcher's clean-caller-error catch omitted LifecycleError (lifecycle.py's domain error — a RuntimeError sibling of the already-handled ProposalError), so a caller mistake returned the server-fault code internal_error instead of invalid_request, which clients treat as retryable: a typo'd goal status on kb.set_goal_status, or a self-supersede/-contradict. mcp already maps LifecycleError to ValueError (server.py) and the cli catches it; this brings jsonl/http in line. an unknown goal id already returns invalid_request via ArtifactNotFoundError, so the same method was self-inconsistent — a bad id was a caller error but a bad status a server fault. --- src/vouch/jsonl_server.py | 7 ++++++- tests/test_jsonl_server.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index bdd4fd9f..3a04d11b 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -1104,7 +1104,12 @@ def handle_request(envelope: dict) -> dict: "id": req_id, "ok": False, "error": {"code": "dead_claim_refs", "message": str(e)}, } - except (ValueError, ProposalError, ArtifactNotFoundError) as e: + # lifecycle validation failures (unknown goal status, self-supersede / + # -contradict) are caller errors, not server faults — the sibling of + # ProposalError. map to invalid_request so jsonl/http match the mcp contract + # (kb_set_goal_status catches LifecycleError and re-raises it as ValueError); + # otherwise a bad request reads as a retryable internal_error. + except (ValueError, ProposalError, ArtifactNotFoundError, life.LifecycleError) as e: return { "id": req_id, "ok": False, "error": {"code": "invalid_request", "message": str(e)}, diff --git a/tests/test_jsonl_server.py b/tests/test_jsonl_server.py index 2cd8d028..7554e93c 100644 --- a/tests/test_jsonl_server.py +++ b/tests/test_jsonl_server.py @@ -381,3 +381,22 @@ def test_import_check_bundle_path_fenced_on_remote(store: KBStore, monkeypatch) "params": {"bundle_path": "/etc/passwd"}}) assert not resp["ok"] assert "project root" in resp["error"]["message"] + + +def test_jsonl_set_goal_status_bad_status_is_invalid_request(store: KBStore, monkeypatch) -> None: + # a typo'd goal status is a caller error, not a server fault. set_goal_status + # validates the status before the goal lookup and raises LifecycleError (a + # RuntimeError, not a ValueError); the dispatcher must map it to + # invalid_request like mcp does, or a client sees internal_error and wrongly + # treats a bad request as retryable. (status is checked before goal lookup, + # so no goal need exist.) + monkeypatch.chdir(store.root) + resp = handle_request( + { + "id": "1", + "method": "kb.set_goal_status", + "params": {"goal_id": "g-missing", "status": "bogus"}, + } + ) + assert not resp["ok"] + assert resp["error"]["code"] == "invalid_request"