From 517b7c24c322c17a62853f18c501afbbfb37f2c7 Mon Sep 17 00:00:00 2001 From: Anatoly Maltsev Date: Fri, 11 Sep 2026 14:38:05 +0400 Subject: [PATCH 1/4] =?UTF-8?q?fix(sdk):=20DEF-EXECUTE-CAPTURE-WIRING=20?= =?UTF-8?q?=E2=80=94=20wire-shape=20NR-A015=20closure=20on=20/execute=20ar?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /execute require_approval arm (backend v3.79+) mints a fresh server-side execution_id for the approval row + writes the binding, then echoes the new id back via reservation_id. Pre-fix runtime.execute() captured the freshly-minted id from /gate calls (via _capture_server_minted_execution_id) but NOT from /execute. The post-approval /execute re-fire reused the kwargs dict with the stale execution_id baked in; consume_approved's WHERE execution_id = $3 predicate missed the freshly-stamped row and fell through to the terminal APPROVAL_REPLAY_REJECTED branch → SDK NR-A015 (NullRunApprovalReplayRejectedError). This commit wires the post-/execute capture and syncs the re-fire kwargs dict so the second /execute call uses the freshly-minted execution_id. Closes the SDK-side leg of NR-A015 on the require_approval arm (the backend v3.79 reservation_id echo lands on the same arm — see backend commit 0a9c3c3a for the wire side). Secondary: both _wait_for_approval_resolution call-sites (in check_workflow_budget + runtime.execute) now pass the captured server-minted execution_id from the contextvar (with the prior org_id/workflow_id sentinel as fallback) instead of the workflow_id sentinel. Diagnostic only — the WS handler matches on approval_id — but log lines + entry metadata now reflect the server-minted id. Tests: tests/test_2026_09_11_execute_capture_wires_execution_id.py (220 lines, 2 tests). Full approval-flow suite (34 tests across test_execute_approval_flow + test_approval_money_flow + test_approval_timeout_field + test_approval_ws_sync_callback + new file) green. ruff check clean. No wire-format change. --- CHANGELOG.md | 48 ++++ src/nullrun/runtime.py | 59 ++++- ...9_11_execute_capture_wires_execution_id.py | 221 ++++++++++++++++++ 3 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 tests/test_2026_09_11_execute_capture_wires_execution_id.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1718c68..59ab87e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,51 @@ +## [Unreleased] + +Patch release — closes the NR-A015 wire-shape gap on the SDK side. The +`/execute` `require_approval` arm (backend v3.79+) mints a fresh +server-side execution_id for the approval row and echoes it via +`reservation_id`. Pre-fix `runtime.execute` captured that id into the +contextvar AFTER `/gate` calls but not after `/execute`, so the post- +approval `/execute` re-fire sent the stale pre-arm execution_id; +`consume_approved`'s `WHERE execution_id = $3` predicate missed the +freshly-stamped row and fell through to the terminal +`APPROVAL_REPLAY_REJECTED` branch. This release wires the +post-`/execute` capture and syncs the kwargs dict so the re-fire uses +the freshly-minted id. + +### Fixed + +- **DEF-EXECUTE-CAPTURE-WIRING** — `runtime.execute` now calls + `_capture_server_minted_execution_id(result)` immediately after + `_transport.execute(...)` and syncs the re-fire kwargs dict to the + captured id (`src/nullrun/runtime.py`). The post-approval re-fire + now sends the freshly-minted execution_id stamped on the approval + row, so `consume_approved`'s `WHERE execution_id = $3` predicate + matches. Closes the SDK-side leg of NR-A015 on the `/execute` + require_approval arm. +- **DEF-WAIT-FOR-APPROVAL-EXEC-ID** — both + `_wait_for_approval_resolution` call-sites (`check_workflow_budget` + + `runtime.execute`) now pass the captured server-minted + execution_id from the contextvar (with the prior + `org_id`/`workflow_id` sentinel as fallback) instead of the + `workflow_id` sentinel (`src/nullrun/runtime.py`). Diagnostic + improvement only — the WS handler matches on `approval_id` — but + log lines + entry metadata now reflect the server-minted id. + +### Added + +- **`tests/test_2026_09_11_execute_capture_wires_execution_id.py`** (220 lines). Two regression tests pinning the fix: + - `test_execute_captures_reservation_id_from_response` — verifies the contextvar updates from the `/execute` response and the re-fire uses the captured id (not the stale pre-call one). + - `test_execute_wait_for_approval_receives_captured_eid` — verifies the WS resolution handler receives the captured execution_id. + +### Compatibility + +Pure reliability fix — no wire-format change. `/gate`, `/execute`, +`/track`, `/cancel` payloads are byte-identical to 0.16.7. Backend +v3.79+ is required for the wire-shape contract (the `reservation_id` +echo is the v3.79+ field that closes the gap); pre-v3.79 backends +silently fall through the capture (helper is fail-OPEN on malformed +values), preserving the pre-fix behaviour for un-deployed backends. + ## [0.16.7] - 2026-09-10 Patch release — closes the typed-exception / catalog-coverage gaps surfaced by the 0.16.6 backend hardening. After that release, every catalog exception the SDK can raise now has a hand-written `DEFAULT_MESSAGES` entry (no more "Something went wrong. Please try again." fallback), and `@protect`-decorated sites surface the real exception type instead of rewriting it into a generic `NullRunBlockedException`. The `@protect` block path in `runtime.execute` now dispatches the actual catalog code through `format_user_message`, so wire-error codes (NR-A012, NR-A016, NR-EX01, …) reach users with actionable wording. No wire-format change. diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 327ebce..a4c2b1b 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -2207,10 +2207,26 @@ def check_workflow_budget(self) -> None: f"check_workflow_budget: require_approval id={approval_id} -- " f"waiting for WS push (timeout={server_timeout if server_timeout is not None else 'env-default'})" ) + # 2026-09-11: pass the actual server-minted execution_id + # (captured above from the /gate response) into the WS + # wait so the entry's metadata + diagnostic log lines + # reflect the same id the server stamped on the + # approval row. Pre-fix this string fell back to + # ``str(self.organization_id)`` which made the + # ``__nullrun_unknown__`` sentinel leak into exception + # payloads (demo + # langgraph_openai_approval_demo.py prints + # ``execution_id=exc.workflow_id``). The handler matches + # purely on ``approval_id``, so this is diagnostic-only + # — captured on the consumer side. + from nullrun.context import get_server_minted_execution_id + + _captured_eid = get_server_minted_execution_id() result = self._wait_for_approval_resolution( approval_id=approval_id, workflow_id=workflow_id, - execution_id=str(self.organization_id or "local"), + execution_id=_captured_eid + or str(self.organization_id or "local"), timeout_seconds=server_timeout, ) outcome = (result.get("outcome") or "").lower() @@ -2963,6 +2979,34 @@ def execute( execute_kwargs["action_digest"] = action_digest result = self._transport.execute(**execute_kwargs) + # 2026-09-11: DEF-EXECUTE-CAPTURE-WIRING. The /execute + # require_approval arm mints a FRESH execution_id (server- + # side) for the approval row + writes the binding, then + # echoes the new id back via ``reservation_id`` (mirrored by + # the backend's GateResponse::require_approval constructor — + # see backend/src/enforcement/gate_wire_adapter.rs v3.79+). + # Without this capture below, the contextvar stays at the + # /gate-minted value, and the post-approval /execute re-fire + # (line ~3037) sends the OLD execution_id back to the + # server. ``consume_approved``'s ``WHERE execution_id = $3`` + # predicate then misses the row stamped with the freshly- + # minted one; the diagnostic SELECT walks all alternatives + # without match and falls through to the terminal + # replay-race branch (``APPROVAL_REPLAY_REJECTED``) — the + # SDK raises NR-A015. Capture here is fail-OPEN (drops + # malformed values silently via the helper's UUID parse), + # matching ``check_workflow_budget``'s behaviour. + _capture_server_minted_execution_id(result) + + # Sync the kwargs dict to the captured id so the post-approval + # re-fire (line ~3083, ``self._transport.execute(**execute_kwargs)``) + # uses the freshly-minted execution_id. The contextvar update + # alone is not enough — the re-fire path does NOT re-read from + # the contextvar; it reuses the kwargs built before /execute. + _captured_after_execute = get_server_minted_execution_id() + if _captured_after_execute is not None: + execute_kwargs["execution_id"] = _captured_after_execute + # Update metrics (thread-safe) metrics.inc_runtime("execute_calls") @@ -2986,10 +3030,21 @@ def execute( log_prefix="runtime.execute", ) + # 2026-09-11: same rationale as in + # ``check_workflow_budget`` above — the entry's + # ``execution_id`` slot should reflect the server-minted + # id stamped on the approval row (captured via + # ``_capture_server_minted_execution_id(result)`` at line + # ~2968), not the workflow_id sentinel. The WS handler + # matches on approval_id only, so this is diagnostic. + from nullrun.context import get_server_minted_execution_id + + _captured_eid = get_server_minted_execution_id() approval_result = self._wait_for_approval_resolution( approval_id=str(approval_id), workflow_id=workflow_id or UNKNOWN_WORKFLOW_ID, - execution_id=str(workflow_id or UNKNOWN_WORKFLOW_ID), + execution_id=_captured_eid + or str(workflow_id or UNKNOWN_WORKFLOW_ID), timeout_seconds=server_timeout, ) outcome = str(approval_result.get("outcome") or "").lower() diff --git a/tests/test_2026_09_11_execute_capture_wires_execution_id.py b/tests/test_2026_09_11_execute_capture_wires_execution_id.py new file mode 100644 index 0000000..1293447 --- /dev/null +++ b/tests/test_2026_09_11_execute_capture_wires_execution_id.py @@ -0,0 +1,221 @@ +"""Regression tests for DEF-EXECUTE-CAPTURE-WIRING (2026-09-11). + +The /execute require_approval arm mints a FRESH server-side +execution_id for the approval row (backend v3.79 echo via +``reservation_id``). Pre-fix ``runtime.execute`` did NOT call +``_capture_server_minted_execution_id`` on the /execute response, +so the contextvar stayed at the previous /gate-captured value. +On the post-approval /execute re-fire, the SDK then sent the OLD +execution_id; ``consume_approved``'s ``WHERE execution_id = $3`` +missed the row stamped with the freshly-minted id and fell +through to the terminal ReplayRejected branch +(``APPROVAL_REPLAY_REJECTED`` → SDK NR-A015). + +These tests pin both halves of the fix: + + 1. ``runtime.execute`` captures ``reservation_id`` into the + contextvar immediately after ``_transport.execute`` returns. + 2. ``runtime.execute`` passes the captured id (not the + ``workflow_id`` sentinel) to ``_wait_for_approval_resolution``. + +Test isolation: each test resets the contextvar at setup (the +conftest fixture does this globally) and uses +``make_test_runtime`` so the WS state is fresh. +""" + +from __future__ import annotations + +import threading +import time +import uuid +from typing import Any + +import pytest + +from nullrun.context import ( + get_server_minted_execution_id, + set_server_minted_execution_id, +) +from nullrun.observability import metrics + + +@pytest.fixture(autouse=True) +def _reset_metrics(): + metrics.reset() + yield + metrics.reset() + + +def _approval_response(reservation_id: str, approval_id: str) -> dict[str, Any]: + """Wire shape for /execute require_approval (v3.79+).""" + return { + "decision": "require_approval", + "decision_source": "gateway", + "approval_id": approval_id, + "approval_timeout_seconds": 1, + "approval_expires_at": "2026-09-11T10:30:40Z", + "reservation_id": reservation_id, + "execution_id": reservation_id, # mirror — used by some SDK paths + "explanation": "Approval required", + "policy_version": 1, + } + + +def _release_when_registered( + runtime, approval_id: str, outcome: str +) -> threading.Thread: + def release() -> None: + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + with runtime._approval_lock: + if approval_id in runtime._approval_pending: + break + time.sleep(0.001) + runtime._handle_approval_resolved( + { + "approval_id": approval_id, + "outcome": outcome, + "note": "operator decision", + "resolved_at": 1_700_000_000, + } + ) + + thread = threading.Thread(target=release, daemon=True) + thread.start() + return thread + + +def test_execute_captures_reservation_id_from_response(make_test_runtime): + """Pin #1: ``runtime.execute`` MUST call + ``_capture_server_minted_execution_id`` on the result so the + contextvar tracks the freshly-minted id. + + Pre-fix the contextvar would stay at whatever was set before + the call (here: the previous /gate-minted value). + """ + runtime = make_test_runtime() + runtime.add_sensitive_tool("refund_customer") + + prior_gate_eid = "01a08ffa-1234-7700-8000-000000000001" + fresh_eid = "01a0900b-aaaa-7fff-8000-000000000099" + set_server_minted_execution_id(prior_gate_eid) + assert get_server_minted_execution_id() == prior_gate_eid + + calls: list[dict[str, Any]] = [] + + def execute_transport(**kwargs): + calls.append(kwargs) + if len(calls) == 1: + return _approval_response(reservation_id=fresh_eid, approval_id="ap-1") + return { + "decision": "allow", + "decision_source": "gateway", + "policy_version": 1, + } + + runtime._transport.execute = execute_transport + release = _release_when_registered(runtime, "ap-1", "approved") + + result = runtime.execute( + "refund_customer", + {"kwargs": {"amount_cents": "120000"}}, + mode="strict", + ) + release.join(timeout=1.0) + + # The contextvar MUST now reflect the freshly-minted reservation_id. + assert get_server_minted_execution_id() == fresh_eid, ( + "DEF-EXECUTE-CAPTURE-WIRING: runtime.execute did NOT capture the " + "freshly-minted reservation_id from the /execute response. " + f"contextvar={get_server_minted_execution_id()!r}, " + f"expected={fresh_eid!r}" + ) + # Re-fire MUST have used the captured (fresh) execution_id, not the + # stale prior one. + assert len(calls) == 2 + assert calls[1]["execution_id"] == fresh_eid, ( + "DEF-EXECUTE-CAPTURE-WIRING: re-fire /execute used stale " + f"execution_id={calls[1]['execution_id']!r} instead of the " + f"freshly-captured one={fresh_eid!r}" + ) + assert calls[1]["approval_id"] == "ap-1" + assert result["decision"] == "allow" + + +def test_execute_wait_for_approval_receives_captured_eid(make_test_runtime): + """Pin #2: ``_wait_for_approval_resolution`` MUST receive the + captured execution_id, not the workflow_id sentinel. + + Pre-fix the SDK passed ``str(workflow_id or UNKNOWN_WORKFLOW_ID)`` + which degenerated to ``"__nullrun_unknown__"`` and surfaced into + demo exception messages via ``exc.workflow_id``. The handler + ignores the value (matches on approval_id only), so this is + diagnostic — but a regression test pins the wire-shape so the + log lines + entry metadata stay accurate. + """ + runtime = make_test_runtime() + runtime.add_sensitive_tool("refund_customer") + + fresh_eid = "01a0900b-bbbb-7fff-8000-000000000abc" + set_server_minted_execution_id(fresh_eid) + assert get_server_minted_execution_id() == fresh_eid + + observed_entries: dict[str, dict[str, Any]] = {} + real_wait = runtime._wait_for_approval_resolution + + def spy_wait_for_approval_resolution( + *, approval_id, workflow_id, execution_id, timeout_seconds=None + ): + observed_entries[approval_id] = { + "workflow_id": workflow_id, + "execution_id": execution_id, + } + # Synthesize a fast outcome so the test returns immediately. + return {"outcome": "approved"} + + runtime._wait_for_approval_resolution = spy_wait_for_approval_resolution + + def execute_transport(**_): + return _approval_response(reservation_id=fresh_eid, approval_id="ap-2") + + runtime._transport.execute = execute_transport + + # Bypass the real re-fire: when the spied wait returns "approved", + # runtime.execute calls _transport.execute again to consume the + # grant. Provide an allow response for that second call. + real_transport_execute = execute_transport + calls: list[dict[str, Any]] = [] + + def two_phase_execute(**kwargs): + calls.append(kwargs) + if len(calls) == 1: + return _approval_response( + reservation_id=fresh_eid, approval_id="ap-2" + ) + return { + "decision": "allow", + "decision_source": "gateway", + "policy_version": 1, + } + + runtime._transport.execute = two_phase_execute + + result = runtime.execute( + "refund_customer", + {"kwargs": {"amount_cents": "120000"}}, + mode="strict", + ) + + assert observed_entries, ( + "_wait_for_approval_resolution was never called from runtime.execute" + ) + entry = observed_entries["ap-2"] + assert entry["execution_id"] == fresh_eid, ( + "DEF-EXECUTE-CAPTURE-WIRING: _wait_for_approval_resolution was " + f"passed execution_id={entry['execution_id']!r}; expected the " + f"captured server-minted id={fresh_eid!r}" + ) + assert result["decision"] == "allow" + # Sanity: the second /execute call (re-fire with approval_id) used + # the fresh execution_id, not a stale one. + assert calls[1]["execution_id"] == fresh_eid From 6f28d0d0d709584d437c89351e294c44f7fc4272 Mon Sep 17 00:00:00 2001 From: Anatoly Maltsev Date: Fri, 11 Sep 2026 15:03:40 +0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(sdk):=20chain=5Fend=20wire=20shape=20?= =?UTF-8?q?=E2=80=94=20populate=20required=20GateRequest=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transport.chain_end pre-fix POSTed only {chain_id, chain_op, execution_id} to /api/v1/gate. The backend's GateRequest struct (backend/src/proxy/http/gate/internal.rs:156) marks organization_id, execution_id, trace_id, mode as REQUIRED — deserialization validates them BEFORE chain_op-specific dispatch, so the chain_op=end control-plane path returned 422 'missing field organization_id' on every call. The SDK comment claiming the handler skips required-field validation on chain_op=end was wrong. Live wire trace against api.nullrun.io (capture [009] in nullrun-examples/examples/_wire_trace_output.txt) confirmed the 422 across all /gate calls before the fix and a 200 OK after. Fix: - Transport.chain_end accepts organization_id (and optional trace_id / operation_id) kwargs, builds a full GateRequest body, and raises NullRunConfigError if a direct caller forgets organization_id. - Action_digest is emitted as the NoImpact sentinel so the Phase-1+ version-gate at backend::gate::gate.rs:148 (proto>=3) accepts the control-plane call without triggering LEGACY_GRANT_REJECTED. - Runtime.chain_end forwards self.organization_id (from _authenticate) and the contextvar trace_id. - Existing TestChainEndEndpoint test updated to pass organization_id; three new tests pin the full GateRequest body, the NullRunConfigError on missing organization_id, and trace_id passthrough. All 1757 SDK tests pass. --- src/nullrun/runtime.py | 16 ++++++- src/nullrun/transport.py | 73 +++++++++++++++++++++++++--- tests/test_v3_wire_contract.py | 87 +++++++++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index a4c2b1b..743def5 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -2395,7 +2395,21 @@ def chain_end(self, chain_id: str) -> dict[str, Any]: Returns: Parsed JSON dict. """ - return self._transport.chain_end(chain_id) + # DEF-CHAIN-END-ORG-ID (2026-09-11): ``Transport.chain_end`` pre-fix + # sent only ``{chain_id, chain_op, execution_id}`` to /gate and the + # backend rejected with 422 ``missing field 'organization_id'``. + # Fix: forward ``self.organization_id`` (set in ``_authenticate``) + # and the contextvar trace_id so the SDK builds a complete + # ``GateRequest`` body. ``trace_id`` is sourced from the contextvar + # to match the rest of the SDK's wire-shape policy (one trace id + # per logical chain). + from nullrun.context import get_trace_id + + return self._transport.chain_end( + chain_id, + organization_id=self.organization_id, + trace_id=get_trace_id(), + ) def approximate_budget(self) -> dict[str, Any]: """UI-only budget estimate via GET /api/v1/budget/approximate diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index ebc8602..9e43703 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -33,6 +33,7 @@ NullRunAuthenticationError, NullRunBackendError, NullRunBlockedException, + NullRunConfigError, NullRunDecision, NullRunExecutionNotFoundError, NullRunInfrastructureError, @@ -2088,6 +2089,10 @@ def heartbeat( def chain_end( self, chain_id: str, + *, + organization_id: str | None = None, + trace_id: str | None = None, + operation_id: str | None = None, ) -> dict[str, Any]: """Close a chain explicitly via /api/v1/gate with chain_op=end . @@ -2105,20 +2110,76 @@ def chain_end( Args: chain_id: Chain to close. + organization_id: Organization identifier (REQUIRED on the + wire — ``GateRequest`` deserialization fails with + 422 ``missing field 'organization_id'`` without it, + see ``backend/src/proxy/http/gate/internal.rs:156``). + ``Runtime.chain_end`` always passes + ``self.organization_id`` from ``_authenticate``. + trace_id: Distributed trace ID. Auto-generated UUIDv4 if + not provided. + operation_id: Idempotency key — set explicitly when the + caller wants the backend's per-``operation_id`` + dedup (matches ``runtime.check_workflow_budget`` + behaviour). Auto-generated UUIDv4 if not provided. Returns: Parsed JSON dict (typically ``{"decision": "allow" "chain_id":...}``). """ - # 2026-07-04 (B3): POST /api/v1/gate with + # DEF-CHAIN-END-ORG-ID (2026-09-11): ``Transport.chain_end`` pre-fix + # POSTed only ``{chain_id, chain_op, execution_id}`` to /gate. The + # backend's ``GateRequest`` struct + # (backend/src/proxy/http/gate/internal.rs:156) marks + # ``organization_id``, ``execution_id``, ``trace_id``, ``mode`` as + # REQUIRED — the deserializer validates them BEFORE chain_op-specific + # dispatch, so even the ``chain_op=end`` control-plane path + # returns 422 ``missing field 'organization_id'`` on the old + # body. Live wire trace against api.nullrun.io confirms the + # 422. Fix: build the same full GateRequest body every other + # /gate caller builds (``check`` at transport.py:1481, the + # capture site at runtime.py:1953). + if organization_id is None: + raise NullRunConfigError( + "Transport.chain_end requires organization_id — " + "NullRunRuntime.chain_end always passes it from " + "_authenticate; a direct Transport.chain_end call must " + "pass organization_id explicitly. The backend's GateRequest " + "struct (backend/src/proxy/http/gate/internal.rs:156) " + "rejects requests without organization_id with 422 " + "VALIDATION_ERROR." + ) + # v0.16.1 (Phase-1+ wire-shape): the backend's + # ``backend/src/proxy/http/gate/gate.rs:148`` version-gate + # fail-CLOSED-rejects any proto>=3 /gate call that omits + # ``action_digest``. ``chain_end`` is a control-plane op with + # no business_impact, so we emit the same NoImpact sentinel + # digest that ``runtime.check_workflow_budget`` produces at + # runtime.py:1978. + from nullrun.business_impact import ( + BusinessImpact as _BusinessImpact, + compute_action_digest as _compute_action_digest, + ) + request = { + "organization_id": organization_id, + # Fresh UUIDv4 per call (canonical hyphenated form so it + # parses through the backend's ``Uuid::parse_str`` if it + # ever reads it on this path). + "execution_id": str(uuid.uuid4()), + "trace_id": trace_id or str(uuid.uuid4()), + "tool": None, + "input": None, + # ``mode="auto"`` — chain_end is a control-plane operation, + # not a budget-consuming call. The orchestrator's + # ``gate_reserve_v3`` Lua call receives ``chain_op=End`` + # and skips the budget reserve path, so the mode string + # only needs to satisfy GateRequest's required-field check. + "mode": "auto", + "operation_id": operation_id or str(uuid.uuid4()), "chain_id": chain_id, "chain_op": "end", - # execution_id is required by the backend's gate handler - # even on chain_end — the handler reads it but does not - # mint a reservation for op=end. Use a fresh uuidv7 - # call (the server ignores it on this path). - "execution_id": uuid.uuid4().hex, + "action_digest": _compute_action_digest(_BusinessImpact.no_impact()), } # 2026-07-06 (bug-fix): same body-before-headers reorder as body = _signed_request_body(request) diff --git a/tests/test_v3_wire_contract.py b/tests/test_v3_wire_contract.py index 38cc023..7c9374c 100644 --- a/tests/test_v3_wire_contract.py +++ b/tests/test_v3_wire_contract.py @@ -208,12 +208,14 @@ def test_chain_end_includes_protocol_header(self): # 2026-07-04 (B3): ``chain_end`` now POSTs to # /api/v1/gate with ``chain_op: "end"``. The /api/v1/chain/end # endpoint was never registered on the backend. + # 2026-09-11 (DEF-CHAIN-END-ORG-ID): chain_end now requires + # organization_id (the backend's GateRequest rejects without it). t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") try: route = respx.post(f"{BASE_URL}/api/v1/gate").mock( return_value=Response(200, json={"decision": "allow"}) ) - t.chain_end("chain-abc") + t.chain_end("chain-abc", organization_id="org-1") sent = route.calls.last.request assert sent.headers["X-NULLRUN-PROTOCOL"] == str(NULLRUN_PROTOCOL_VERSION) body = sent.content.decode("utf-8") @@ -913,7 +915,7 @@ def test_chain_end_sends_chain_id_in_body(self): route = respx.post(f"{BASE_URL}/api/v1/gate").mock( return_value=Response(200, json={"decision": "allow"}) ) - t.chain_end("chain-1") + t.chain_end("chain-1", organization_id="org-1") sent = route.calls.last.request body = sent.content.decode("utf-8") assert '"chain_id":"chain-1"' in body @@ -921,6 +923,87 @@ def test_chain_end_sends_chain_id_in_body(self): finally: t.stop() + @respx.mock + def test_chain_end_sends_full_gate_request_body(self): + # DEF-CHAIN-END-ORG-ID (2026-09-11): pre-fix ``chain_end`` POSTed + # only ``{chain_id, chain_op, execution_id}`` to /gate. The + # backend's ``GateRequest`` struct (see + # backend/src/proxy/http/gate/internal.rs:156) marks + # ``organization_id``, ``execution_id``, ``trace_id``, ``mode`` + # as REQUIRED — the deserializer rejects with 422 + # ``missing field 'organization_id'`` BEFORE chain_op-specific + # dispatch runs. Live wire trace against api.nullrun.io + # (capture [009] in + # nullrun-examples/examples/_wire_trace_output.txt) confirms + # the 422. + # + # Fix: ``Transport.chain_end`` accepts ``organization_id`` (and + # optional ``trace_id``/``operation_id``) kwargs, builds a + # full GateRequest body (incl. action_digest so the + # Phase-1+ version-gate at backend::gate::gate.rs:148 passes), + # and raises ``NullRunConfigError`` if a direct caller + # forgets to pass organization_id. + t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") + try: + route = respx.post(f"{BASE_URL}/api/v1/gate").mock( + return_value=Response(200, json={"decision": "allow"}) + ) + t.chain_end("chain-1", organization_id="org-1") + sent = route.calls.last.request + body = sent.content.decode("utf-8") + # All four GateRequest required fields must be on the wire. + assert '"organization_id":"org-1"' in body + assert '"execution_id":"' in body + assert '"trace_id":"' in body + assert '"mode":"auto"' in body + assert '"chain_id":"chain-1"' in body + assert '"chain_op":"end"' in body + assert '"operation_id":"' in body + # Phase-1+ version-gate rejects missing action_digest on + # proto>=3 — chain_end emits the NoImpact sentinel digest + # to match ``runtime.check_workflow_budget``. + assert '"action_digest":"' in body + finally: + t.stop() + + @respx.mock + def test_chain_end_missing_organization_id_raises(self): + # DEF-CHAIN-END-ORG-ID: a direct Transport.chain_end call + # without organization_id MUST raise ``NullRunConfigError`` + # before it hits the network — silent 422 from the backend + # would surface as a confusing transport error. + from nullrun.breaker.exceptions import NullRunConfigError + + t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") + try: + with pytest.raises(NullRunConfigError) as excinfo: + t.chain_end("chain-1") + assert "organization_id" in str(excinfo.value) + finally: + t.stop() + + @respx.mock + def test_chain_end_forwards_trace_id_when_provided(self): + # When the caller (typically ``Runtime.chain_end``) supplies + # a trace_id, it MUST land on the wire unchanged so the + # chain-close event is correlated with the rest of the + # chain's spans. + t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") + try: + route = respx.post(f"{BASE_URL}/api/v1/gate").mock( + return_value=Response(200, json={"decision": "allow"}) + ) + t.chain_end( + "chain-1", + organization_id="org-1", + trace_id="trace-abc", + ) + sent = route.calls.last.request + body = sent.content.decode("utf-8") + assert '"trace_id":"trace-abc"' in body + finally: + t.stop() + # ───────────────────────────────────────────────────────────────────── # — /gate execution_id is fresh uuidv7 per call (BUG #4 fix) From 2badf0162ba435cceaa648186ca820dc093eb12b Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 11 Sep 2026 18:52:59 +0400 Subject: [PATCH 3/4] fix(sdk): AUTH-01 transport-error reclassification + HEART-01 public wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects from 24-driver pass (commit d912b77 in nullrun-examples): AUTH-01 (LOW): NullRunRuntime.__init__ and _authenticate misclassified httpx.RequestError (DNS failure, connection refused, TLS handshake, request timeout) as NullRunAuthenticationError. The Transport.heartbeat already used the correct class — NullRunTransportError(NETWORK_ERROR, 'heartbeat') — for the same condition; only the auth path was wrong. Fix: - Drop the redundant defensive arm in __init__ (backstop for a code path that no longer exists between _authenticate() and _transport.start()). - Reclassify the arm in _authenticate: NullRunTransportError( source=NETWORK_ERROR, endpoint='auth'). Drop the misleading error_code='NR-B001' and user_action kwargs (the transport class manages those itself). - Two pre-existing tests test_authenticate_network_error_raises were locking in the old misclassification — updated to assert the correct class. Back-compat: NullRunTransportError and NullRunAuthenticationError are siblings under NullRunInfrastructureError, so the parent class still catches both. Cookbook code branching on except NullRunAuthenticationError: for retry will need to also catch NullRunTransportError — CHANGELOG entry under 'Fixed'. HEART-01 (LOW): NullRunRuntime.heartbeat() missing from public API; only private Transport.heartbeat() and the scheduler Runtime.ping_chain() existed. Fix: add NullRunRuntime.heartbeat(chain_id) thin forwarder mirroring the chain_end / cancel_execution pattern. Pure addition — no existing API surface changes. Tests: - New tests/test_2026_09_11_auth_heartbeat_sweep.py (7 tests): * AUTH-01: connect-error, timeout-error, back-compat parent-class check * HEART-01: method exists, forwards chain_id, passes through transport error unchanged, ping_chain scheduler intact CHANGELOG.md updated with both defect entries + back-compat notes. --- CHANGELOG.md | 32 +++ src/nullrun/runtime.py | 72 +++++-- tests/test_2026_09_11_auth_heartbeat_sweep.py | 199 ++++++++++++++++++ tests/test_runtime.py | 17 +- tests/test_runtime_branches.py | 12 +- 5 files changed, 308 insertions(+), 24 deletions(-) create mode 100644 tests/test_2026_09_11_auth_heartbeat_sweep.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ab87e..22feca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ freshly-stamped row and fell through to the terminal post-`/execute` capture and syncs the kwargs dict so the re-fire uses the freshly-minted id. +Also includes the AUTH-01 / HEART-01 sweep from the 24-driver pass: +reclassification of httpx transport errors on the auth path, plus a +public `Runtime.heartbeat()` wrapper. + ### Fixed - **DEF-EXECUTE-CAPTURE-WIRING** — `runtime.execute` now calls @@ -30,12 +34,40 @@ the freshly-minted id. `workflow_id` sentinel (`src/nullrun/runtime.py`). Diagnostic improvement only — the WS handler matches on `approval_id` — but log lines + entry metadata now reflect the server-minted id. +- **DEF-AUTH-01** — `NullRunRuntime.__init__` auth path no longer + reclassifies `httpx.RequestError` as `NullRunAuthenticationError`. + The defensive duplicate arm in `__init__` (backstop for a code path + that no longer exists) is removed; the real arm in `_authenticate` + now raises `NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")` + — matching the convention used by `Transport.heartbeat` for the + same condition on `/heartbeat`. The previous wrap misled operators: + a network failure looked like an auth failure, even though the + message itself acknowledged "this is a transport failure (not an + auth failure)". + + **Back-compat**: `NullRunTransportError` and `NullRunAuthenticationError` + are siblings under `NullRunInfrastructureError`, so the parent class + still catches both. Cookbook code that branches on + `except NullRunAuthenticationError:` for retry will need to also + catch `NullRunTransportError`. Two existing tests + (`test_authenticate_network_error_raises` in `test_runtime.py` and + `test_runtime_branches.py`) were locking in the old misclassification + and have been updated to assert the correct class. ### Added +- **DEF-HEART-01** — `NullRunRuntime.heartbeat(chain_id)` public method + added (thin forwarder to `Transport.heartbeat`). Mirrors the + `chain_end` / `cancel_execution` pattern. Use for single-shot chain + TTL extensions; `Runtime.ping_chain()` remains the wall-clock + scheduler variant. Pure addition — no existing API surface changes. + - **`tests/test_2026_09_11_execute_capture_wires_execution_id.py`** (220 lines). Two regression tests pinning the fix: - `test_execute_captures_reservation_id_from_response` — verifies the contextvar updates from the `/execute` response and the re-fire uses the captured id (not the stale pre-call one). - `test_execute_wait_for_approval_receives_captured_eid` — verifies the WS resolution handler receives the captured execution_id. +- **`tests/test_2026_09_11_auth_heartbeat_sweep.py`** (~190 lines, 7 tests). Regression tests for AUTH-01 + HEART-01: + - AUTH-01: `test_auth_connect_error_raises_transport_error_not_auth`, `test_auth_timeout_raises_transport_error_not_auth`, `test_auth_error_class_no_longer_catches_network_error` (back-compat parent-class check). + - HEART-01: `test_heartbeat_method_exists_on_public_api`, `test_heartbeat_forwards_chain_id_to_transport`, `test_heartbeat_passes_through_transport_error`, `test_ping_chain_still_works_after_heartbeat_added`. ### Compatibility diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 743def5..359e98b 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -95,6 +95,7 @@ NullRunInfrastructureError, NullRunTransportError, NullRunWorkflowKilledError, + TransportErrorSource, WorkflowKilledInterrupt, WorkflowPausedException, ) @@ -767,15 +768,14 @@ def __init__( # Test mode: skip all network calls self._transport.start() else: - try: - self._authenticate() - except NullRunAuthenticationError: - raise # Re-raise auth errors immediately - don't continue in unprotected mode - except httpx.RequestError as e: - raise NullRunAuthenticationError( - f"Auth request failed: {e}. Cannot establish secure connection to NullRun. " - f"Refusing to operate in unprotected mode." - ) from e + # AUTH-01 (2026-09-11): previously this arm caught ``httpx.RequestError`` + # and re-raised ``NullRunAuthenticationError``, misclassifying network + # failures as auth failures. Arm B in ``_authenticate`` already catches + # the same condition with the correct class (``NullRunTransportError``); + # this defensive duplicate was a backstop for a code path that no longer + # exists between ``_authenticate()`` and ``self._transport.start()``. + # Remove: arm B is sufficient. + self._authenticate() self._transport.start() # Start remote polling unless disabled (internal `polling=False` # for tests/CI). Production always polls. @@ -1252,19 +1252,22 @@ def _authenticate(self) -> None: ) raise err except httpx.RequestError as e: - # Network error - raise exception, do not fall back silently - err = NullRunAuthenticationError( + # AUTH-01 (2026-09-11): reclassify httpx.RequestError as + # ``NullRunTransportError`` instead of ``NullRunAuthenticationError``. + # The previous wrap misled operators — the same condition (DNS failure, + # connection refused, TLS handshake error, request timeout) is correctly + # classified as ``NullRunTransportError(NETWORK_ERROR, "auth")`` by + # ``Transport.heartbeat`` (transport.py:2077) and the rest of the SDK. + # The ``user_action`` previously embedded here noted "This is a + # transport failure (not an auth failure)" — the class should match + # the message. ``NullRunTransportError.__init__`` already sets + # ``error_code="NR-B001"`` (transport.py:233) and the standard + # retryable ``user_action`` (transport.py:234-237). + err = NullRunTransportError( f"Auth request failed: {e}. Cannot establish secure connection to NullRun. " f"Refusing to operate in unprotected mode.", - error_code="NR-B001", - user_action=( - "Could not reach the NullRun backend at " - f"{self.api_url}. Check network connectivity and the " - "configured api_url. This is a transport failure (not " - "an auth failure) — the API key may be valid, the " - "backend is just unreachable." - ), - cause=e, + source=TransportErrorSource.NETWORK_ERROR, + endpoint="auth", ) self._emit_sdk_error(err, stage="auth") raise err from e @@ -2358,6 +2361,35 @@ def stop() -> None: return stop + def heartbeat(self, chain_id: str) -> dict[str, Any]: + """POST /api/v1/heartbeat — extend a chain's idle TTL. + + Single-shot wrapper around ``Transport.heartbeat`` matching the + ``chain_end`` / ``cancel_execution`` public-API pattern + (DEF-HEART-01, 2026-09-11). Use this for one-off TTL extensions; + use ``ping_chain`` when you want a wall-clock scheduler that calls + this method every N seconds. + + The wire body is ``{"chain_id": chain_id}`` — HMAC headers carry + ``organization_id`` + ``trace_id`` automatically via + ``_build_signed_headers``, so no extra kwargs are needed at the + transport layer (mirrors the simpler heartbeat shape vs. chain_end's + ``organization_id``/``trace_id`` injection). + + The transport layer already raises ``NullRunTransportError( + NETWORK_ERROR, "heartbeat")`` for network errors (transport.py:2077), + so no reclassification is needed at this layer. + + Args: + chain_id: Active chain_id (UUID v4) registered via + ``with chain(chain_id, op="start")``. + + Returns: + Parsed JSON dict (typically ``{"status": "ok", "chain_id": ..., + "last_active": ts}``). + """ + return self._transport.heartbeat(chain_id) + def cancel_execution(self, execution_id: str, reason: str | None = None) -> dict[str, Any]: """Cancel an in-flight execution via /api/v1/cancel . diff --git a/tests/test_2026_09_11_auth_heartbeat_sweep.py b/tests/test_2026_09_11_auth_heartbeat_sweep.py new file mode 100644 index 0000000..f605716 --- /dev/null +++ b/tests/test_2026_09_11_auth_heartbeat_sweep.py @@ -0,0 +1,199 @@ +"""Regression tests for AUTH-01 and HEART-01 (2026-09-11 sweep). + +AUTH-01 (LOW): ``NullRunRuntime.__init__`` arms in the auth path misclassified +``httpx.RequestError`` (DNS failure, connection refused, TLS handshake, request +timeout) as ``NullRunAuthenticationError``. The transport layer already used +the correct class — ``NullRunTransportError(NETWORK_ERROR, "heartbeat")`` for +the same condition on /heartbeat. Only the auth path was wrong. + +Fix: replace arm B in ``_authenticate`` with ``NullRunTransportError`` and +delete the redundant defensive arm A in ``__init__``. + +HEART-01 (LOW): ``Runtime.heartbeat()`` was missing from the public API; only +private ``Transport.heartbeat()`` and the scheduler ``Runtime.ping_chain()`` +existed. Long-running chains without ``ping_chain`` had no way to extend the +chain's idle TTL. + +Fix: add ``Runtime.heartbeat(chain_id)`` thin forwarder (mirrors the +``chain_end`` / ``cancel_execution`` pattern). + +Test isolation: each test uses ``make_test_runtime`` (test-mode runtime) so +network calls are skipped, and patches the appropriate ``_transport.heartbeat`` +/ ``_authenticate`` method to inject the error path under test. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx +from httpx import Response + +from nullrun.breaker.exceptions import ( + NullRunAuthenticationError, + NullRunInfrastructureError, + NullRunTransportError, + TransportErrorSource, +) +from nullrun.runtime import NullRunRuntime + +BASE_URL = "https://api.test.nullrun.io" + + +# ────────────────────────────────────────────────────────────── +# AUTH-01: auth-path transport-error reclassification +# ────────────────────────────────────────────────────────────── + + +class TestAuthNetworkErrorReclassification: + """AUTH-01: httpx.RequestError on auth path raises NullRunTransportError.""" + + def test_auth_connect_error_raises_transport_error_not_auth( + self, + monkeypatch, + make_runtime, + ): + """A connection refused on /api/v1/auth/verify must surface as + NullRunTransportError(NETWORK_ERROR, "auth"), NOT + NullRunAuthenticationError. The previous wrap was misleading — + the API key may be valid, the backend is just unreachable. + """ + monkeypatch.setenv("NULLRUN_API_KEY", "test-key-12345678") + monkeypatch.setenv("NULLRUN_API_URL", BASE_URL) + with respx.mock: + # Force httpx.ConnectError on /auth/verify + respx.post(f"{BASE_URL}/api/v1/auth/verify").mock( + side_effect=httpx.ConnectError("connection refused") + ) + with pytest.raises(NullRunTransportError) as exc_info: + make_runtime(api_url=BASE_URL, polling=False) + + err = exc_info.value + assert err.source == TransportErrorSource.NETWORK_ERROR + assert err.endpoint == "auth" + assert err.error_code == "NR-B001" # default for NullRunTransportError + + def test_auth_timeout_raises_transport_error_not_auth( + self, + monkeypatch, + ): + """httpx.TimeoutException on auth path also reclassified.""" + monkeypatch.setenv("NULLRUN_API_KEY", "test-key-12345678") + monkeypatch.setenv("NULLRUN_API_URL", BASE_URL) + with respx.mock: + respx.post(f"{BASE_URL}/api/v1/auth/verify").mock( + side_effect=httpx.TimeoutException("timed out") + ) + with pytest.raises(NullRunTransportError) as exc_info: + NullRunRuntime(api_key="test-key-12345678", api_url=BASE_URL, polling=False) + + assert exc_info.value.source == TransportErrorSource.NETWORK_ERROR + + def test_auth_error_class_no_longer_catches_network_error( + self, + monkeypatch, + ): + """Back-compat check: user code with ``except NullRunAuthenticationError`` + will NOT silently swallow the network error case after this fix. + The new NullRunTransportError is a sibling under + NullRunInfrastructureError — only the parent class catches both. + """ + monkeypatch.setenv("NULLRUN_API_KEY", "test-key-12345678") + monkeypatch.setenv("NULLRUN_API_URL", BASE_URL) + with respx.mock: + respx.post(f"{BASE_URL}/api/v1/auth/verify").mock( + side_effect=httpx.ConnectError("nope") + ) + with pytest.raises(NullRunTransportError) as exc_info: + NullRunRuntime(api_key="test-key-12345678", api_url=BASE_URL, polling=False) + + # Parent-class catch still works (NullRunInfrastructureError). + try: + raise exc_info.value + except NullRunInfrastructureError as e: + # Confirm the catch reaches the new error. + assert e is exc_info.value + else: + pytest.fail("NullRunInfrastructureError should catch the new transport error") + + # Sibling-class (NullRunAuthenticationError) does NOT catch it. + try: + raise exc_info.value + except NullRunAuthenticationError: + pytest.fail( + "NullRunAuthenticationError must NOT catch the new " + "NullRunTransportError — they're siblings, not parent/child" + ) + except NullRunTransportError: + pass # Expected + + +# ────────────────────────────────────────────────────────────── +# HEART-01: public Runtime.heartbeat() wrapper +# ────────────────────────────────────────────────────────────── + + +class TestRuntimeHeartbeatForwarder: + """HEART-01: NullRunRuntime.heartbeat forwards to Transport.heartbeat.""" + + def test_heartbeat_method_exists_on_public_api(self, make_test_runtime): + """The heartbeat method must be present on the Runtime public surface.""" + rt = make_test_runtime() + assert hasattr(rt, "heartbeat"), "Runtime.heartbeat missing from public API" + assert callable(getattr(rt, "heartbeat")) + + def test_heartbeat_forwards_chain_id_to_transport(self, make_test_runtime): + """Runtime.heartbeat(chain_id) calls Transport.heartbeat with the same chain_id.""" + rt = make_test_runtime() + captured: dict = {} + + def fake_transport_heartbeat(chain_id_arg: str) -> dict: + captured["chain_id"] = chain_id_arg + return { + "status": "ok", + "chain_id": chain_id_arg, + "last_active": "2026-09-11T12:00:00Z", + } + + rt._transport.heartbeat = fake_transport_heartbeat # type: ignore[method-assign] + result = rt.heartbeat("chain-test-abc-123") + + assert captured["chain_id"] == "chain-test-abc-123" + assert result == { + "status": "ok", + "chain_id": "chain-test-abc-123", + "last_active": "2026-09-11T12:00:00Z", + } + + def test_heartbeat_passes_through_transport_error(self, make_test_runtime): + """Transport-layer NullRunTransportError must propagate unchanged + (not be rewrapped to NullRunAuthenticationError — AUTH-01 analog).""" + rt = make_test_runtime() + sentinel = NullRunTransportError( + "network error on /heartbeat", + source=TransportErrorSource.NETWORK_ERROR, + endpoint="heartbeat", + ) + + def fake_transport_heartbeat_raises(chain_id_arg: str) -> dict: + raise sentinel + + rt._transport.heartbeat = fake_transport_heartbeat_raises # type: ignore[method-assign] + with pytest.raises(NullRunTransportError) as exc_info: + rt.heartbeat("chain-test-abc-123") + + assert exc_info.value is sentinel + assert exc_info.value.source == TransportErrorSource.NETWORK_ERROR + assert exc_info.value.endpoint == "heartbeat" + + def test_ping_chain_still_works_after_heartbeat_added(self, make_test_runtime): + """Adding Runtime.heartbeat must not break Runtime.ping_chain (scheduler).""" + rt = make_test_runtime() + assert hasattr(rt, "ping_chain") + # Don't actually run the scheduler (it spawns a daemon thread); + # just confirm the callable is intact and takes the expected kwargs. + import inspect + + sig = inspect.signature(rt.ping_chain) + assert "chain_id" in sig.parameters + assert "interval" in sig.parameters diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 286e986..70a7e1f 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -964,12 +964,25 @@ def test_authenticate_non_200_raises(): def test_authenticate_network_error_raises(): + """AUTH-01 (2026-09-11): httpx.RequestError on auth path now raises + ``NullRunTransportError(NETWORK_ERROR, "auth")`` instead of + ``NullRunAuthenticationError``. The previous test was locking in the + misclassified behavior — fix the test to assert the correct class. + ``NullRunInfrastructureError`` (parent) still catches it for back-compat. + """ import httpx - from nullrun.breaker.exceptions import NullRunAuthenticationError + from nullrun.breaker.exceptions import NullRunInfrastructureError, NullRunTransportError, TransportErrorSource rt = _make_runtime_with_mocked_auth() rt._transport._client.post.side_effect = httpx.ConnectError("nope") - with pytest.raises(NullRunAuthenticationError): + with pytest.raises(NullRunTransportError) as exc_info: + rt._authenticate() + + assert exc_info.value.source == TransportErrorSource.NETWORK_ERROR + assert exc_info.value.endpoint == "auth" + # Parent-class catch still works for back-compat. + with pytest.raises(NullRunInfrastructureError): + rt._transport._client.post.side_effect = httpx.ConnectError("nope") rt._authenticate() diff --git a/tests/test_runtime_branches.py b/tests/test_runtime_branches.py index 6606c74..cb372a1 100644 --- a/tests/test_runtime_branches.py +++ b/tests/test_runtime_branches.py @@ -497,12 +497,20 @@ def test_authenticate_non_200_raises(): def test_authenticate_network_error_raises(): + """AUTH-01 (2026-09-11): httpx.RequestError on auth path now raises + ``NullRunTransportError(NETWORK_ERROR, "auth")`` instead of + ``NullRunAuthenticationError``. See test_runtime.py for the same fix + and rationale. + """ import httpx - from nullrun.breaker.exceptions import NullRunAuthenticationError + from nullrun.breaker.exceptions import NullRunTransportError, TransportErrorSource rt = _make_runtime_with_mocked_auth() rt._transport._client.post.side_effect = httpx.ConnectError("nope") - with pytest.raises(NullRunAuthenticationError): + with pytest.raises(NullRunTransportError) as exc_info: rt._authenticate() + + assert exc_info.value.source == TransportErrorSource.NETWORK_ERROR + assert exc_info.value.endpoint == "auth" From 640ede271e581bf1a94615e0fa870d525f27785b Mon Sep 17 00:00:00 2001 From: Anatoly Maltsev Date: Fri, 11 Sep 2026 19:44:24 +0400 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=200.16.8=20=E2=80=94=20NR-A?= =?UTF-8?q?015=20execute-capture=20wiring=20+=20AUTH-01=20transport-error?= =?UTF-8?q?=20reclassification=20+=20HEART-01=20public=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 commits ahead of origin/master, organised into three themes: 1. NR-A015 wire-shape closure on /execute arm - 5f96b2e fix(sdk): DEF-EXECUTE-CAPTURE-WIRING — wire-shape NR-A015 closure on /execute arm. _capture_server_minted_execution_id(result) is now called immediately after _transport.execute(...) and the re-fire kwargs dict syncs to the captured id, so consume_approved's WHERE execution_id = $3 predicate matches the freshly-stamped approval row. Closes the SDK-side leg of NR-A015 on the require_approval arm (backend v3.79+ reservation_id echo lands on the same arm — see backend commit 0a9c3c3a for the wire side). New tests/test_2026_09_11_execute_capture_wires_execution_id.py (220 lines, 2 tests) pins the fix; full approval-flow suite (34 tests across test_execute_approval_flow + test_approval_money_flow + test_approval_timeout_field + test_approval_ws_sync_callback + new file) green. - ebac334 fix(sdk): chain_end wire shape — populate required GateRequest fields. Runtime.chain_end forwards organization_id (from _authenticate) and the trace_id contextvar; action_digest is emitted as the NoImpact sentinel so the Phase-1+ version-gate at backend::gate::gate.rs:148 (proto>=3) accepts the control-plane call without triggering LEGACY_GRANT_REJECTED. Existing TestChainEndEndpoint test updated to pass organization_id; three new tests pin the full GateRequest body, the NullRunConfigError on missing organization_id, and trace_id passthrough. All 1757 SDK tests pass at commit time. 2. AUTH-01 transport-error reclassification - dcf11c8 fix(sdk): AUTH-01 transport-error reclassification + HEART-01 public wrapper. NullRunRuntime.__init__ no longer reclassifies httpx.RequestError as NullRunAuthenticationError; the defensive duplicate arm is removed; the real arm in _authenticate now raises NullRunTransportError(source=NETWORK_ERROR, endpoint="auth") matching the Transport.heartbeat convention for the same condition on /heartbeat. Back-compat: both are siblings under NullRunInfrastructureError so the parent still catches both; cookbook code branching on except NullRunAuthenticationError: for retry needs to also catch NullRunTransportError. 3. HEART-01 public API - Same dcf11c8 commit. NullRunRuntime.heartbeat(chain_id) thin forwarder to Transport.heartbeat, mirroring chain_end / cancel_execution pattern. Use for single-shot chain TTL extensions; Runtime.ping_chain() remains the wall-clock scheduler variant. 4. Tooling + CHANGELOG - 261dfe1 fix errors (this commit, reworded). Version bump 0.16.7 → 0.16.8 across pyproject.toml:9 + src/nullrun/__version__.py:8 + uv.lock workspace entry. CHANGELOG.md: rename ## [Unreleased] → ## [0.16.8] - 2026-09-11 with full release-notes block (Fixed + Added + Compatibility + Why this is needed). Plus minor ruff cleanups (F811 TransportErrorSource duplicate import in runtime.py, two I001 multi-line import blocks) committed alongside — see git show for the file-level diff. Verification on release/0.16.8: - ruff check src tests : clean - mypy src/nullrun : 0 issues in 37 source files - pytest -q : 1796 passed, 4 skipped (106s) (vs 1784 baseline 0.16.7 — +12 new tests) - nullrun.__version__ : 0.16.8 - wire-format compatibility : unchanged from 0.16.7 --- CHANGELOG.md | 37 ++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- src/nullrun/__version__.py | 2 +- src/nullrun/runtime.py | 1 - src/nullrun/transport.py | 2 ++ tests/test_runtime.py | 6 +++++- uv.lock | 2 +- 7 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22feca5..bf57614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [Unreleased] +## [0.16.8] - 2026-09-11 Patch release — closes the NR-A015 wire-shape gap on the SDK side. The `/execute` `require_approval` arm (backend v3.79+) mints a fresh @@ -78,6 +78,41 @@ echo is the v3.79+ field that closes the gap); pre-v3.79 backends silently fall through the capture (helper is fail-OPEN on malformed values), preserving the pre-fix behaviour for un-deployed backends. +### Why this is needed + +**NR-A015 (execute capture)** — the user-facing symptom was a +post-approval `/execute` re-fire landing on `APPROVAL_REPLAY_REJECTED` +because the SDK stamped the pre-arm `execution_id` into the +re-fire's kwargs dict, but `consume_approved`'s `WHERE execution_id += $3` predicate had to match the freshly-minted id from the +approval-row bind (backend v3.79+). The terminal error was a +typed `NullRunApprovalReplayRejectedError(NR-A015)` — operators had +no signal that the re-fire was sending a stale id rather than a +truly-replayed call. 0.16.8 captures the `reservation_id` echo +from `/execute`'s response into the same contextvar that `/gate` +already uses, and re-emits the captured id on the re-fire kwargs +dict. + +**AUTH-01 (transport reclassification)** — operators reading +`NullRunAuthenticationError` from a failed `__init__` were led to +rotate the API key because the class name suggested auth failure. +Pre-fix, the duplicate arm in `NullRunRuntime.__init__` rewrapped +`httpx.RequestError` as `NullRunAuthenticationError` (with the +"this is a transport failure (not an auth failure)" wording in the +message itself — a smoke signal the wrap was wrong). 0.16.8 raises +`NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")` +matching the `Transport.heartbeat` convention. Catch-block semantics +in cookbooks now need `except (NullRunAuthenticationError, +NullRunTransportError):` for full coverage under +`NullRunInfrastructureError`. + +**HEART-01 (public API)** — single-shot chain TTL extensions had to +reach through `runtime._transport.heartbeat(...)` because +`NullRunRuntime` exposed only the wall-clock `ping_chain()` scheduler. +0.16.8 adds `NullRunRuntime.heartbeat(chain_id)` as a thin +forwarder to `Transport.heartbeat`, matching the chain_end / +cancel_execution pattern. + ## [0.16.7] - 2026-09-10 Patch release — closes the typed-exception / catalog-coverage gaps surfaced by the 0.16.6 backend hardening. After that release, every catalog exception the SDK can raise now has a hand-written `DEFAULT_MESSAGES` entry (no more "Something went wrong. Please try again." fallback), and `@protect`-decorated sites surface the real exception type instead of rewriting it into a generic `NullRunBlockedException`. The `@protect` block path in `runtime.execute` now dispatches the actual catalog code through `format_user_message`, so wire-error codes (NR-A012, NR-A016, NR-EX01, …) reach users with actionable wording. No wire-format change. diff --git a/pyproject.toml b/pyproject.toml index 282d4da..c84fc45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "nullrun" # Full release history lives in CHANGELOG.md; only the current version # is pinned here. -version = "0.16.7" +version = "0.16.8" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. The headline is the canonical §1 statement # from positioning.md — "runtime decision layer for tool-using AI agents" diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index b33757c..9d7d147 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -5,5 +5,5 @@ string and the SDK_MIN_VERSION constant. """ -__version__ = "0.16.7" +__version__ = "0.16.8" __platform_version__ = "1.0.0" diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 359e98b..8ee5040 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -95,7 +95,6 @@ NullRunInfrastructureError, NullRunTransportError, NullRunWorkflowKilledError, - TransportErrorSource, WorkflowKilledInterrupt, WorkflowPausedException, ) diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 9e43703..d6f5084 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -2158,6 +2158,8 @@ def chain_end( # runtime.py:1978. from nullrun.business_impact import ( BusinessImpact as _BusinessImpact, + ) + from nullrun.business_impact import ( compute_action_digest as _compute_action_digest, ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 70a7e1f..885d5f4 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -972,7 +972,11 @@ def test_authenticate_network_error_raises(): """ import httpx - from nullrun.breaker.exceptions import NullRunInfrastructureError, NullRunTransportError, TransportErrorSource + from nullrun.breaker.exceptions import ( + NullRunInfrastructureError, + NullRunTransportError, + TransportErrorSource, + ) rt = _make_runtime_with_mocked_auth() rt._transport._client.post.side_effect = httpx.ConnectError("nope") diff --git a/uv.lock b/uv.lock index 1cf8446..562b1f0 100644 --- a/uv.lock +++ b/uv.lock @@ -2870,7 +2870,7 @@ wheels = [ [[package]] name = "nullrun" -version = "0.16.7" +version = "0.16.8" source = { editable = "." } dependencies = [ { name = "httpx" },