Skip to content

Commit fa0befc

Browse files
committed
release(0.12.1): wire server-minted execution_id through /check -> /track
Bug-fix release. The 0.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. Closes the four gaps documented in docs/sdk-v3-migration-gaps.md: * check_workflow_budget() now reads response["reservation_id"] into a contextvar (nullrun.context._server_minted_execution_id_var). * New helpers set_/get_/reset_/clear_server_minted_execution_id plus a paired _server_minted_reservation_at timestamp for the 295s TTL guard. * _enrich_event stamps execution_id on the /track payload while the captured reservation is fresh; past the 295s safety window it drops and clears the capture so a doomed id never ships to /track (which would 503 RESERVATION_NOT_FOUND -- CLAUDE.md section 33). * _route_track dispatches 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 forces everything through the legacy batch path (backends still on v1/v2). Adds 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.
1 parent 1cc2d1e commit fa0befc

6 files changed

Lines changed: 1265 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,41 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
88
---
99

1010

11+
## [0.12.1] - 2026-07-04
12+
13+
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`).
14+
15+
This release closes the four gaps documented in `docs/sdk-v3-migration-gaps.md`:
16+
17+
- `check_workflow_budget()` now reads `response["reservation_id"]` and stores it on a contextvar (`nullrun.context._server_minted_execution_id_var`).
18+
- 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.
19+
- `_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.
20+
- `_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`.
21+
- `NULLRUN_V3_TRACK_DISABLE=1` opt-out forces everything through the legacy batch path (backends still on v1/v2).
22+
23+
### Added
24+
25+
- `nullrun.context._server_minted_execution_id_var` + `nullrun.context._server_minted_reservation_at_var` + 6 helpers (`get_/set_/reset_/clear_`).
26+
- `nullrun.runtime._capture_server_minted_execution_id(response)` — defensive UUID parse + warn-on-malformed.
27+
- `nullrun.runtime._route_track(wire_event)` — dispatches to single-event /track or batch /track/batch.
28+
- `nullrun.runtime._build_v3_track_payload(event, reservation_id)` — maps an enriched event onto the v3 /track wire schema.
29+
- 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.
30+
31+
### Changed
32+
33+
- `__version__` bumped from 0.12.0 to 0.12.1 (post-release integrity fix — the v0.12.0 wiring never shipped before this).
34+
35+
### Fixed
36+
37+
- 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).
38+
- 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).
39+
1140
## [0.12.0] - 2026-07-03
1241

1342
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.
1443

44+
> **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.
45+
1546
### Added
1647

1748
- `nullrun.uuid7` module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs.

docs/sdk-v3-migration-gaps.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# SDK 0.12.0 → 0.12.1 v3 migration history
2+
3+
This document records the four gaps that existed in the v0.12.0 wire-up
4+
of the SDK's server-minted `execution_id` propagation. **It is kept as
5+
historical evidence of the integrity bug** — the gaps are now closed in
6+
0.12.1 (see `CHANGELOG.md`).
7+
8+
The current canonical implementation lives in:
9+
10+
- `src/nullrun/context.py``_server_minted_execution_id_var`,
11+
`_server_minted_reservation_at_var` + 6 helpers
12+
(`get_/set_/reset_/clear_` × 2 vars).
13+
- `src/nullrun/runtime.py``_capture_server_minted_execution_id`,
14+
`_route_track`, `_build_v3_track_payload`,
15+
`SERVER_MINTED_RESERVATION_MAX_AGE_SECONDS = 295.0`.
16+
- `tests/test_v3_server_minted.py` — 27 contract tests pinning each
17+
step (no live backend required; uses respx to mock /gate, /track,
18+
/track/batch).
19+
20+
Reference: backend `gate/http/internal.rs::reserve_v3_enabled` mints
21+
the uuidv7 server-side; `proxy/handlers.rs::gate_consume_v3` validates
22+
the v3 reserve→consume invariant (consume ≤ reserve + ε_cents,
23+
CLAUDE.md §25 + ADR-005).
24+
25+
## Why this history matters
26+
27+
0.12.0's `__version__.py` docstring (and the v3.12 backend changelog)
28+
promised propagation that was not yet implemented. The integrity bug
29+
surfaced only when an operator audit compared the version bump against
30+
the actual code paths in `runtime.py::1189-1227`, `_enrich_event`,
31+
and `transport.py::track()`. The fix in 0.12.1 closes the loop and
32+
makes the version honest.
33+
34+
If you ever see a v0.12.0 release without a 0.12.1+ in the same deploy,
35+
treat that deployment as drift — the v3 /track wiring was not yet
36+
active at that version.

src/nullrun/__version__.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,50 @@
11
"""NullRun Platform SDK.
22
3-
v3.12 (2026-07-03) — server-minted execution_id default ON.
3+
v3.12 / 0.12.0 (2026-07-03) — server-minted execution_id default ON.
44
55
The backend `gate_reserve_v3` now mints a uuidv7 execution_id
6-
internally (CLAUDE.md §24). The SDK no longer needs to generate
7-
its own `execution_id` for /check; it gets the server-minted
8-
one back in the response and propagates it to /track. This
9-
version (`0.12.0`) is the SDK_MIN_VERSION for the v3 rollout —
10-
older SDKs continue to work because the gate IGNORES the
11-
client-supplied execution_id (it mints its own), but they
12-
should upgrade for proper /track binding propagation and the
13-
new `capabilities()` probe.
6+
internally (CLAUDE.md §24). This version (`0.12.0`) is the
7+
SDK_MIN_VERSION for the v3 rollout — older SDKs continue to
8+
work because the gate IGNORES the client-supplied execution_id
9+
(it mints its own), but they cannot fully participate in the
10+
v3 /track idempotency contract.
11+
12+
---
13+
14+
v3.12 / 0.12.1 (2026-07-04) — bug-fix: complete the wiring
15+
that 0.12.0 advertised.
16+
17+
Honest history: the v0.12.0 changelog entry above said "the
18+
SDK no longer needs to generate its own execution_id for
19+
/check; it gets the server-minted one back in the response
20+
and propagates it to /track", but the propagation code was
21+
NOT shipped in 0.12.0. The 0.12.0 wire was correct in intent
22+
but the SDK still routed through /track/batch and ignored
23+
`response["reservation_id"]` (see
24+
`docs/sdk-v3-migration-gaps.md` and audit memory
25+
`sdk-v3-migration-gaps`).
26+
27+
0.12.1 ships the four missing pieces:
28+
29+
1. ``_capture_server_minted_execution_id(response)`` reads
30+
``reservation_id`` from the /check response into a
31+
contextvar ``nullrun.context._server_minted_execution_id_var``.
32+
2. ``_enrich_event`` stamps the captured id onto /track
33+
payloads (with a 295s freshness guard so an expired
34+
reservation never ships a doomed id).
35+
3. ``_route_track`` dispatches ``llm_call`` events to the
36+
v3 single-event endpoint ``/api/v1/track`` via
37+
``Transport.track_single``, so the backend's
38+
``gate_consume_v3`` validates the consume-vs-reserve +
39+
ε invariant (CLAUDE.md §25).
40+
4. ``NULLRUN_V3_TRACK_DISABLE=1`` opt-out for backends still
41+
on the v1/v2 path.
42+
43+
Pinning: still SDK_MIN_VERSION_FOR_V3 = "0.12.0". Operators
44+
upgrading from < 0.12.0 should jump straight to 0.12.1 — 0.12.0
45+
released with the integrity bug above and was never deployed
46+
in production with the v3 wiring.
1447
"""
1548

16-
__version__ = "0.12.0"
49+
__version__ = "0.12.1"
1750
__platform_version__ = "1.0.0"

src/nullrun/context.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import uuid
2323
from collections.abc import Generator
2424
from contextlib import contextmanager
25-
from contextvars import ContextVar
25+
from contextvars import ContextVar, Token
2626

2727
# Context variables for workflow/trace propagation.
2828
_workflow_id_var: ContextVar[str | None] = ContextVar("workflow_id", default=None)
@@ -160,6 +160,145 @@ def set_chain_op(op: str) -> None:
160160
_chain_op_var.set(op)
161161

162162

163+
# ---------------------------------------------------------------------------
164+
# Server-minted execution_id (2026-07-04 — CLAUDE.md §24, §29)
165+
# ---------------------------------------------------------------------------
166+
#
167+
# Pre-0.12.0 the SDK sent a client-supplied ``execution_id`` (usually
168+
# ``workflow_id``) in /check requests and IGNORED the server's response.
169+
# This left two problems:
170+
#
171+
# 1. CLAUDE.md §24 ownership — the backend's `gate_reserve_v3`
172+
# generates a uuidv7 internally, persists
173+
# ``execution:{execution_id}`` (24h TTL) and creates
174+
# ``reservation:{execution_id}`` (300s TTL). The client-minted
175+
# id never matched, so on the v3 path the gate rejected /track
176+
# with 503 RESERVATION_NOT_FOUND (§29 — fail-CLOSED).
177+
#
178+
# 2. CLAUDE.md §23 idempotency — /track's ``idempotency_key``
179+
# contract depends on the server-minted UUID being reused
180+
# on retry. Without picking it up at /check the SDK has no
181+
# way to compute a stable key.
182+
#
183+
# Fix: capture the ``reservation_id`` field from the /check
184+
# response into this contextvar. The runtime sets it on every
185+
# successful /check; the runtime's ``_enrich_event`` reads it on
186+
# the way out and tags the /track payload with ``execution_id``.
187+
#
188+
# Lifetime: scoped automatically by ``with workflow(...)`` /
189+
# ``with chain(...)`` — the runtime resets the contextvar on
190+
# block exit so a /check in one block never leaks into a /track
191+
# in a sibling block. Tests can drive it manually with
192+
# ``set_/reset_server_minted_execution_id`` (Token-based API
193+
# mirrors the user-facing audit spec; ``clear_`` is a
194+
# no-token convenience for the runtime's ``_enrich_event``
195+
# after a /track has been issued).
196+
#
197+
# The reservation TTL (300s) is shorter than the chain id's 24h
198+
# binding TTL, so we also record the capture timestamp —
199+
# ``get_server_minted_reservation_at`` returns ``time.monotonic()``
200+
# at the moment /check returned 200. The runtime ignores the
201+
# contextvar when the age exceeds 295s (5s margin below the
202+
# 300s backend reservation TTL) so an exceptionally long LLM
203+
# call never ships a doomed ``execution_id``.
204+
_server_minted_execution_id_var: ContextVar[str | None] = ContextVar(
205+
"server_minted_execution_id", default=None
206+
)
207+
_server_minted_reservation_at_var: ContextVar[float] = ContextVar(
208+
"server_minted_reservation_at", default=0.0
209+
)
210+
211+
212+
def get_server_minted_execution_id() -> str | None:
213+
"""Return the server-minted execution_id from the last /check, or
214+
``None`` if none captured in scope.
215+
216+
Read by ``NullRunRuntime._enrich_event`` to tag the /track
217+
payload. ``None`` is the legacy / v1-v2 path — the wire spec
218+
allows the field to be omitted when the backend has not
219+
minted one (capability ``server_minted_execution_id=False``).
220+
"""
221+
return _server_minted_execution_id_var.get()
222+
223+
224+
def get_server_minted_reservation_at() -> float:
225+
"""Return ``time.monotonic()`` at the moment of /check capture,
226+
or ``0.0`` if no capture in scope.
227+
228+
Used by ``NullRunRuntime._enrich_event`` to refuse a /track
229+
whose /check has aged past the v3 reservation TTL (300s —
230+
CLAUDE.md §29). The runtime captures the timestamp at the
231+
same instant the id is captured, so the two values always
232+
refer to the same /check.
233+
"""
234+
return _server_minted_reservation_at_var.get()
235+
236+
237+
def set_server_minted_execution_id(value: str | None) -> Token[str | None]:
238+
"""Capture the server-minted execution_id returned by /check.
239+
240+
Returns the ``Token`` so the caller can restore the previous
241+
value via :func:`reset_server_minted_execution_id`. The
242+
runtime drives the lifetime explicitly (it owns the
243+
capture/reset cycle around the user-function call) — user
244+
code does not need to call this directly.
245+
246+
Args:
247+
value: UUID v7 string returned on ``GateResponse.
248+
reservation_id`` (server-minted per §24). Pass
249+
``None`` to clear (e.g. on a hard block response
250+
which carries no reservation_id).
251+
"""
252+
return _server_minted_execution_id_var.set(value)
253+
254+
255+
def set_server_minted_reservation_at(value: float) -> Token[float]:
256+
"""Capture the ``time.monotonic()`` instant corresponding to
257+
``set_server_minted_execution_id``.
258+
259+
Called by the runtime immediately after :func:`set_server_minted_execution_id`
260+
so the two timestamps stay in lockstep. Returns the matching
261+
Token for symmetric :func:`reset_server_minted_reservation_at`.
262+
"""
263+
return _server_minted_reservation_at_var.set(value)
264+
265+
266+
def reset_server_minted_execution_id(token: Token[str | None]) -> None:
267+
"""Restore the previous server-minted execution_id value.
268+
269+
Pair with :func:`set_server_minted_execution_id`. The runtime
270+
stores the token at capture time and resets it on the matching
271+
/track emission (or at workflow/chain block exit, whichever
272+
comes first).
273+
"""
274+
_server_minted_execution_id_var.reset(token)
275+
276+
277+
def reset_server_minted_reservation_at(token: Token[float]) -> None:
278+
"""Restore the previous reservation capture timestamp.
279+
280+
Pair with :func:`set_server_minted_reservation_at`.
281+
"""
282+
_server_minted_reservation_at_var.reset(token)
283+
284+
285+
def clear_server_minted_execution_id() -> None:
286+
"""Erase the captured server-minted execution_id + timestamp.
287+
288+
No-token convenience for the runtime's "block exited, drop the
289+
capture" code path. Equivalent to::
290+
291+
_server_minted_execution_id_var.set(None)
292+
_server_minted_reservation_at_var.set(0.0)
293+
294+
Use :func:`reset_server_minted_execution_id` instead when you
295+
have a Token to consume — that path restores the previous
296+
scope's value, ``clear_`` strictly forgets it.
297+
"""
298+
_server_minted_execution_id_var.set(None)
299+
_server_minted_reservation_at_var.set(0.0)
300+
301+
163302
def set_attempt_index(index: int) -> None:
164303
"""Set current attempt index for retry correlation."""
165304
_attempt_index_var.set(index)

0 commit comments

Comments
 (0)