Skip to content

Commit 393d7a8

Browse files
authored
release(0.13.1): drift-fixes — B1 check_v3 + B3 chain_end + M3 approximate_budget + B2 track_single docstring (#55)
* release(0.13.1): drift-fixes — B1 check_v3 + B3 chain_end + M3 approximate_budget + B2 track_single docstring Drift audit 2026-07-04 (docs/drift.md) found 4 BLOCKERs still active in 0.13.0 SDK vs backend wire contract. All four closed: * B1: Transport.check_v3 (drift B1) — was POSTing to /api/v1/check (removed 2026-06-27, 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; check_v3 is kept as a v3-named alias for backward compat. * B2: Transport.track_single docstring + test body — described fictitious wire shape {execution_id, actual_cost_cents, api_key_id, cost_source}. Real TrackRequestRaw is {workflow_id, tokens, cost_cents, ...} (built by runtime._build_v3_track_payload) — execution_id is replaced by reservation_id, SDK always emits cost_cents:0 (backend recomputes from tokens), api_key_id is derived server-side. Docstring + test now match real contract. * B3: Transport.chain_end (drift B3) — was POSTing to /api/v1/chain/end (never registered on backend). Now POSTs to /api/v1/gate with chain_op: "end" (matches documented backend contract). * M3: Transport.approximate_budget (drift M3) — was appending ?organization_id=<id>. Backend resolves org from X-API-Key / Authorization header, does NOT accept query params. Method now calls bare URL. organization_id arg retained as accepted-but- unused for backward compat. Tests touched (tests/test_v3_wire_contract.py): * test_check_v3_includes_protocol_header — re-mocked /api/v1/gate * test_check_v3_accepts_chain_context — re-mocked /api/v1/gate * test_chain_end_includes_protocol_header — re-mocked /api/v1/gate + added chain_op=end check * test_chain_end_sends_chain_id_in_body — re-mocked /api/v1/gate + added chain_op=end check * test_track_single_includes_protocol_header — body matches real wire shape 1037 lib tests pass (no regression). No SDK_MIN_VERSION bump — wire format unchanged from caller's perspective; only URLs + docstrings changed. Recommended upgrade path: 0.13.0 -> 0.13.1. * chore(gitignore): exclude docs/postman/ collection exports The Postman collection + environments were exported under docs/postman/ for ad-hoc local API exploration. They are working artifacts, not part of the SDK source surface — keeping them on disk but out of VCS.
1 parent eeea0d1 commit 393d7a8

5 files changed

Lines changed: 205 additions & 83 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,4 @@ CLAUDE.md
6969
analyze.md
7070
docs/integration-baseline-2026-06-19.md
7171
audit.md
72+
docs/postman/

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ name = "nullrun"
99
# decision exception + fail-CLOSED/OPEN honesty in module docstring).
1010
# No on-wire breaking change; backends on 1.0.0 keep working
1111
# unchanged. See docs/drift.md for the full audit trail.
12-
version = "0.13.0"
12+
# 0.13.1 (2026-07-04): drift-fixes release — see __version__.py
13+
# for the four BLOCKER closes (B1 check_v3, B2 track_single
14+
# docstring, B3 chain_end, M3 approximate_budget query param).
15+
version = "0.13.1"
1316
# Long form used by PyPI page meta-description and search snippets.
1417
# Kept under the 200-char preview threshold so the full line is visible
1518
# without an "expand" click. Keywords are matched against likely search

src/nullrun/__version__.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,68 @@
140140
SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade
141141
path: 0.12.2 -> 0.13.0 (no on-wire breaking change; the SDK
142142
will pick up the new idempotency_key stamping automatically).
143+
144+
---
145+
146+
v3.15 / 0.13.1 (2026-07-04) — drift-fixes release: closes the four
147+
BLOCKER items from the SDK↔backend drift audit that were still active
148+
in 0.13.0.
149+
150+
1. ``Transport.check_v3`` (drift B1): was POSTing to ``/api/v1/check``
151+
(removed 2026-06-27 — handler now returns 410 Gone with
152+
``replacement: /api/v1/gate``). Now delegates to ``Transport.check``
153+
which targets ``/api/v1/gate`` and forwards all v3 wire fields
154+
(``chain_id``, ``chain_op``, ``idempotency_key``, ``stream``).
155+
``check()`` is the canonical entry point; ``check_v3`` is kept
156+
as a v3-named alias for callers/tests that already use it.
157+
158+
2. ``Transport.track_single`` docstring + ``tests/test_v3_wire_contract.py::
159+
test_track_single_includes_protocol_header`` body (drift B2): the
160+
docstring described a fictitious wire shape ``{execution_id,
161+
actual_cost_cents, api_key_id, cost_source}``. The real backend
162+
``TrackRequestRaw`` is ``{workflow_id, tokens, cost_cents, ...}``
163+
(built by ``runtime._build_v3_track_payload``) — ``execution_id``
164+
is replaced by ``reservation_id``, and the SDK always emits
165+
``cost_cents: 0`` because the backend recomputes the authoritative
166+
cost from tokens + the org's pricing policy (see
167+
``_WIRE_STRIP_FIELDS`` in runtime.py). ``api_key_id`` is derived
168+
server-side from the request auth, not supplied by the SDK.
169+
Docstring + test body now match the real contract.
170+
171+
3. ``Transport.chain_end`` (drift B3): was POSTing to
172+
``/api/v1/chain/end`` — that endpoint was never registered on
173+
the backend (``backend/src/proxy/http/routes.rs`` has zero
174+
matches). Now POSTs to ``/api/v1/gate`` with ``chain_op: "end"``
175+
(matches the documented backend contract from
176+
``backend/src/proxy/http/cancel.rs:39``'s own comment).
177+
178+
4. ``Transport.approximate_budget`` (drift M3): was appending
179+
``?organization_id=<id>`` to the URL. The backend's
180+
``approximate_budget_handler`` (``backend/src/proxy/http/
181+
budget.rs:130-145``) resolves the org from the X-API-Key /
182+
Authorization header — it does NOT accept a query parameter.
183+
The method now calls the bare URL. The ``organization_id``
184+
argument is retained as an accepted-but-unused parameter for
185+
backward compatibility with any external caller that still
186+
passes it (silently no-ops).
187+
188+
Tests touched (in ``tests/test_v3_wire_contract.py``):
189+
* ``test_check_v3_includes_protocol_header`` — re-mocked against
190+
/api/v1/gate (was /api/v1/check).
191+
* ``test_check_v3_accepts_chain_context`` — re-mocked against
192+
/api/v1/gate (was /api/v1/check).
193+
* ``test_chain_end_includes_protocol_header`` — re-mocked against
194+
/api/v1/gate (was /api/v1/chain/end); added chain_op=end check.
195+
* ``test_chain_end_sends_chain_id_in_body`` — re-mocked against
196+
/api/v1/gate (was /api/v1/chain/end); added chain_op=end check.
197+
* ``test_track_single_includes_protocol_header`` — body now matches
198+
the real wire shape (reservation_id + workflow_id + tokens +
199+
cost_cents:0 + cost_source:"provisional").
200+
201+
1037 lib tests pass (no regression). Recommended upgrade path:
202+
0.13.0 -> 0.13.1. No SDK_MIN_VERSION bump — wire format is the same
203+
from the caller's perspective; only the URLs and docstrings changed.
143204
"""
144205

145-
__version__ = "0.13.0"
206+
__version__ = "0.13.1"
146207
__platform_version__ = "1.0.0"

src/nullrun/transport.py

Lines changed: 99 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,30 +1727,22 @@ def check_v3(
17271727
request: dict[str, Any],
17281728
on_transport_error: Callable[[Exception], dict[str, Any]] | str | None = None,
17291729
) -> dict[str, Any]:
1730-
"""POST /api/v1/check — wire-protocol v3 pre-execution gate.
1731-
1732-
CLAUDE.md §3, §16, §22-§24. The v3 replacement for ``check()``
1733-
(/api/v1/gate). Adds three new optional fields on top of the
1734-
v2 wire shape:
1735-
1736-
* ``chain_id`` (UUID v4, optional) — pairs with ``chain_op``
1737-
(``"start"`` / ``"continue"`` / ``"end"``). Enables the
1738-
backend's soft-mode gate (§5) which only allows budget
1739-
overdrafts when a chain is active.
1740-
* ``chain_op`` (string, optional) — ``"start"`` creates a
1741-
chain in REGISTERED state, ``"continue"`` extends the TTL,
1742-
``"end"`` closes the chain. Absent means auto-register.
1743-
* ``idempotency_key`` (UUID v4, optional) — replays return
1744-
the original decision instead of re-running the gate.
1745-
1746-
The response carries a server-minted ``execution_id`` (§24) —
1747-
callers MUST NOT treat the request's ``execution_id`` field
1748-
as authoritative; the backend overwrites it on the response.
1730+
"""Pre-execution gate — wire-protocol v3 (drift.md B1 fix 2026-07-04).
1731+
1732+
Pre-fix this method POSTed to ``/api/v1/check``. That endpoint
1733+
was removed on 2026-06-27 — the handler now returns
1734+
``410 Gone`` with a ``replacement: /api/v1/gate`` hint. The
1735+
SDK's ``check()`` method already targets ``/api/v1/gate`` and
1736+
forwards every v3 wire field (CLAUDE.md §16) — ``chain_id``,
1737+
``chain_op``, ``idempotency_key``, ``stream``. This method
1738+
is kept as a v3-named alias so existing call sites and tests
1739+
continue to work; internally it delegates to ``check()`` with
1740+
the same body.
17491741
17501742
Args:
17511743
request: Gate request body. Must include ``organization_id``,
17521744
``execution_id`` (for backward compat — server mints its
1753-
own), ``operation_id``, and ``check_type``.
1745+
own on /check), ``operation_id``, and ``check_type``.
17541746
on_transport_error: Mirrors the ``check()`` flag.
17551747
17561748
Returns:
@@ -1762,7 +1754,7 @@ def check_v3(
17621754
NullRunAuthenticationError: 401/403 (PROTOCOL_TOO_OLD,
17631755
PROTOCOL_TOO_NEW, API_KEY_REVOKED, CHAIN_CROSS_ORG).
17641756
NullRunConsumeOverbudgetError: 422 (placeholder for /track;
1765-
not raised on /check).
1757+
not raised on /gate).
17661758
NullRunBudgetError: 402 BUDGET_HARD_BLOCKED /
17671759
BUDGET_SOFT_BLOCKED / BUDGET_OVERDRAFT_EXCEEDED.
17681760
NullRunChainError: 402 CHAIN_MAX_DURATION_EXCEEDED /
@@ -1771,42 +1763,12 @@ def check_v3(
17711763
NullRunBackendError: 5xx / BUDGET_DATA_UNAVAILABLE /
17721764
RATE_LIMIT_REDIS_UNAVAILABLE.
17731765
"""
1774-
gate_request = dict(request)
1775-
headers = self._build_signed_headers()
1776-
body = _signed_request_body(gate_request)
1777-
1778-
try:
1779-
response = self._client.post(
1780-
f"{self.api_url}/api/v1/check",
1781-
content=body,
1782-
headers=headers,
1783-
timeout=5.0,
1784-
)
1785-
except httpx.RequestError as e:
1786-
if on_transport_error == "raise":
1787-
raise NullRunTransportError(
1788-
f"Network error on /check: {e}",
1789-
source=TransportErrorSource.NETWORK_ERROR,
1790-
endpoint="check",
1791-
) from e
1792-
logger.warning(f"/check request failed: {e}")
1793-
return {
1794-
"decision": "block",
1795-
"decision_source": DecisionSource.FALLBACK,
1796-
"execution_id": None,
1797-
"remaining_budget_cents": 0,
1798-
"projected_cost_cents": 0,
1799-
"explanations": [f"/check request failed: {e}"],
1800-
"suggestions": ["Check API availability"],
1801-
}
1802-
1803-
if response.status_code == 200:
1804-
data = response.json()
1805-
data.setdefault("decision_source", DecisionSource.GATEWAY)
1806-
return data # type: ignore[no-any-return]
1807-
1808-
# Non-2xx — map through the v3 error envelope parser.
1809-
raise _parse_v3_error_envelope(response, "check")
1766+
# drift.md 2026-07-04 (B1): /api/v1/check returns 410 Gone.
1767+
# ``check()`` already targets /api/v1/gate with all v3 wire
1768+
# fields forwarded (chain_id, chain_op, idempotency_key,
1769+
# stream, tools). Delegate rather than duplicate the wire
1770+
# shape — single source of truth for the v3 body.
1771+
return self.check(request, on_transport_error=on_transport_error)
18101772

18111773
def track_single(
18121774
self,
@@ -1820,13 +1782,33 @@ def track_single(
18201782
``actual_cost <= reserved_cents + epsilon_cents`` (§25,
18211783
ADR-005) and rejects with 422 CONSUME_OVERBUDGET on
18221784
violation. The reserved binding is the one created by the
1823-
matching ``/check`` call (same ``execution_id``).
1785+
matching ``/check`` call (same ``reservation_id``).
1786+
1787+
The wire shape is built by ``runtime._build_v3_track_payload``
1788+
(see ``runtime.py:2679-2776``); this method just forwards
1789+
whatever dict the caller hands it. The post-fix schema is:
18241790
18251791
Args:
1826-
request: Consume request body. Must include
1827-
``execution_id``, ``actual_cost_cents``,
1828-
``api_key_id``. Optional ``cost_source``
1829-
(``"provisional"`` / ``"authoritative"``) — see §22.
1792+
request: Consume request body. Must include:
1793+
1794+
* ``reservation_id`` (str, server-minted uuidv7 from
1795+
the matching /check response — wired via
1796+
``_capture_server_minted_execution_id``)
1797+
* ``workflow_id`` (str, the workflow the call belongs to)
1798+
* ``tokens`` (int, sum of input + output tokens)
1799+
* ``cost_cents`` (int, ``0`` — backend computes the
1800+
authoritative cost from tokens + the org's
1801+
pricing policy; sending a wrong number risks
1802+
double-billing, see _WIRE_STRIP_FIELDS in runtime.py)
1803+
* ``cost_source`` (str, ``"provisional"`` /
1804+
``"authoritative"`` per §22 — SDK always emits
1805+
``"provisional"``)
1806+
1807+
Optional fields: ``input_tokens``, ``output_tokens``,
1808+
``model``, ``latency_ms``, ``metadata``, ``trace_id``,
1809+
``span_id``, ``agent_id``, ``environment``,
1810+
``agent_type``, ``attempt_index``, ``is_retry``,
1811+
``idempotency_key``.
18301812
18311813
Returns:
18321814
Parsed JSON dict with at least
@@ -1839,6 +1821,17 @@ def track_single(
18391821
NullRunBackendError: 503 RESERVATION_NOT_FOUND /
18401822
EXECUTION_NOT_BOUND.
18411823
NullRunAuthenticationError: 401/403.
1824+
1825+
drift.md 2026-07-04 (B2): pre-fix this docstring (and the
1826+
surrounding module comment) described a fictitious wire
1827+
shape ``{execution_id, actual_cost_cents, api_key_id,
1828+
cost_source}``. The backend's actual ``TrackRequestRaw`` is
1829+
``{workflow_id, tokens, cost_cents, ...}``; ``execution_id``
1830+
is replaced by ``reservation_id``, ``actual_cost_cents`` is
1831+
replaced by ``cost_cents`` (the SDK always sends 0 — see
1832+
``_WIRE_STRIP_FIELDS``), and ``api_key_id`` is derived
1833+
server-side from the request auth, not supplied by the SDK.
1834+
The docstring now matches the real wire contract.
18421835
"""
18431836
headers = self._build_signed_headers()
18441837
body = _signed_request_body(request)
@@ -1964,35 +1957,58 @@ def chain_end(
19641957
self,
19651958
chain_id: str,
19661959
) -> dict[str, Any]:
1967-
"""POST /api/v1/chain/end — close a chain explicitly.
1968-
1969-
CLAUDE.md §6 (chain state machine). The handler is already
1970-
idempotent — a no-op 200 OK for an unknown chain_id is the
1971-
documented success path. The SDK still raises through the
1972-
envelope parser on a true non-2xx so unexpected backend
1960+
"""Close a chain explicitly via /api/v1/gate with chain_op=end
1961+
(CLAUDE.md §6, drift.md B3 fix 2026-07-04).
1962+
1963+
Pre-fix this method POSTed to ``/api/v1/chain/end``. That
1964+
endpoint was never registered on the backend
1965+
(``backend/src/proxy/http/routes.rs`` has zero matches for
1966+
``chain/end`` or ``chain_end_handler``) — the only documented
1967+
way to close a chain is to POST /api/v1/gate with
1968+
``{"chain_id": "...", "chain_op": "end"}``. The handler is
1969+
already idempotent — a no-op 200 OK for an unknown chain_id
1970+
is the documented success path. The SDK still raises through
1971+
the envelope parser on a true non-2xx so unexpected backend
19731972
regressions surface.
19741973
19751974
Args:
19761975
chain_id: Chain to close.
19771976
19781977
Returns:
1979-
Parsed JSON dict (typically ``{"status": "ok",
1978+
Parsed JSON dict (typically ``{"decision": "allow",
19801979
"chain_id": ...}``).
19811980
"""
1982-
request = {"chain_id": chain_id}
1981+
# drift.md 2026-07-04 (B3): POST /api/v1/gate with
1982+
# ``chain_op: "end"``. The backend's gate handler
1983+
# (``backend/src/proxy/http/gate/gate.rs``) accepts the same
1984+
# body shape as ``check()`` — the ``chain_op`` field routes
1985+
# the request through the chain state machine rather than the
1986+
# budget reserve path. No execution_id minting or reservation
1987+
# is created on this code path (the chain is being torn down,
1988+
# not started), so we reuse the caller's chain_id as a stable
1989+
# placeholder for the signature.
1990+
request = {
1991+
"chain_id": chain_id,
1992+
"chain_op": "end",
1993+
# execution_id is required by the backend's gate handler
1994+
# even on chain_end — the handler reads it but does not
1995+
# mint a reservation for op=end. Use a fresh uuidv7 per
1996+
# call (the server ignores it on this path).
1997+
"execution_id": uuid.uuid4().hex,
1998+
}
19831999
headers = self._build_signed_headers()
19842000
body = _signed_request_body(request)
19852001

19862002
try:
19872003
response = self._client.post(
1988-
f"{self.api_url}/api/v1/chain/end",
2004+
f"{self.api_url}/api/v1/gate",
19892005
content=body,
19902006
headers=headers,
19912007
timeout=5.0,
19922008
)
19932009
except httpx.RequestError as e:
19942010
raise NullRunTransportError(
1995-
f"Network error on /chain/end: {e}",
2011+
f"Network error on /gate (chain_end): {e}",
19962012
source=TransportErrorSource.NETWORK_ERROR,
19972013
endpoint="chain_end",
19982014
) from e
@@ -2037,10 +2053,19 @@ def approximate_budget(
20372053
# ApproximateBudget uses GET (not POST) per the wire contract;
20382054
# no signed body, so we use _auth_headers() directly instead
20392055
# of _build_signed_headers().
2056+
#
2057+
# drift.md 2026-07-04 (M3 fix): the backend's
2058+
# ``approximate_budget_handler`` (``backend/src/proxy/http/
2059+
# budget.rs:130-145``) resolves the org from the X-API-Key
2060+
# / Authorization header — it does NOT take a ``organization_id``
2061+
# query parameter. Pre-fix this method appended
2062+
# ``?organization_id=...`` to the URL, which the backend
2063+
# ignored silently and the audit flagged as drift. We now
2064+
# call the bare URL and keep the ``organization_id`` arg as
2065+
# an accepted-but-unused parameter for backward compatibility
2066+
# with any external caller that still passes it.
20402067
headers = self._auth_headers_for_get()
20412068
url = f"{self.api_url}/api/v1/budget/approximate"
2042-
if organization_id:
2043-
url += f"?organization_id={organization_id}"
20442069

20452070
try:
20462071
response = self._client.get(url, headers=headers, timeout=5.0)

0 commit comments

Comments
 (0)