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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions docs/sdk-v3-migration-gaps.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 9 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 43 additions & 10 deletions src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -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"
141 changes: 140 additions & 1 deletion src/nullrun/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading