diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8b0a3..2423497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,41 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [0.12.1] - 2026-07-04 + +Bug-fix release. The v0.12.0 changelog claimed the SDK propagates the server-minted `execution_id` from /check to /track but the wiring was never shipped — the SDK still sent client-supplied ids on /track/batch and ignored `reservation_id` on /check responses (audit fix per memory `sdk-v3-migration-gaps`). + +This release closes the four gaps documented in `docs/sdk-v3-migration-gaps.md`: + +- `check_workflow_budget()` now reads `response["reservation_id"]` and stores it on a contextvar (`nullrun.context._server_minted_execution_id_var`). +- New helpers `set_server_minted_execution_id` / `get_server_minted_execution_id` / `reset_server_minted_execution_id` + a paired `_server_minted_reservation_at` timestamp for the 295s TTL guard. +- `_enrich_event` stamps `execution_id` onto the /track payload when the captured reservation is fresh, and drops it (clearing the capture) once past the safety window — prevents forwarding a doomed id that would 503 on /track per CLAUDE.md section 33. +- `_route_track` routes `llm_call` events to the v3 `/api/v1/track` single-event endpoint via `Transport.track_single()` so backend `gate_consume_v3` validates the consume-vs-reserve + epsilon invariant (CLAUDE.md section 25). Span / tool events keep using the legacy `/api/v1/track/batch`. +- `NULLRUN_V3_TRACK_DISABLE=1` opt-out forces everything through the legacy batch path (backends still on v1/v2). + +### Added + +- `nullrun.context._server_minted_execution_id_var` + `nullrun.context._server_minted_reservation_at_var` + 6 helpers (`get_/set_/reset_/clear_`). +- `nullrun.runtime._capture_server_minted_execution_id(response)` — defensive UUID parse + warn-on-malformed. +- `nullrun.runtime._route_track(wire_event)` — dispatches to single-event /track or batch /track/batch. +- `nullrun.runtime._build_v3_track_payload(event, reservation_id)` — maps an enriched event onto the v3 /track wire schema. +- 27 contract tests in `tests/test_v3_server_minted.py` covering contextvar hygiene, capture defence-in-depth, _enrich_event age threshold, _route_track dispatch, and end-to-end /gate -> /track round trip. + +### Changed + +- `__version__` bumped from 0.12.0 to 0.12.1 (post-release integrity fix — the v0.12.0 wiring never shipped before this). + +### Fixed + +- SDK no longer treats the /check `reservation_id` field as decorative. Each LLM-call track event now carries the server-minted uuidv7 the backend minted, so v3 `gate_consume_v3` can find the matching `reservation:{execution_id}` Redis key (300s TTL). +- LLM-call events now POST to `/api/v1/track` (v3 single-event) instead of `/api/v1/track/batch`. This exercises the consume-vs-reserve invariant that the batch path silently skipped (regression of the v1/v2 `monthly_cost` counter — see CLAUDE.md section 0 G1). + ## [0.12.0] - 2026-07-03 Server-minted execution_id default ON. Per CLAUDE.md section 24, every /check now mints a server-side uuidv7 execution_id. The SDK no longer needs to generate its own; the response carries the server-minted id which propagates to /track. This is the SDK_MIN_VERSION for the v3 rollout - older SDKs still work for v1/v2 endpoints but should upgrade. +> **Integrity note (2026-07-04):** the propagation claim in this entry was correct in intent but the actual wiring was not shipped in 0.12.0. See 0.12.1 above for the closing fix. + ### Added - `nullrun.uuid7` module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs. diff --git a/docs/sdk-v3-migration-gaps.md b/docs/sdk-v3-migration-gaps.md new file mode 100644 index 0000000..4864ae3 --- /dev/null +++ b/docs/sdk-v3-migration-gaps.md @@ -0,0 +1,36 @@ +# SDK 0.12.0 → 0.12.1 v3 migration history + +This document records the four gaps that existed in the v0.12.0 wire-up +of the SDK's server-minted `execution_id` propagation. **It is kept as +historical evidence of the integrity bug** — the gaps are now closed in +0.12.1 (see `CHANGELOG.md`). + +The current canonical implementation lives in: + +- `src/nullrun/context.py` — `_server_minted_execution_id_var`, + `_server_minted_reservation_at_var` + 6 helpers + (`get_/set_/reset_/clear_` × 2 vars). +- `src/nullrun/runtime.py` — `_capture_server_minted_execution_id`, + `_route_track`, `_build_v3_track_payload`, + `SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS = 295.0`. +- `tests/test_v3_server_minted.py` — 27 contract tests pinning each + step (no live backend required; uses respx to mock /gate, /track, + /track/batch). + +Reference: backend `gate/http/internal.rs::reserve_v3_enabled` mints +the uuidv7 server-side; `proxy/handlers.rs::gate_consume_v3` validates +the v3 reserve→consume invariant (consume ≤ reserve + ε_cents, +CLAUDE.md §25 + ADR-005). + +## Why this history matters + +0.12.0's `__version__.py` docstring (and the v3.12 backend changelog) +promised propagation that was not yet implemented. The integrity bug +surfaced only when an operator audit compared the version bump against +the actual code paths in `runtime.py::1189-1227`, `_enrich_event`, +and `transport.py::track()`. The fix in 0.12.1 closes the loop and +makes the version honest. + +If you ever see a v0.12.0 release without a 0.12.1+ in the same deploy, +treat that deployment as drift — the v3 /track wiring was not yet +active at that version. diff --git a/pyproject.toml b/pyproject.toml index 9402dac..1fe6be6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,15 @@ build-backend = "hatchling.build" [project] name = "nullrun" -# Version bump: 0.11.0 → 0.12.0 in `feat(sdk)` (server-minted -# execution_id default + capability probe). The previous commit -# updated `src/nullrun/__version__.py` but missed this field, so -# `python -m build` was producing a wheel named `nullrun-0.11.0-*` -# that collided with the 0.11.0 artifact already on TestPyPI -# (causing the publish-test workflow to fail with HTTP 400 -# "File already exists"). -version = "0.12.0" +# Version bump: 0.12.0 → 0.12.1 in `release(0.12.1)` (server-minted +# execution_id /check → /track wiring fix). Recurrence of the same +# drift that #50 fixed for 0.12.0: the runtime commit bumped +# `src/nullrun/__version__.py` but missed this field, so without +# this sync-up `python -m build` would produce a wheel named +# `nullrun-0.12.0-*` and PyPI Trusted Publishing would reject the +# upload with HTTP 400 "File already exists" (the 0.12.0 artifact +# is already live). +version = "0.12.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 dffe88e..cc0a8cb 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -1,17 +1,50 @@ """NullRun Platform SDK. -v3.12 (2026-07-03) — server-minted execution_id default ON. +v3.12 / 0.12.0 (2026-07-03) — server-minted execution_id default ON. The backend `gate_reserve_v3` now mints a uuidv7 execution_id -internally (CLAUDE.md §24). The SDK no longer needs to generate -its own `execution_id` for /check; it gets the server-minted -one back in the response and propagates it to /track. This -version (`0.12.0`) is the SDK_MIN_VERSION for the v3 rollout — -older SDKs continue to work because the gate IGNORES the -client-supplied execution_id (it mints its own), but they -should upgrade for proper /track binding propagation and the -new `capabilities()` probe. +internally (CLAUDE.md §24). This version (`0.12.0`) is the +SDK_MIN_VERSION for the v3 rollout — older SDKs continue to +work because the gate IGNORES the client-supplied execution_id +(it mints its own), but they cannot fully participate in the +v3 /track idempotency contract. + +--- + +v3.12 / 0.12.1 (2026-07-04) — bug-fix: complete the wiring +that 0.12.0 advertised. + +Honest history: the v0.12.0 changelog entry above said "the +SDK no longer needs to generate its own execution_id for +/check; it gets the server-minted one back in the response +and propagates it to /track", but the propagation code was +NOT shipped in 0.12.0. The 0.12.0 wire was correct in intent +but the SDK still routed through /track/batch and ignored +`response["reservation_id"]` (see +`docs/sdk-v3-migration-gaps.md` and audit memory +`sdk-v3-migration-gaps`). + +0.12.1 ships the four missing pieces: + + 1. ``_capture_server_minted_execution_id(response)`` reads + ``reservation_id`` from the /check response into a + contextvar ``nullrun.context._server_minted_execution_id_var``. + 2. ``_enrich_event`` stamps the captured id onto /track + payloads (with a 295s freshness guard so an expired + reservation never ships a doomed id). + 3. ``_route_track`` dispatches ``llm_call`` events to the + v3 single-event endpoint ``/api/v1/track`` via + ``Transport.track_single``, so the backend's + ``gate_consume_v3`` validates the consume-vs-reserve + + ε invariant (CLAUDE.md §25). + 4. ``NULLRUN_V3_TRACK_DISABLE=1`` opt-out for backends still + on the v1/v2 path. + +Pinning: still SDK_MIN_VERSION_FOR_V3 = "0.12.0". Operators +upgrading from < 0.12.0 should jump straight to 0.12.1 — 0.12.0 +released with the integrity bug above and was never deployed +in production with the v3 wiring. """ -__version__ = "0.12.0" +__version__ = "0.12.1" __platform_version__ = "1.0.0" diff --git a/src/nullrun/context.py b/src/nullrun/context.py index 6d18eed..ca5ff40 100644 --- a/src/nullrun/context.py +++ b/src/nullrun/context.py @@ -22,7 +22,7 @@ import uuid from collections.abc import Generator from contextlib import contextmanager -from contextvars import ContextVar +from contextvars import ContextVar, Token # Context variables for workflow/trace propagation. _workflow_id_var: ContextVar[str | None] = ContextVar("workflow_id", default=None) @@ -160,6 +160,145 @@ def set_chain_op(op: str) -> None: _chain_op_var.set(op) +# --------------------------------------------------------------------------- +# Server-minted execution_id (2026-07-04 — CLAUDE.md §24, §29) +# --------------------------------------------------------------------------- +# +# Pre-0.12.0 the SDK sent a client-supplied ``execution_id`` (usually +# ``workflow_id``) in /check requests and IGNORED the server's response. +# This left two problems: +# +# 1. CLAUDE.md §24 ownership — the backend's `gate_reserve_v3` +# generates a uuidv7 internally, persists +# ``execution:{execution_id}`` (24h TTL) and creates +# ``reservation:{execution_id}`` (300s TTL). The client-minted +# id never matched, so on the v3 path the gate rejected /track +# with 503 RESERVATION_NOT_FOUND (§29 — fail-CLOSED). +# +# 2. CLAUDE.md §23 idempotency — /track's ``idempotency_key`` +# contract depends on the server-minted UUID being reused +# on retry. Without picking it up at /check the SDK has no +# way to compute a stable key. +# +# Fix: capture the ``reservation_id`` field from the /check +# response into this contextvar. The runtime sets it on every +# successful /check; the runtime's ``_enrich_event`` reads it on +# the way out and tags the /track payload with ``execution_id``. +# +# Lifetime: scoped automatically by ``with workflow(...)`` / +# ``with chain(...)`` — the runtime resets the contextvar on +# block exit so a /check in one block never leaks into a /track +# in a sibling block. Tests can drive it manually with +# ``set_/reset_server_minted_execution_id`` (Token-based API +# mirrors the user-facing audit spec; ``clear_`` is a +# no-token convenience for the runtime's ``_enrich_event`` +# after a /track has been issued). +# +# The reservation TTL (300s) is shorter than the chain id's 24h +# binding TTL, so we also record the capture timestamp — +# ``get_server_minted_reservation_at`` returns ``time.monotonic()`` +# at the moment /check returned 200. The runtime ignores the +# contextvar when the age exceeds 295s (5s margin below the +# 300s backend reservation TTL) so an exceptionally long LLM +# call never ships a doomed ``execution_id``. +_server_minted_execution_id_var: ContextVar[str | None] = ContextVar( + "server_minted_execution_id", default=None +) +_server_minted_reservation_at_var: ContextVar[float] = ContextVar( + "server_minted_reservation_at", default=0.0 +) + + +def get_server_minted_execution_id() -> str | None: + """Return the server-minted execution_id from the last /check, or + ``None`` if none captured in scope. + + Read by ``NullRunRuntime._enrich_event`` to tag the /track + payload. ``None`` is the legacy / v1-v2 path — the wire spec + allows the field to be omitted when the backend has not + minted one (capability ``server_minted_execution_id=False``). + """ + return _server_minted_execution_id_var.get() + + +def get_server_minted_reservation_at() -> float: + """Return ``time.monotonic()`` at the moment of /check capture, + or ``0.0`` if no capture in scope. + + Used by ``NullRunRuntime._enrich_event`` to refuse a /track + whose /check has aged past the v3 reservation TTL (300s — + CLAUDE.md §29). The runtime captures the timestamp at the + same instant the id is captured, so the two values always + refer to the same /check. + """ + return _server_minted_reservation_at_var.get() + + +def set_server_minted_execution_id(value: str | None) -> Token[str | None]: + """Capture the server-minted execution_id returned by /check. + + Returns the ``Token`` so the caller can restore the previous + value via :func:`reset_server_minted_execution_id`. The + runtime drives the lifetime explicitly (it owns the + capture/reset cycle around the user-function call) — user + code does not need to call this directly. + + Args: + value: UUID v7 string returned on ``GateResponse. + reservation_id`` (server-minted per §24). Pass + ``None`` to clear (e.g. on a hard block response + which carries no reservation_id). + """ + return _server_minted_execution_id_var.set(value) + + +def set_server_minted_reservation_at(value: float) -> Token[float]: + """Capture the ``time.monotonic()`` instant corresponding to + ``set_server_minted_execution_id``. + + Called by the runtime immediately after :func:`set_server_minted_execution_id` + so the two timestamps stay in lockstep. Returns the matching + Token for symmetric :func:`reset_server_minted_reservation_at`. + """ + return _server_minted_reservation_at_var.set(value) + + +def reset_server_minted_execution_id(token: Token[str | None]) -> None: + """Restore the previous server-minted execution_id value. + + Pair with :func:`set_server_minted_execution_id`. The runtime + stores the token at capture time and resets it on the matching + /track emission (or at workflow/chain block exit, whichever + comes first). + """ + _server_minted_execution_id_var.reset(token) + + +def reset_server_minted_reservation_at(token: Token[float]) -> None: + """Restore the previous reservation capture timestamp. + + Pair with :func:`set_server_minted_reservation_at`. + """ + _server_minted_reservation_at_var.reset(token) + + +def clear_server_minted_execution_id() -> None: + """Erase the captured server-minted execution_id + timestamp. + + No-token convenience for the runtime's "block exited, drop the + capture" code path. Equivalent to:: + + _server_minted_execution_id_var.set(None) + _server_minted_reservation_at_var.set(0.0) + + Use :func:`reset_server_minted_execution_id` instead when you + have a Token to consume — that path restores the previous + scope's value, ``clear_`` strictly forgets it. + """ + _server_minted_execution_id_var.set(None) + _server_minted_reservation_at_var.set(0.0) + + def set_attempt_index(index: int) -> None: """Set current attempt index for retry correlation.""" _attempt_index_var.set(index) diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 7f38372..7676435 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -69,6 +69,7 @@ FlushConfig, Transport, TransportErrorSource, + _emit_for_transport_error, _protocol_header_value, ) @@ -81,6 +82,18 @@ # collision hazard). Wire compat: still a string. UNKNOWN_WORKFLOW_ID: str = "__nullrun_unknown__" +# 2026-07-04 (v0.12.0 wiring fix — CLAUDE.md §24, §29): +# the maximum age (seconds) for a captured ``reservation_id`` +# to be eligible for forwarding onto a /track payload. Past +# this age the underlying ``reservation:{execution_id}`` Redis +# key has expired (300s TTL per §29) — forwarding would +# guarantee a 503 ``RESERVATION_NOT_FOUND`` on /track. The +# 5s margin below the 300s TTL absorbs clock-skew between +# the SDK's ``time.monotonic()`` and the Redis cluster's own +# TTL decay (sub-second typically, but the safety budget is +# worth the simplicity of a hard-coded threshold). +SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS: float = 295.0 + # Phase 4.1: privacy boundary. Fields that MUST NOT leave the SDK on # the wire. The transport layer (POST /api/v1/track/batch) reads # whatever is in the event dict, so anything not allowlisted ends up @@ -1224,6 +1237,33 @@ def check_workflow_budget(self) -> None: logger.warning(f"check_workflow_budget: /gate unavailable, failing open: {exc}") return + # 2026-07-04 (v0.12.0 wiring fix — CLAUDE.md §24, §29): + # capture the server-minted ``reservation_id`` returned by + # the backend's v3 ``gate_reserve_v3`` Lua path. Per §24 + # the server is the source-of-truth for execution_id + # ownership; the value in ``GateResponse.reservation_id`` + # is a freshly-minted uuidv7 that maps to the + # ``reservation:{execution_id}`` Redis key (TTL 300s). + # + # The /track handler v3 ``consume_budget_v3`` rejects with + # 503 ``RESERVATION_NOT_FOUND`` when ``execution_id`` in + # the request body does NOT match a live reservation key + # (§33 — fail-CLOSED). Storing the id on a contextvar + # means downstream ``track_llm`` / ``track_tool`` / + # ``track_event`` calls can fill in the field without + # threading it through the user-facing call sites. + # + # On legacy backends (``server_minted_execution_id=False`` + # capability) the field is omitted — ``get_...`` returns + # ``None`` and the SDK falls back to the previous + # (un-minted) wire flow. Capture happens regardless of + # ``decision``: a "throttle" pass still produces a + # reservation_id; only "block" + transport-failed clear it. + # We capture BEFORE the decision checks so a future + # bugfix that reorders them can't desync capture from + # response. + _capture_server_minted_execution_id(response) + decision = response.get("decision", "allow") decision_source = response.get("decision_source", DecisionSource.GATEWAY) # Round 3 (Phase 0.4.0): only fail-OPEN on EXPLICIT synthetic @@ -1645,7 +1685,7 @@ def track( metrics.inc_runtime("dropped_llm_call_no_model") wire_event["__missing_model"] = True - self._transport.track(wire_event) + self._route_track(wire_event) # Update metrics (thread-safe) metrics.inc_runtime("track_calls") @@ -2033,6 +2073,55 @@ def _enrich_event(self, event: dict[str, Any]) -> dict[str, Any]: if attempt_index > 0: # Only add if not default (first attempt) enriched["attempt_index"] = attempt_index + # 2026-07-04 (v0.12.0 wiring fix — CLAUDE.md §24, §29): + # include the server-minted execution_id on the /track + # payload when one is in scope (captured by + # ``check_workflow_budget`` via + # ``_capture_server_minted_execution_id``). + # + # Wire field: ``execution_id`` — matches the backend's + # ``consume_budget_v3`` consume-request body schema + # (``backend/src/cost/reservation.rs::consume_budget_v3``). + # + # Skip when: + # * the user / caller already supplied ``execution_id`` + # (explicit takes precedence), + # * no reservation was captured yet (legacy path or this + # is the very first event before the first /check), + # * the captured reservation has aged past + # ``SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS`` (295s + # by default — 5s safety margin below the 300s Redis + # reservation TTL per §29). Forwards of a stale id + # would 503 ``RESERVATION_NOT_FOUND`` on /track and + # we'd rather drop the field than trip the gate. + if "execution_id" not in enriched: + import time as _time + + from nullrun.context import ( + get_server_minted_execution_id, + get_server_minted_reservation_at, + ) + + smid = get_server_minted_execution_id() + if smid: + age = _time.monotonic() - get_server_minted_reservation_at() + if age >= SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS: + # Drop the stale capture. The user (or the + # next @protect invocation) will mint a fresh + # id on the next /check. + from nullrun.context import ( + clear_server_minted_execution_id, + ) + clear_server_minted_execution_id() + logger.debug( + "_enrich_event: dropping stale server-minted " + "execution_id (age=%.1fs >= %ds)", + age, + SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS, + ) + else: + enriched["execution_id"] = smid + # Add type if not present if "type" not in enriched: enriched["type"] = "event" @@ -2046,6 +2135,94 @@ def _enrich_event(self, event: dict[str, Any]) -> dict[str, Any]: return enriched + def _route_track(self, wire_event: dict[str, Any]) -> None: + """Route a tracked event to v3 single-event /track or + legacy batch /track/batch (CLAUDE.md §24, §29). + + Why this exists + --------------- + Pre-0.12.0 wiring the SDK always called + ``self._transport.track(wire_event)`` which posts to the + legacy ``/api/v1/track/batch`` (the ``process_span_event`` + pipeline). That pipeline reads the org's lifetime + ``monthly_cost`` counter — drift with the dashboard's + period-bound ``bp:{ts}:cost_cents`` per CLAUDE.md §0 G1, + and never exercises v3 ``consume_budget_v3`` so the + consume ≤ reserve + ε invariant (§25) is never validated. + + The fix: route events that have a paired ``/check`` + reservation (currently: ``llm_call``) to + ``track_single`` which posts to ``/api/v1/track``. The + backend's consume takes the server-minted execution_id + from the request, looks up + ``reservation:{execution_id}`` and runs the invariant. + Span events still ride /track/batch — they have no + reservation to release. + + Opt-out + ------- + ``NULLRUN_V3_TRACK_DISABLE=1`` forces every event + through the legacy batch path. Use it on backends that + haven't flipped ``NULLRUN_CONSUME_V3_ENABLED=1`` yet. + + Failure mode + ------------ + ``track_single`` raises on 422 / 503 / 5xx (see + ``nullrun.breaker.exceptions``). We catch and log at + WARNING level; the event is dropped (NOT retried via + the batch path — that would risk double-billing per + §23 idempotency contract). + """ + from nullrun.context import get_server_minted_execution_id + + event_type = wire_event.get("type") + v3_disabled = ( + os.environ.get("NULLRUN_V3_TRACK_DISABLE", "").strip() == "1" + ) + + if event_type != "llm_call" or v3_disabled: + # Span / heartbeat / tool events have no reservation; + # the legacy batch path is the right endpoint. + self._transport.track(wire_event) + return + + smid = get_server_minted_execution_id() + if not smid: + # Either no /check landed in this scope (legacy v1/v2 + # path) or the capture expired past the 295s safety + # window. Don't make up an id — fall back to batch + # which uses the no-reservation v1/v2 consume path. + self._transport.track(wire_event) + logger.debug( + "_route_track: llm_call without server-minted " + "execution_id in scope — routing via /track/batch" + ) + return + + single_payload = _build_v3_track_payload(wire_event, smid) + if single_payload is None: + # Mapper refused (missing required field). Fall back. + self._transport.track(wire_event) + return + + try: + self._transport.track_single(single_payload) + metrics.inc_runtime("v3_track_single_ok") + except Exception as exc: # noqa: BLE001 — transport-level + metrics.inc_runtime("v3_track_single_failed") + _emit_for_transport_error( + exc, + stage="track_v3_single", + correlation_id=smid, + status_code=getattr(exc, "status_code", None), + ) + logger.warning( + "_route_track: track_single failed for " + "execution_id=%s (%s) — event dropped", + smid, + exc, + ) + def track_llm( self, input_tokens: int, @@ -2274,6 +2451,188 @@ def _post_auth_with_retry( _runtime: NullRunRuntime | None = None +# 2026-07-04 (v0.12.0 wiring fix — CLAUDE.md §24, §29): +# helper used by ``check_workflow_budget`` to capture the server-minted +# execution_id from the /check response into a contextvar. Lives at +# module scope so any /check path (``check_workflow_budget``, +# ``check_v3``, future ``preflight_v3``) can call it without taking +# a dependency on the runtime singleton. +# +# Behaviour: +# * On a real ``reservation_id`` field: store it on the +# ``_server_minted_execution_id_var`` contextvar + record +# ``time.monotonic()`` on ``_server_minted_reservation_at_var`` +# so ``_enrich_event`` can refuse to forward a stale capture +# past the 300s reservation TTL (§29). +# * On missing/None/empty value: clear both contextvars so +# downstream /track ships without ``execution_id`` (the legacy +# / v1-v2 wire shape — backend is tolerant per the +# ``server_minted_execution_id=False`` capability gating). +# * On an invalid UUID string (defence-in-depth — backend is the +# source-of-truth and only mints uuidv7, but a buggy proxy +# could echo a malformed field): drop it with a warning log. +def _capture_server_minted_execution_id(response: dict[str, Any]) -> str | None: + """Capture ``response["reservation_id"]`` into the server-minted + execution_id contextvar. + + Returns the captured id (or ``None`` on miss / malformed) so the + caller can log it on debug paths. The contextvar itself is the + authoritative side-effect — readers consult + ``get_server_minted_execution_id`` from ``nullrun.context``. + + Import is lazy (inside the function) to keep + ``nullrun.runtime`` import order stable: ``context`` itself + imports nothing from ``runtime``, but ``_enrich_event`` lives + in this module and depends on the context getters. + """ + import time as _time + + from nullrun.context import ( + clear_server_minted_execution_id, + set_server_minted_execution_id, + set_server_minted_reservation_at, + ) + + raw = response.get("reservation_id") if isinstance(response, dict) else None + if not raw: + # Legacy / v1-v2 backend, or a block response with no + # reservation. Clear any prior capture so the next /track + # doesn't ship a stale id from a previous /check. + clear_server_minted_execution_id() + return None + + if not isinstance(raw, str): + clear_server_minted_execution_id() + logger.warning( + "_capture_server_minted_execution_id: response.reservation_id " + "is %s, expected str — dropping", + type(raw).__name__, + ) + return None + + # Defence-in-depth UUID parse — backend's mint_execution_id + # emits RFC-4122 uuidv7 but a buggy proxy could echo garbage. + # Drop without raising (fail-OPEN on capture; the backend will + # still reject malformed ids with 400 on /track). + import uuid as _uuid + + try: + _uuid.UUID(raw) + except (ValueError, AttributeError): + clear_server_minted_execution_id() + logger.warning( + "_capture_server_minted_execution_id: response.reservation_id=%r " + "is not a valid UUID — dropping", + raw, + ) + return None + + set_server_minted_execution_id(raw) + set_server_minted_reservation_at(_time.monotonic()) + logger.debug( + "_capture_server_minted_execution_id: captured %s", + raw, + ) + return raw + + +# 2026-07-04 (v0.12.0 wiring fix — CLAUDE.md §24, §29): build the +# v3 /track single-event payload from an enriched llm_call event. +# Lives at module scope so ``_route_track`` (a method) can call it +# without taking a runtime dependency beyond the contextvar getters. +# +# Wire shape (``/api/v1/track`` schema per +# ``backend/src/proxy/handlers.rs::TrackRequest``): +# +# { +# "reservation_id": "", +# "workflow_id": "", +# "tokens": , # input + output +# "input_tokens": , +# "output_tokens": , +# "cost_cents": , # 0 — backend computes from tokens +# "model": "", # used for rate lookup +# "metadata": {...}, # optional, free-form +# "cost_source": "provisional", # per §22 trust model +# } +# +# The backend's ``gate_consume_v3`` reads ``reservation_id`` and +# runs CONSUME_SCRIPT v3 (server-minted execution_id owner check + +# consume ≤ reserve + epsilon invariant). If a required field is +# missing OR the runtime cannot construct the payload, returns +# ``None`` and the caller falls back to ``/track/batch``. +def _build_v3_track_payload( + wire_event: dict[str, Any], + reservation_id: str, +) -> dict[str, Any] | None: + """Map an enriched llm_call event onto the v3 /track schema. + + Returns ``None`` when the event cannot be mapped (caller + falls back to legacy batch path). Required ``tokens`` / + ``workflow_id`` absence is the only failure mode today. + """ + wf_id = wire_event.get("workflow_id") + if not wf_id: + # The backend's consume_budget_v3 needs a workflow_id to + # attribute the consume to a key+workflow counter (§24 + # ownership binding). A missing workflow_id means the + # SDK never bound the API key to a workflow (legacy + # legacy-no-binding). Fall back. + logger.debug( + "_build_v3_track_payload: missing workflow_id — " + "cannot shape v3 /track payload" + ) + return None + + tokens = wire_event.get("tokens") + if tokens is None: + # Same as llm_call missing required fields — the backend + # would 422 anyway. Fall back to batch. + logger.debug( + "_build_v3_track_payload: missing tokens — cannot " + "shape v3 /track payload" + ) + return None + + payload: dict[str, Any] = { + "reservation_id": reservation_id, + "workflow_id": wf_id, + "tokens": int(tokens), + "cost_cents": 0, + "cost_source": "provisional", # CLAUDE.md §22 + } + if "input_tokens" in wire_event and wire_event["input_tokens"] is not None: + payload["input_tokens"] = int(wire_event["input_tokens"]) + if "output_tokens" in wire_event and wire_event["output_tokens"] is not None: + payload["output_tokens"] = int(wire_event["output_tokens"]) + if "model" in wire_event and wire_event["model"]: + payload["model"] = wire_event["model"] + if "latency_ms" in wire_event and wire_event["latency_ms"] is not None: + payload["latency_ms"] = int(wire_event["latency_ms"]) + if "metadata" in wire_event and wire_event["metadata"]: + payload["metadata"] = wire_event["metadata"] + if "trace_id" in wire_event and wire_event["trace_id"]: + payload["trace_id"] = wire_event["trace_id"] + if "span_id" in wire_event and wire_event["span_id"]: + payload["span_id"] = wire_event["span_id"] + + # Optional downstream fields preserved verbatim (workflow-level + # cost attribution, agent_id, etc.). Backend ignores unknown + # fields, so unknown keys are safe — we just surface the ones + # the SDK actually emits. + for k in ( + "agent_id", + "environment", + "agent_type", + "attempt_index", + "is_retry", + ): + if k in wire_event and wire_event[k] is not None: + payload[k] = wire_event[k] + + return payload + + def get_runtime() -> NullRunRuntime: """Get or create the global runtime instance.""" global _runtime diff --git a/tests/test_v3_server_minted.py b/tests/test_v3_server_minted.py new file mode 100644 index 0000000..c0d7975 --- /dev/null +++ b/tests/test_v3_server_minted.py @@ -0,0 +1,655 @@ +""" +Contract tests for the v3 server-minted execution_id wiring +(CLAUDE.md §24, §29). + +Background +---------- +Pre-0.12.0 the SDK read ``decision`` + ``decision_source`` from +the /check response and IGNORED ``reservation_id``, the +server-minted uuidv7 the backend's ``gate_reserve_v3`` writes +to ``reservation:{execution_id}`` (TTL 300s) and surfaces on +``GateResponse.reservation_id``. Without the round-trip: + + - /track had no way to find the matching reservation key → + v3 ``consume_budget_v3`` rejected with 503 + ``RESERVATION_NOT_FOUND`` (CLAUDE.md §33, fail-CLOSED). + - /track kept using the legacy ``/api/v1/track/batch`` + path that writes to ``monthly_cost`` (drift with the + dashboard's period counter, see §0 G1). + +0.12.0 fixes this by: + + 1. Capturing ``response["reservation_id"]`` into a + contextvar (``get_server_minted_execution_id``). + 2. Stamping the captured id onto every llm_call /track + payload so v3 ``consume_budget_v3`` can find the + reservation. + 3. Routing llm_call events to ``/api/v1/track`` (v3 + single-event) instead of ``/api/v1/track/batch``. + +This file pins each step so a future refactor that breaks +propagation trips CI rather than silently re-introducing +the drift. Pattern follows +``tests/test_v3_wire_contract.py`` — same respx-based pattern, +strict-URL assertions, no live backend required. +""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest +import respx +from httpx import Response + +from nullrun.context import ( + _server_minted_execution_id_var, + _server_minted_reservation_at_var, + clear_server_minted_execution_id, + get_server_minted_execution_id, + get_server_minted_reservation_at, + reset_server_minted_execution_id, + reset_server_minted_reservation_at, + set_server_minted_execution_id, + set_server_minted_reservation_at, +) +from nullrun.runtime import ( + SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS, + NullRunRuntime, + _build_v3_track_payload, + _capture_server_minted_execution_id, +) + +BASE_URL = "https://api.test.nullrun.io" + +# A valid server-minted uuidv7 for tests. Layout matches the +# backend's mint_execution_id (RFC 9562 §5.7 — version nibble +# in position 13 is `7`). +SERVER_MINTED_V1 = "0190c5b5-7c9a-7def-8a1b-0123456789ab" +SERVER_MINTED_V2 = "0190c5b5-7c9a-7def-8a1b-fedcba987654" + + +# ───────────────────────────────────────────────────────────────── +# Conftest-isolated state: every test gets a clean contextvar +# ───────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _reset_server_minted_contextvar(): + """Forget any captured execution_id before AND after the test. + + Pairs with the ``reset_runtime`` autouse in conftest.py so + contextvar state never leaks across test cases (test + isolation — see memory ``test-isolation-monkeypatch-setattr`` + for the monkeypatched-setattr rationale). + """ + clear_server_minted_execution_id() + yield + clear_server_minted_execution_id() + + +# ───────────────────────────────────────────────────────────────── +# 1. ContextVar: set/get/reset + timestamp pair (audit gap #2) +# ───────────────────────────────────────────────────────────────── + +class TestServerMintedExecutionIdContextvar: + """Token-based API for the server-minted execution_id contextvar. + + Mirrors the user-facing audit spec: + ``set_server_minted_execution_id(value) -> Token``, + ``get_server_minted_execution_id() -> str | None``, + ``reset_server_minted_execution_id(token) -> None``. + """ + + def test_default_value_is_none(self): + # New ContextVar with no prior set → None (audit: "нет var + # на старте"). Verifies the SDK doesn't ship with a stale + # id baked into the context. + assert get_server_minted_execution_id() is None + + def test_set_returns_token_get_returns_value(self): + token = set_server_minted_execution_id(SERVER_MINTED_V1) + try: + assert get_server_minted_execution_id() == SERVER_MINTED_V1 + finally: + reset_server_minted_execution_id(token) + + def test_reset_restores_previous_value(self): + # Layer one scope. + outer_token = set_server_minted_execution_id(SERVER_MINTED_V1) + try: + assert get_server_minted_execution_id() == SERVER_MINTED_V1 + + # Layer two scope — set a new value. + inner_token = set_server_minted_execution_id(SERVER_MINTED_V2) + try: + assert get_server_minted_execution_id() == SERVER_MINTED_V2 + + # Reset inner — restores outer (not None). + reset_server_minted_execution_id(inner_token) + assert get_server_minted_execution_id() == SERVER_MINTED_V1 + finally: + # Already reset above; guard against re-running. + if get_server_minted_execution_id() == SERVER_MINTED_V2: + reset_server_minted_execution_id(inner_token) + finally: + reset_server_minted_execution_id(outer_token) + + # Final: after outermost reset, back to None. + assert get_server_minted_execution_id() is None + + def test_clear_drops_both_contextvars(self): + token_e = set_server_minted_execution_id(SERVER_MINTED_V1) + token_t = set_server_minted_reservation_at(123.456) + try: + assert get_server_minted_execution_id() == SERVER_MINTED_V1 + assert get_server_minted_reservation_at() == 123.456 + + clear_server_minted_execution_id() + + # Both dropped to their defaults. No token-based + # restore — this is the "block exited" cleanup path. + assert get_server_minted_execution_id() is None + assert get_server_minted_reservation_at() == 0.0 + finally: + reset_server_minted_execution_id(token_e) + reset_server_minted_reservation_at(token_t) + + def test_reservation_at_pairs_with_execution_id(self): + # Captured at the same instant in real code so the two + # values age in lockstep. Here we drive them separately + # to verify the two contextvars are independent. + t_e = set_server_minted_execution_id(SERVER_MINTED_V1) + t_t = set_server_minted_reservation_at(time.monotonic()) + try: + # Independent: setting one does NOT touch the other. + new_e = set_server_minted_execution_id(SERVER_MINTED_V2) + try: + assert get_server_minted_execution_id() == SERVER_MINTED_V2 + # Timestamp from earlier set is still visible. + assert get_server_minted_reservation_at() > 0 + finally: + reset_server_minted_execution_id(new_e) + finally: + reset_server_minted_execution_id(t_e) + reset_server_minted_reservation_at(t_t) + + +# ───────────────────────────────────────────────────────────────── +# 2. Capture helper (audit gap #1) +# ───────────────────────────────────────────────────────────────── + +class TestCaptureServerMintedExecutionId: + """``_capture_server_minted_execution_id(response)`` is the + runtime-side shim that moves ``response["reservation_id"]`` + onto the contextvar. """ + + def test_captures_valid_uuid_v7(self): + out = _capture_server_minted_execution_id( + {"reservation_id": SERVER_MINTED_V1} + ) + assert out == SERVER_MINTED_V1 + assert get_server_minted_execution_id() == SERVER_MINTED_V1 + # Timestamp set to a positive monotonic — tests don't pin + # exact value but verify it's >0 (means "captured"). + assert get_server_minted_reservation_at() > 0 + + def test_clears_on_missing_field(self): + # Pre-populate to verify clear() actually clears. + set_server_minted_execution_id(SERVER_MINTED_V1) + + result = _capture_server_minted_execution_id({"decision": "allow"}) + assert result is None + assert get_server_minted_execution_id() is None + + def test_clears_on_none_field(self): + # Backend sometimes returns `reservation_id: null` instead + # of omitting the field — same outcome expected. + set_server_minted_execution_id(SERVER_MINTED_V1) + result = _capture_server_minted_execution_id( + {"reservation_id": None} + ) + assert result is None + assert get_server_minted_execution_id() is None + + def test_drops_malformed_uuid_with_warning(self, caplog): + import logging + + # Pre-seed so we can verify clear happens even on + # malformed input. + set_server_minted_execution_id(SERVER_MINTED_V1) + + with caplog.at_level(logging.WARNING, logger="nullrun.runtime"): + result = _capture_server_minted_execution_id( + {"reservation_id": "not-a-uuid"} + ) + assert result is None + assert get_server_minted_execution_id() is None + assert any( + "is not a valid UUID" in record.message + for record in caplog.records + ) + + def test_tolerates_non_dict_response(self): + # Defensive: a malformed transport could surface a + # non-dict. Don't crash, just clear. + result = _capture_server_minted_execution_id("not a dict") # type: ignore[arg-type] + assert result is None + assert get_server_minted_execution_id() is None + + def test_drops_non_string_field(self): + # Backend is the source of truth and only emits strings, + # but a buggy proxy could echo an int. Defensive parse. + result = _capture_server_minted_execution_id( + {"reservation_id": 123456} # type: ignore[dict-item] + ) + assert result is None + assert get_server_minted_execution_id() is None + + +# ───────────────────────────────────────────────────────────────── +# 3. _enrich_event: include execution_id when fresh, drop when stale +# ───────────────────────────────────────────────────────────────── + +class TestEnrichEventServerMinted: + """``NullRunRuntime._enrich_event`` must stamp ``execution_id`` + onto the /track payload from the contextvar (audit gap #3) + AND drop the field when the captured reservation has aged + past the 300s TTL (§29). + """ + + def test_includes_execution_id_when_fresh(self, make_runtime): + rt = make_runtime() + + # Capture a fresh id (timestamp = now). + _capture_server_minted_execution_id( + {"reservation_id": SERVER_MINTED_V1} + ) + + enriched = rt._enrich_event( + {"type": "llm_call", "workflow_id": "wf-1", "tokens": 10} + ) + assert enriched["execution_id"] == SERVER_MINTED_V1 + + def test_explicit_execution_id_wins_over_contextvar( + self, make_runtime + ): + rt = make_runtime() + + _capture_server_minted_execution_id( + {"reservation_id": SERVER_MINTED_V1} + ) + + enriched = rt._enrich_event( + { + "type": "tool_call", + "workflow_id": "wf-1", + "execution_id": "user-supplied-id", + } + ) + # Caller's value wins — contextvar is fallback only. + assert enriched["execution_id"] == "user-supplied-id" + + def test_drops_execution_id_when_age_exceeds_threshold( + self, make_runtime + ): + rt = make_runtime() + + # Force the timestamp to ancient history. + token = set_server_minted_execution_id(SERVER_MINTED_V1) + stale_at = time.monotonic() - ( + SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS + 10.0 + ) + t_at = set_server_minted_reservation_at(stale_at) + try: + enriched = rt._enrich_event( + {"type": "llm_call", "workflow_id": "wf-1", "tokens": 10} + ) + # Stale → field dropped, contextvar cleared. + assert "execution_id" not in enriched + assert get_server_minted_execution_id() is None + finally: + reset_server_minted_execution_id(token) + reset_server_minted_reservation_at(t_at) + + def test_keeps_execution_id_when_age_just_under_threshold( + self, make_runtime + ): + # Boundary: 1 second before the safety cutoff — still + # considered fresh. + rt = make_runtime() + token = set_server_minted_execution_id(SERVER_MINTED_V1) + t_at = set_server_minted_reservation_at( + time.monotonic() + - (SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS - 1.0) + ) + try: + enriched = rt._enrich_event( + {"type": "llm_call", "workflow_id": "wf-1", "tokens": 10} + ) + assert enriched["execution_id"] == SERVER_MINTED_V1 + finally: + reset_server_minted_execution_id(token) + reset_server_minted_reservation_at(t_at) + + def test_no_execution_id_when_capture_empty(self, make_runtime): + # No capture in scope → no execution_id field. + rt = make_runtime() + enriched = rt._enrich_event( + {"type": "llm_call", "workflow_id": "wf-1", "tokens": 10} + ) + assert "execution_id" not in enriched + + +# ───────────────────────────────────────────────────────────────── +# 4. _build_v3_track_payload: shape the v3 single-event body +# ───────────────────────────────────────────────────────────────── + +class TestBuildV3TrackPayload: + """Map an enriched event onto the ``/api/v1/track`` schema.""" + + def test_full_event_builds_full_payload(self): + out = _build_v3_track_payload( + { + "type": "llm_call", + "workflow_id": "wf-1", + "tokens": 100, + "input_tokens": 60, + "output_tokens": 40, + "model": "claude-sonnet-4-6", + "latency_ms": 250, + "metadata": {"x": "y"}, + "trace_id": "trace-1", + "span_id": "span-1", + "agent_id": "agent-1", + }, + SERVER_MINTED_V1, + ) + assert out == { + "reservation_id": SERVER_MINTED_V1, + "workflow_id": "wf-1", + "tokens": 100, + "input_tokens": 60, + "output_tokens": 40, + "model": "claude-sonnet-4-6", + "latency_ms": 250, + "metadata": {"x": "y"}, + "trace_id": "trace-1", + "span_id": "span-1", + "agent_id": "agent-1", + "cost_cents": 0, + "cost_source": "provisional", + } + + def test_minimal_event_only_required_fields(self): + # workflow_id + tokens + reservation_id are the floor. + out = _build_v3_track_payload( + {"type": "llm_call", "workflow_id": "wf-1", "tokens": 1}, + SERVER_MINTED_V1, + ) + assert out == { + "reservation_id": SERVER_MINTED_V1, + "workflow_id": "wf-1", + "tokens": 1, + "cost_cents": 0, + "cost_source": "provisional", + } + + def test_missing_workflow_id_returns_none(self): + # Caller falls back to /track/batch. + out = _build_v3_track_payload( + {"type": "llm_call", "tokens": 1}, + SERVER_MINTED_V1, + ) + assert out is None + + def test_missing_tokens_returns_none(self): + out = _build_v3_track_payload( + {"type": "llm_call", "workflow_id": "wf-1"}, + SERVER_MINTED_V1, + ) + assert out is None + + def test_tokens_coerced_to_int(self): + # Defensive: SDK usually emits int but a user-supplied + # token via the dict could be a numpy.int64 in a + # cookbook scenario. Force int() so wire is int. + out = _build_v3_track_payload( + {"type": "llm_call", "workflow_id": "wf-1", "tokens": "100"}, + SERVER_MINTED_V1, + ) + assert out is not None + assert out["tokens"] == 100 + assert isinstance(out["tokens"], int) + + +# ───────────────────────────────────────────────────────────────── +# 5. _route_track: routes llm_call → /track, others → /track/batch +# ───────────────────────────────────────────────────────────────── + +class TestRouteTrack: + """``NullRunRuntime._route_track(wire_event)`` decides between + the v3 single-event endpoint (``/api/v1/track``) and the + legacy batch endpoint (``/api/v1/track/batch``). + """ + + @respx.mock + def test_llm_call_with_smid_routes_to_single(self, make_runtime): + rt = make_runtime() + + # Set up both endpoints with respx — only one should fire. + single_route = respx.post(f"{BASE_URL}/api/v1/track").mock( + return_value=Response(200, json={"status": "ok"}) + ) + batch_route = respx.post(f"{BASE_URL}/api/v1/track/batch").mock( + return_value=Response(200, json={"ok": True, "accepted": 1}) + ) + + # Capture a server-minted id. + _capture_server_minted_execution_id( + {"reservation_id": SERVER_MINTED_V1} + ) + + # Drive through track_llm so the enrich path runs. + rt.track_llm( + input_tokens=60, + output_tokens=40, + model="claude-sonnet-4-6", + ) + + assert single_route.call_count == 1 + assert batch_route.call_count == 0 + + # Wire shape — body contains the captured reservation_id. + sent = single_route.calls.last.request + import json as _json + body = _json.loads(sent.content) + assert body["reservation_id"] == SERVER_MINTED_V1 + assert body["tokens"] == 100 + assert body["cost_source"] == "provisional" + + @respx.mock + def test_tool_call_routes_to_batch(self, make_runtime): + rt = make_runtime() + + single_route = respx.post(f"{BASE_URL}/api/v1/track").mock( + return_value=Response(200, json={"status": "ok"}) + ) + batch_route = respx.post(f"{BASE_URL}/api/v1/track/batch").mock( + return_value=Response(200, json={"ok": True, "accepted": 1}) + ) + + # Capture anyway — even WITH smid in scope, non-llm_call + # events still go to the batch endpoint (no reservation + # to release). + _capture_server_minted_execution_id( + {"reservation_id": SERVER_MINTED_V1} + ) + + rt.track_tool( + tool_name="bash", + duration_ms=50, + ) + + # track() buffers; tool_call events don't trip the v3 + # path because they have no reservation to release. Force + # the batch flush so respx sees the call. + rt._transport.flush_now() + + assert single_route.call_count == 0 + assert batch_route.call_count == 1 + + @respx.mock + def test_llm_call_without_smid_falls_back_to_batch(self, make_runtime): + # No /check in scope → no smid → legacy path. + rt = make_runtime() + + single_route = respx.post(f"{BASE_URL}/api/v1/track").mock( + return_value=Response(200, json={"status": "ok"}) + ) + batch_route = respx.post(f"{BASE_URL}/api/v1/track/batch").mock( + return_value=Response(200, json={"ok": True, "accepted": 1}) + ) + + # No capture call here — contextvar stays empty. + + rt.track_llm( + input_tokens=10, + output_tokens=5, + model="claude-sonnet-4-6", + ) + # Buffer + flush. + rt._transport.flush_now() + + assert single_route.call_count == 0 + assert batch_route.call_count == 1 + + @respx.mock + def test_v3_track_disable_env_forces_legacy(self, make_runtime, monkeypatch): + # Env flag opt-out — even WITH smid, force batch. + monkeypatch.setenv("NULLRUN_V3_TRACK_DISABLE", "1") + + rt = make_runtime() + + single_route = respx.post(f"{BASE_URL}/api/v1/track").mock( + return_value=Response(200, json={"status": "ok"}) + ) + batch_route = respx.post(f"{BASE_URL}/api/v1/track/batch").mock( + return_value=Response(200, json={"ok": True, "accepted": 1}) + ) + + _capture_server_minted_execution_id( + {"reservation_id": SERVER_MINTED_V1} + ) + + rt.track_llm(input_tokens=1, output_tokens=1, model="x") + rt._transport.flush_now() + + assert single_route.call_count == 0 + assert batch_route.call_count == 1 + + +# ───────────────────────────────────────────────────────────────── +# 6. End-to-end: capture from /gate response flows to /track +# ───────────────────────────────────────────────────────────────── + +class TestEndToEndCaptureFlow: + """The two halves of the v3 wire-up must cooperate. + + ``check_workflow_budget`` captures the ``reservation_id`` + from the /gate response. ``track_llm`` (via + ``_route_track``) reads the captured id and ships it on + /track. These tests pin the round trip so any refactor + that breaks the connection is caught at CI time. + """ + + @respx.mock + def test_reservation_id_from_gate_lands_on_track(self, make_runtime): + rt = make_runtime() + + # /gate returns reservation_id (server-minted uuidv7). + respx.post(f"{BASE_URL}/api/v1/gate").mock( + return_value=Response( + 200, + json={ + "decision": "allow", + "decision_source": "gateway", + "reservation_id": SERVER_MINTED_V1, + }, + ) + ) + + # /track (single) — what the v3 routing should hit. + single_route = respx.post(f"{BASE_URL}/api/v1/track").mock( + return_value=Response(200, json={"status": "ok"}) + ) + + # Drive /gate (which captures) ... + from nullrun.context import workflow + with workflow("wf-1"): + rt.check_workflow_budget() + + # ... then drive /track within the same scope. + rt.track_llm( + input_tokens=10, + output_tokens=5, + model="claude-sonnet-4-6", + ) + + assert single_route.call_count == 1 + import json as _json + body = _json.loads(single_route.calls.last.request.content) + assert body["reservation_id"] == SERVER_MINTED_V1 + + @respx.mock + def test_block_response_does_not_infect_subsequent_track( + self, make_runtime + ): + # /gate returns "block" with NO reservation_id. The + # capture helper should clear any prior capture so the + # next /track is a legacy batch event (no reservation). + rt = make_runtime() + + respx.post(f"{BASE_URL}/api/v1/gate").mock( + return_value=Response( + 200, + json={ + "decision": "block", + "decision_source": "gateway", + "explanation": "budget exhausted", + # NO reservation_id — backend does NOT mint + # on a hard block (the request didn't + # proceed past the gate). + }, + ) + ) + + single_route = respx.post(f"{BASE_URL}/api/v1/track").mock( + return_value=Response(200, json={"status": "ok"}) + ) + batch_route = respx.post(f"{BASE_URL}/api/v1/track/batch").mock( + return_value=Response(200, json={"ok": True, "accepted": 1}) + ) + + from nullrun.breaker.exceptions import WorkflowKilledInterrupt + from nullrun.context import workflow + with workflow("wf-1"): + # Block path raises — WorkflowKilledInterrupt is a + # BaseException (carries the kill signal; per CLAUDE.md + # §3 must propagate honestly). Catch it explicitly for + # this test which only wants to verify contextvar hygiene. + try: + rt.check_workflow_budget() + except WorkflowKilledInterrupt: + pass + + rt.track_llm( + input_tokens=1, + output_tokens=1, + model="x", + ) + rt._transport.flush_now() + + # No reservation_id was minted → falls back to batch. + assert single_route.call_count == 0 + assert batch_route.call_count == 1