Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ CLAUDE.md
analyze.md
docs/integration-baseline-2026-06-19.md
audit.md
docs/postman/
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 62 additions & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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"
173 changes: 99 additions & 74 deletions src/nullrun/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 /
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading