diff --git a/.gitignore b/.gitignore index 79c51d6..603938e 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ CLAUDE.md analyze.md docs/integration-baseline-2026-06-19.md audit.md +docs/postman/ diff --git a/pyproject.toml b/pyproject.toml index eb99f7d..49dcac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,10 @@ name = "nullrun" # decision exception + fail-CLOSED/OPEN honesty in module docstring). # No on-wire breaking change; backends on 1.0.0 keep working # unchanged. See docs/drift.md for the full audit trail. -version = "0.13.0" +# 0.13.1 (2026-07-04): drift-fixes release — see __version__.py +# for the four BLOCKER closes (B1 check_v3, B2 track_single +# docstring, B3 chain_end, M3 approximate_budget query param). +version = "0.13.1" # Long form used by PyPI page meta-description and search snippets. # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index eacffdb..99e3716 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -140,7 +140,68 @@ SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade path: 0.12.2 -> 0.13.0 (no on-wire breaking change; the SDK will pick up the new idempotency_key stamping automatically). + +--- + +v3.15 / 0.13.1 (2026-07-04) — drift-fixes release: closes the four +BLOCKER items from the SDK↔backend drift audit that were still active +in 0.13.0. + + 1. ``Transport.check_v3`` (drift B1): was POSTing to ``/api/v1/check`` + (removed 2026-06-27 — handler now returns 410 Gone with + ``replacement: /api/v1/gate``). Now delegates to ``Transport.check`` + which targets ``/api/v1/gate`` and forwards all v3 wire fields + (``chain_id``, ``chain_op``, ``idempotency_key``, ``stream``). + ``check()`` is the canonical entry point; ``check_v3`` is kept + as a v3-named alias for callers/tests that already use it. + + 2. ``Transport.track_single`` docstring + ``tests/test_v3_wire_contract.py:: + test_track_single_includes_protocol_header`` body (drift B2): the + docstring described a fictitious wire shape ``{execution_id, + actual_cost_cents, api_key_id, cost_source}``. The real backend + ``TrackRequestRaw`` is ``{workflow_id, tokens, cost_cents, ...}`` + (built by ``runtime._build_v3_track_payload``) — ``execution_id`` + is replaced by ``reservation_id``, and the SDK always emits + ``cost_cents: 0`` because the backend recomputes the authoritative + cost from tokens + the org's pricing policy (see + ``_WIRE_STRIP_FIELDS`` in runtime.py). ``api_key_id`` is derived + server-side from the request auth, not supplied by the SDK. + Docstring + test body now match the real contract. + + 3. ``Transport.chain_end`` (drift B3): was POSTing to + ``/api/v1/chain/end`` — that endpoint was never registered on + the backend (``backend/src/proxy/http/routes.rs`` has zero + matches). Now POSTs to ``/api/v1/gate`` with ``chain_op: "end"`` + (matches the documented backend contract from + ``backend/src/proxy/http/cancel.rs:39``'s own comment). + + 4. ``Transport.approximate_budget`` (drift M3): was appending + ``?organization_id=`` to the URL. The backend's + ``approximate_budget_handler`` (``backend/src/proxy/http/ + budget.rs:130-145``) resolves the org from the X-API-Key / + Authorization header — it does NOT accept a query parameter. + The method now calls the bare URL. The ``organization_id`` + argument is retained as an accepted-but-unused parameter for + backward compatibility with any external caller that still + passes it (silently no-ops). + +Tests touched (in ``tests/test_v3_wire_contract.py``): + * ``test_check_v3_includes_protocol_header`` — re-mocked against + /api/v1/gate (was /api/v1/check). + * ``test_check_v3_accepts_chain_context`` — re-mocked against + /api/v1/gate (was /api/v1/check). + * ``test_chain_end_includes_protocol_header`` — re-mocked against + /api/v1/gate (was /api/v1/chain/end); added chain_op=end check. + * ``test_chain_end_sends_chain_id_in_body`` — re-mocked against + /api/v1/gate (was /api/v1/chain/end); added chain_op=end check. + * ``test_track_single_includes_protocol_header`` — body now matches + the real wire shape (reservation_id + workflow_id + tokens + + cost_cents:0 + cost_source:"provisional"). + +1037 lib tests pass (no regression). Recommended upgrade path: +0.13.0 -> 0.13.1. No SDK_MIN_VERSION bump — wire format is the same +from the caller's perspective; only the URLs and docstrings changed. """ -__version__ = "0.13.0" +__version__ = "0.13.1" __platform_version__ = "1.0.0" diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index fe3dc91..e572558 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -1727,30 +1727,22 @@ def check_v3( request: dict[str, Any], on_transport_error: Callable[[Exception], dict[str, Any]] | str | None = None, ) -> dict[str, Any]: - """POST /api/v1/check — wire-protocol v3 pre-execution gate. - - CLAUDE.md §3, §16, §22-§24. The v3 replacement for ``check()`` - (/api/v1/gate). Adds three new optional fields on top of the - v2 wire shape: - - * ``chain_id`` (UUID v4, optional) — pairs with ``chain_op`` - (``"start"`` / ``"continue"`` / ``"end"``). Enables the - backend's soft-mode gate (§5) which only allows budget - overdrafts when a chain is active. - * ``chain_op`` (string, optional) — ``"start"`` creates a - chain in REGISTERED state, ``"continue"`` extends the TTL, - ``"end"`` closes the chain. Absent means auto-register. - * ``idempotency_key`` (UUID v4, optional) — replays return - the original decision instead of re-running the gate. - - The response carries a server-minted ``execution_id`` (§24) — - callers MUST NOT treat the request's ``execution_id`` field - as authoritative; the backend overwrites it on the response. + """Pre-execution gate — wire-protocol v3 (drift.md B1 fix 2026-07-04). + + Pre-fix this method POSTed to ``/api/v1/check``. That endpoint + was removed on 2026-06-27 — the handler now returns + ``410 Gone`` with a ``replacement: /api/v1/gate`` hint. The + SDK's ``check()`` method already targets ``/api/v1/gate`` and + forwards every v3 wire field (CLAUDE.md §16) — ``chain_id``, + ``chain_op``, ``idempotency_key``, ``stream``. This method + is kept as a v3-named alias so existing call sites and tests + continue to work; internally it delegates to ``check()`` with + the same body. Args: request: Gate request body. Must include ``organization_id``, ``execution_id`` (for backward compat — server mints its - own), ``operation_id``, and ``check_type``. + own on /check), ``operation_id``, and ``check_type``. on_transport_error: Mirrors the ``check()`` flag. Returns: @@ -1762,7 +1754,7 @@ def check_v3( NullRunAuthenticationError: 401/403 (PROTOCOL_TOO_OLD, PROTOCOL_TOO_NEW, API_KEY_REVOKED, CHAIN_CROSS_ORG). NullRunConsumeOverbudgetError: 422 (placeholder for /track; - not raised on /check). + not raised on /gate). NullRunBudgetError: 402 BUDGET_HARD_BLOCKED / BUDGET_SOFT_BLOCKED / BUDGET_OVERDRAFT_EXCEEDED. NullRunChainError: 402 CHAIN_MAX_DURATION_EXCEEDED / @@ -1771,42 +1763,12 @@ def check_v3( NullRunBackendError: 5xx / BUDGET_DATA_UNAVAILABLE / RATE_LIMIT_REDIS_UNAVAILABLE. """ - gate_request = dict(request) - headers = self._build_signed_headers() - body = _signed_request_body(gate_request) - - try: - response = self._client.post( - f"{self.api_url}/api/v1/check", - content=body, - headers=headers, - timeout=5.0, - ) - except httpx.RequestError as e: - if on_transport_error == "raise": - raise NullRunTransportError( - f"Network error on /check: {e}", - source=TransportErrorSource.NETWORK_ERROR, - endpoint="check", - ) from e - logger.warning(f"/check request failed: {e}") - return { - "decision": "block", - "decision_source": DecisionSource.FALLBACK, - "execution_id": None, - "remaining_budget_cents": 0, - "projected_cost_cents": 0, - "explanations": [f"/check request failed: {e}"], - "suggestions": ["Check API availability"], - } - - if response.status_code == 200: - data = response.json() - data.setdefault("decision_source", DecisionSource.GATEWAY) - return data # type: ignore[no-any-return] - - # Non-2xx — map through the v3 error envelope parser. - raise _parse_v3_error_envelope(response, "check") + # drift.md 2026-07-04 (B1): /api/v1/check returns 410 Gone. + # ``check()`` already targets /api/v1/gate with all v3 wire + # fields forwarded (chain_id, chain_op, idempotency_key, + # stream, tools). Delegate rather than duplicate the wire + # shape — single source of truth for the v3 body. + return self.check(request, on_transport_error=on_transport_error) def track_single( self, @@ -1820,13 +1782,33 @@ def track_single( ``actual_cost <= reserved_cents + epsilon_cents`` (§25, ADR-005) and rejects with 422 CONSUME_OVERBUDGET on violation. The reserved binding is the one created by the - matching ``/check`` call (same ``execution_id``). + matching ``/check`` call (same ``reservation_id``). + + The wire shape is built by ``runtime._build_v3_track_payload`` + (see ``runtime.py:2679-2776``); this method just forwards + whatever dict the caller hands it. The post-fix schema is: Args: - request: Consume request body. Must include - ``execution_id``, ``actual_cost_cents``, - ``api_key_id``. Optional ``cost_source`` - (``"provisional"`` / ``"authoritative"``) — see §22. + request: Consume request body. Must include: + + * ``reservation_id`` (str, server-minted uuidv7 from + the matching /check response — wired via + ``_capture_server_minted_execution_id``) + * ``workflow_id`` (str, the workflow the call belongs to) + * ``tokens`` (int, sum of input + output tokens) + * ``cost_cents`` (int, ``0`` — backend computes the + authoritative cost from tokens + the org's + pricing policy; sending a wrong number risks + double-billing, see _WIRE_STRIP_FIELDS in runtime.py) + * ``cost_source`` (str, ``"provisional"`` / + ``"authoritative"`` per §22 — SDK always emits + ``"provisional"``) + + Optional fields: ``input_tokens``, ``output_tokens``, + ``model``, ``latency_ms``, ``metadata``, ``trace_id``, + ``span_id``, ``agent_id``, ``environment``, + ``agent_type``, ``attempt_index``, ``is_retry``, + ``idempotency_key``. Returns: Parsed JSON dict with at least @@ -1839,6 +1821,17 @@ def track_single( NullRunBackendError: 503 RESERVATION_NOT_FOUND / EXECUTION_NOT_BOUND. NullRunAuthenticationError: 401/403. + + drift.md 2026-07-04 (B2): pre-fix this docstring (and the + surrounding module comment) described a fictitious wire + shape ``{execution_id, actual_cost_cents, api_key_id, + cost_source}``. The backend's actual ``TrackRequestRaw`` is + ``{workflow_id, tokens, cost_cents, ...}``; ``execution_id`` + is replaced by ``reservation_id``, ``actual_cost_cents`` is + replaced by ``cost_cents`` (the SDK always sends 0 — see + ``_WIRE_STRIP_FIELDS``), and ``api_key_id`` is derived + server-side from the request auth, not supplied by the SDK. + The docstring now matches the real wire contract. """ headers = self._build_signed_headers() body = _signed_request_body(request) @@ -1964,35 +1957,58 @@ def chain_end( self, chain_id: str, ) -> dict[str, Any]: - """POST /api/v1/chain/end — close a chain explicitly. - - CLAUDE.md §6 (chain state machine). The handler is already - idempotent — a no-op 200 OK for an unknown chain_id is the - documented success path. The SDK still raises through the - envelope parser on a true non-2xx so unexpected backend + """Close a chain explicitly via /api/v1/gate with chain_op=end + (CLAUDE.md §6, drift.md B3 fix 2026-07-04). + + Pre-fix this method POSTed to ``/api/v1/chain/end``. That + endpoint was never registered on the backend + (``backend/src/proxy/http/routes.rs`` has zero matches for + ``chain/end`` or ``chain_end_handler``) — the only documented + way to close a chain is to POST /api/v1/gate with + ``{"chain_id": "...", "chain_op": "end"}``. The handler is + already idempotent — a no-op 200 OK for an unknown chain_id + is the documented success path. The SDK still raises through + the envelope parser on a true non-2xx so unexpected backend regressions surface. Args: chain_id: Chain to close. Returns: - Parsed JSON dict (typically ``{"status": "ok", + Parsed JSON dict (typically ``{"decision": "allow", "chain_id": ...}``). """ - request = {"chain_id": chain_id} + # drift.md 2026-07-04 (B3): POST /api/v1/gate with + # ``chain_op: "end"``. The backend's gate handler + # (``backend/src/proxy/http/gate/gate.rs``) accepts the same + # body shape as ``check()`` — the ``chain_op`` field routes + # the request through the chain state machine rather than the + # budget reserve path. No execution_id minting or reservation + # is created on this code path (the chain is being torn down, + # not started), so we reuse the caller's chain_id as a stable + # placeholder for the signature. + request = { + "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 per + # call (the server ignores it on this path). + "execution_id": uuid.uuid4().hex, + } headers = self._build_signed_headers() body = _signed_request_body(request) try: response = self._client.post( - f"{self.api_url}/api/v1/chain/end", + f"{self.api_url}/api/v1/gate", content=body, headers=headers, timeout=5.0, ) except httpx.RequestError as e: raise NullRunTransportError( - f"Network error on /chain/end: {e}", + f"Network error on /gate (chain_end): {e}", source=TransportErrorSource.NETWORK_ERROR, endpoint="chain_end", ) from e @@ -2037,10 +2053,19 @@ def approximate_budget( # ApproximateBudget uses GET (not POST) per the wire contract; # no signed body, so we use _auth_headers() directly instead # of _build_signed_headers(). + # + # drift.md 2026-07-04 (M3 fix): the backend's + # ``approximate_budget_handler`` (``backend/src/proxy/http/ + # budget.rs:130-145``) resolves the org from the X-API-Key + # / Authorization header — it does NOT take a ``organization_id`` + # query parameter. Pre-fix this method appended + # ``?organization_id=...`` to the URL, which the backend + # ignored silently and the audit flagged as drift. We now + # call the bare URL and keep the ``organization_id`` arg as + # an accepted-but-unused parameter for backward compatibility + # with any external caller that still passes it. headers = self._auth_headers_for_get() url = f"{self.api_url}/api/v1/budget/approximate" - if organization_id: - url += f"?organization_id={organization_id}" try: response = self._client.get(url, headers=headers, timeout=5.0) diff --git a/tests/test_v3_wire_contract.py b/tests/test_v3_wire_contract.py index d365b55..81e6a82 100644 --- a/tests/test_v3_wire_contract.py +++ b/tests/test_v3_wire_contract.py @@ -126,9 +126,13 @@ def test_check_includes_protocol_header(self): @respx.mock def test_check_v3_includes_protocol_header(self): + # drift.md 2026-07-04 (B1): ``check_v3`` now delegates to + # ``check()`` which targets /api/v1/gate (the + # /api/v1/check endpoint was removed 2026-06-27 and returns + # 410 Gone). Wire the mock against /api/v1/gate to match. t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") try: - route = respx.post(f"{BASE_URL}/api/v1/check").mock( + route = respx.post(f"{BASE_URL}/api/v1/gate").mock( return_value=Response( 200, json={ @@ -146,12 +150,28 @@ def test_check_v3_includes_protocol_header(self): @respx.mock def test_track_single_includes_protocol_header(self): + # drift.md 2026-07-04 (B2): body shape matches the v3 wire + # contract — ``reservation_id`` (server-minted from /check), + # ``workflow_id`` + ``tokens`` + ``cost_cents`` (the SDK + # always emits 0 — backend recomputes from tokens) + + # ``cost_source: "provisional"``. Pre-fix this test sent the + # legacy / fictitious shape + # ``{execution_id, actual_cost_cents}`` which doesn't match + # ``TrackRequestRaw`` and would 422 on the wire. t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") try: route = respx.post(f"{BASE_URL}/api/v1/track").mock( return_value=Response(200, json={"status": "ok"}) ) - t.track_single({"execution_id": "exec-1", "actual_cost_cents": 5}) + t.track_single( + { + "reservation_id": "00000000-0000-0000-0000-000000000099", + "workflow_id": "wf-1", + "tokens": 100, + "cost_cents": 0, + "cost_source": "provisional", + } + ) sent = route.calls.last.request assert sent.headers["X-NULLRUN-PROTOCOL"] == "3" finally: @@ -185,14 +205,20 @@ def test_heartbeat_includes_protocol_header(self): @respx.mock def test_chain_end_includes_protocol_header(self): + # drift.md 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. t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") try: - route = respx.post(f"{BASE_URL}/api/v1/chain/end").mock( - return_value=Response(200, json={"status": "ok"}) + route = respx.post(f"{BASE_URL}/api/v1/gate").mock( + return_value=Response(200, json={"decision": "allow"}) ) t.chain_end("chain-abc") sent = route.calls.last.request assert sent.headers["X-NULLRUN-PROTOCOL"] == "3" + body = sent.content.decode("utf-8") + assert '"chain_id":"chain-abc"' in body + assert '"chain_op":"end"' in body finally: t.stop() @@ -319,9 +345,12 @@ def test_check_omits_chain_id_when_not_provided(self): @respx.mock def test_check_v3_accepts_chain_context(self): + # drift.md 2026-07-04 (B1): ``check_v3`` delegates to + # ``check()`` which posts to /api/v1/gate. The /api/v1/check + # endpoint returns 410 Gone since 2026-06-27. t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") try: - route = respx.post(f"{BASE_URL}/api/v1/check").mock( + route = respx.post(f"{BASE_URL}/api/v1/gate").mock( return_value=Response( 200, json={ @@ -774,15 +803,18 @@ class TestChainEndEndpoint: @respx.mock def test_chain_end_sends_chain_id_in_body(self): + # drift.md 2026-07-04 (B3): chain_end targets /api/v1/gate + # with chain_op=end. Verify both fields land on the wire. t = Transport(api_url=BASE_URL, api_key="nr_live_abc123") try: - route = respx.post(f"{BASE_URL}/api/v1/chain/end").mock( - return_value=Response(200, json={"status": "ok"}) + route = respx.post(f"{BASE_URL}/api/v1/gate").mock( + return_value=Response(200, json={"decision": "allow"}) ) t.chain_end("chain-1") sent = route.calls.last.request body = sent.content.decode("utf-8") assert '"chain_id":"chain-1"' in body + assert '"chain_op":"end"' in body finally: t.stop()