@@ -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