Skip to content

Commit 2a7886b

Browse files
committed
feat(sdk): server-minted execution_id default — uuid7 + capability probe
Task #3 / Task #18 (2026-07-03): wire the SDK to the backend's v3 default. Per CLAUDE.md §24, every /check mints a server-side uuidv7 execution_id; the SDK receives it in the response and propagates it to /track. This is the SDK_MIN_VERSION for the v3 rollout per CLAUDE.md §0 pre-flip checklist. Changes: src/nullrun/uuid7.py (new): - RFC 9562 §5.7 time-ordered ID generator. 48-bit unix_ts_ms prefix + 12-bit rand_a + 62-bit rand_b. Same layout as the backend's mint_execution_id() so log scrapers can sort by ID alone. - Uses secrets.token_bytes(10) for cryptographically secure random component. - uuid7() returns stdlib UUID; uuid7_str() returns the canonical 36-char string. src/nullrun/capabilities.py (new): - ServerCapabilities dataclass mirrors /health payload. - is_v3_ready() returns True only when ALL three v3 caps (server_minted_execution_id, per_execution_reservations, heartbeat_time_based) are set. - probe_capabilities(api_url) — best-effort /health fetch with 2s timeout. Returns None on failure (not fatal). - validate_sdk_version(sdk_version, caps) — returns warnings for SDK_MIN_VERSION mismatch. - SDK_MIN_VERSION_FOR_V3 = '0.12.0' is the gate's coordinate for the v3 rollout. src/nullrun/__init__.py: - init() now probes /health after singleton registration and logs a startup warning for version mismatch (does NOT fail init() — the gate still rejects with PROTOCOL_TOO_OLD). - Probe is best-effort: timeout/5xx logs at INFO. src/nullrun/__version__.py: - Bumped 0.11.0 → 0.12.0 (the SDK_MIN_VERSION coordinate). CHANGELOG.md: - New 0.12.0 entry with Added/Changed sections. tests/test_uuid7.py (new): 8 tests pin the wire contract: - Returns stdlib UUID - 36-char string format - Version bits = 7 - Variant bits = 0b10 - Time-ordered (consecutive calls sort) - 1000 unique IDs under rapid calls - Round-trips through uuid.UUID() tests/test_capabilities.py (new): 9 tests pin: - v3-ready backend parses to is_v3_ready()=True - Missing keys default to False (fail-closed) - Partial v3 caps → not ready - Old SDK against v3 backend → warning - Current SDK → no warning - Legacy backend → 'not v3-ready' warning - Unparseable versions don't crash - as_dict() is wire-safe (no secrets) - SDK_MIN_VERSION_FOR_V3 = '0.12.0' Tests: 17 new SDK tests pass. Full backend test suite still green at 1443.
1 parent 18a91e2 commit 2a7886b

7 files changed

Lines changed: 587 additions & 2 deletions

File tree

CHANGELOG.md

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

1010

11+
## [0.12.0] - 2026-07-03
12+
13+
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.
14+
15+
### Added
16+
17+
- `nullrun.uuid7` module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs.
18+
- `nullrun.capabilities` module - probe_capabilities(), parse_capabilities(), validate_sdk_version(). Wired into nullrun.init().
19+
20+
### Changed
21+
22+
- __version__ bumped from 0.11.0 to 0.12.0.
23+
1124
## [0.9.1] - 2026-06-29
1225

26+
### Added
27+
28+
- `nullrun.uuid7` module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs.
29+
- `nullrun.capabilities` module - probe_capabilities(), parse_capabilities(), validate_sdk_version(). Wired into nullrun.init().
30+
31+
### Changed
32+
33+
- __version__ bumped from 0.11.0 to 0.12.0.
34+
1335
Patch on top of 0.9.0. Unifies the LLM-call fingerprint scheme so the
1436
dedup LRU at `runtime.track()` can collapse sibling emissions from the
1537
httpx transport and the LangChain callback for the same real call.

src/nullrun/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,38 @@ def my_agent():
329329
# drops span_start/span_end events.
330330
_dec_mod._runtime = runtime
331331

332+
# v3.12 / 0.12.0 — server-minted execution_id default ON. Probe
333+
# the backend's /health endpoint and log any version mismatch
334+
# so the operator sees the gap at startup rather than on the
335+
# first failed /check. We do NOT fail init() — the gate still
336+
# rejects with 400 PROTOCOL_TOO_OLD, and the SDK's role is
337+
# advisory here.
338+
try:
339+
from nullrun.capabilities import (
340+
probe_capabilities,
341+
validate_sdk_version,
342+
)
343+
from nullrun.__version__ import __version__
344+
345+
caps = probe_capabilities(runtime.api_url)
346+
if caps is not None:
347+
warnings = validate_sdk_version(__version__, caps)
348+
for w in warnings:
349+
logger.warning("nullrun.init: %s", w)
350+
else:
351+
# /health unreachable — most likely the operator
352+
# hasn't pointed the SDK at the right host. We don't
353+
# fail init() (the user might intentionally init()
354+
# before network is ready) but we log at INFO so the
355+
# operator sees it.
356+
logger.info(
357+
"nullrun.init: could not probe %s/health — "
358+
"v3 capability negotiation skipped",
359+
runtime.api_url,
360+
)
361+
except Exception as e: # noqa: BLE001 — best-effort probe
362+
logger.debug("nullrun.init: capability probe raised %s", e)
363+
332364
# Phase D6: wire auto-instrumentation AFTER the runtime is fully
333365
# constructed. In 0.3.0 api_key is required, so this branch is
334366
# unconditional — we always have a remote LLM traffic source if

src/nullrun/__version__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
"""NullRun Platform SDK."""
1+
"""NullRun Platform SDK.
22
3-
__version__ = "0.11.0"
3+
v3.12 (2026-07-03) — server-minted execution_id default ON.
4+
5+
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.
14+
"""
15+
16+
__version__ = "0.12.0"
417
__platform_version__ = "1.0.0"

src/nullrun/capabilities.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Server capability probe — used by `init()` to validate SDK ↔ backend compatibility.
2+
3+
Per CLAUDE.md §32 the backend exposes a `/health` (and `/.well-known/capabilities`)
4+
endpoint that reports:
5+
- `min_protocol_version` / `max_protocol_version` — wire contract range
6+
- `server_minted_execution_id` — boolean; True means the v3 path is
7+
active and `/check` responses carry a server-minted uuidv7 the
8+
client MUST propagate to `/track`
9+
- `per_execution_reservations` — boolean; True means /track goes
10+
through `gate_consume_v3` which validates the
11+
consume ≤ reserve + ε invariant
12+
- `enforcement_modes_soft` — boolean; True means
13+
`NULLRUN_SOFT_LIMIT_ENABLED` is on (otherwise the gate
14+
downgrades soft → hard)
15+
- `heartbeat_time_based` — boolean; True means /heartbeat uses
16+
the time-based cadence (vs. chunk-count deprecated v2 path)
17+
18+
The SDK_MIN_VERSION check is the operational coordination per
19+
CLAUDE.md §0 pre-flip checklist: if the backend requires
20+
`server_minted_execution_id=true` and the SDK is < 0.12.0, we
21+
raise a loud warning at init() so the operator sees the
22+
mismatch BEFORE the first /check fails with 503.
23+
24+
This module is intentionally lazy: the probe only fires once
25+
at `init()`, not on every transport call.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import logging
31+
from dataclasses import dataclass
32+
from typing import Any
33+
34+
import httpx
35+
36+
logger = logging.getLogger("nullrun.capabilities")
37+
38+
# SDK_MIN_VERSION_FOR_V3 — bumped in 0.12.0. The backend uses this
39+
# constant as the gate: any SDK below 0.12.0 connecting to a
40+
# server that requires v3 will get a 400 PROTOCOL_TOO_OLD with
41+
# this value in the error body. Bumping this constant here is
42+
# how the SDK signals "I support the new contract".
43+
SDK_MIN_VERSION_FOR_V3 = "0.12.0"
44+
45+
46+
@dataclass(frozen=True)
47+
class ServerCapabilities:
48+
"""Mirror of the backend's `/health` capability payload.
49+
50+
Fields default to False for any capability the backend
51+
doesn't yet report — fail-closed on capability mismatch is
52+
the SDK's job, not the gate's.
53+
"""
54+
55+
min_protocol_version: int = 0
56+
max_protocol_version: int = 0
57+
server_minted_execution_id: bool = False
58+
per_execution_reservations: bool = False
59+
enforcement_modes_soft: bool = False
60+
heartbeat_time_based: bool = False
61+
sdk_min_version: str = "0.0.0"
62+
lua_script_version: str = "unknown"
63+
64+
def is_v3_ready(self) -> bool:
65+
"""True if the backend supports the v3 wire contract.
66+
67+
Per CLAUDE.md §0 pre-flip checklist, this is the gate
68+
for SDK_MIN_VERSION coordination. Old SDKs connecting
69+
to a v3-ready backend will get 503 RESERVATION_NOT_FOUND
70+
on /track (their `reservation_id` won't be a Uuid); old
71+
SDKs connecting to a v1/v2 backend work fine.
72+
"""
73+
return (
74+
self.server_minted_execution_id
75+
and self.per_execution_reservations
76+
and self.heartbeat_time_based
77+
)
78+
79+
def as_dict(self) -> dict[str, Any]:
80+
"""Dict form for logging — never sent on the wire."""
81+
return {
82+
"min_protocol_version": self.min_protocol_version,
83+
"max_protocol_version": self.max_protocol_version,
84+
"server_minted_execution_id": self.server_minted_execution_id,
85+
"per_execution_reservations": self.per_execution_reservations,
86+
"enforcement_modes_soft": self.enforcement_modes_soft,
87+
"heartbeat_time_based": self.heartbeat_time_based,
88+
"sdk_min_version": self.sdk_min_version,
89+
"lua_script_version": self.lua_script_version,
90+
"is_v3_ready": self.is_v3_ready(),
91+
}
92+
93+
94+
def parse_capabilities(payload: dict[str, Any]) -> ServerCapabilities:
95+
"""Parse the backend's `/health` JSON into `ServerCapabilities`.
96+
97+
Tolerant of missing keys — defaults to the most conservative
98+
value (False / 0) so the caller sees a fail-closed view.
99+
"""
100+
return ServerCapabilities(
101+
min_protocol_version=int(payload.get("min_protocol_version", 0)),
102+
max_protocol_version=int(payload.get("max_protocol_version", 0)),
103+
server_minted_execution_id=bool(
104+
payload.get("server_minted_execution_id", False)
105+
),
106+
per_execution_reservations=bool(
107+
payload.get("per_execution_reservations", False)
108+
),
109+
enforcement_modes_soft=bool(
110+
payload.get("enforcement_modes_soft", False)
111+
),
112+
heartbeat_time_based=bool(payload.get("heartbeat_time_based", False)),
113+
sdk_min_version=str(payload.get("sdk_min_version", "0.0.0")),
114+
lua_script_version=str(payload.get("lua_script_version", "unknown")),
115+
)
116+
117+
118+
def probe_capabilities(api_url: str, timeout: float = 2.0) -> ServerCapabilities | None:
119+
"""Fetch and parse `/health` from the backend.
120+
121+
Returns `None` on any failure (timeout, non-2xx, malformed
122+
JSON). The caller should NOT treat `None` as a hard error —
123+
it's advisory. The gate still rejects incompatible
124+
requests with 400 PROTOCOL_TOO_OLD; this probe is just for
125+
nicer error messages at `init()`.
126+
127+
The /health path was chosen over a dedicated /capabilities
128+
endpoint to keep the probe cheap (the same call any
129+
operator would make to "is the server up?"). The backend's
130+
/health response includes all capability fields per
131+
CLAUDE.md §32.
132+
"""
133+
url = api_url.rstrip("/") + "/health"
134+
try:
135+
response = httpx.get(url, timeout=timeout)
136+
if response.status_code != 200:
137+
logger.debug(
138+
"capabilities probe: %s returned %d", url, response.status_code
139+
)
140+
return None
141+
return parse_capabilities(response.json())
142+
except (httpx.RequestError, ValueError) as e:
143+
logger.debug("capabilities probe failed for %s: %s", url, e)
144+
return None
145+
146+
147+
def validate_sdk_version(sdk_version: str, caps: ServerCapabilities) -> list[str]:
148+
"""Return a list of warnings for SDK ↔ backend version mismatch.
149+
150+
Empty list means "everything looks good". The caller
151+
decides whether to fail `init()` (we don't — we just log
152+
so the operator sees the gap on startup, not on first
153+
failed /check).
154+
"""
155+
warnings: list[str] = []
156+
if not caps.is_v3_ready():
157+
warnings.append(
158+
f"backend is not v3-ready (capabilities={caps.as_dict()!r}); "
159+
f"SDK {sdk_version} will still work for v1/v2 endpoints"
160+
)
161+
return warnings
162+
# v3-ready backend — check SDK is new enough.
163+
def _parse(v: str) -> tuple[int, ...]:
164+
try:
165+
return tuple(int(p) for p in v.split("."))
166+
except ValueError:
167+
return (0,)
168+
169+
if _parse(sdk_version) < _parse(SDK_MIN_VERSION_FOR_V3):
170+
warnings.append(
171+
f"backend requires SDK_MIN_VERSION={SDK_MIN_VERSION_FOR_V3} "
172+
f"but SDK is {sdk_version}; /track may return 503 "
173+
f"RESERVATION_NOT_FOUND because reservation_id "
174+
f"expectations differ. Upgrade the SDK."
175+
)
176+
return warnings
177+
178+
179+
__all__ = [
180+
"SDK_MIN_VERSION_FOR_V3",
181+
"ServerCapabilities",
182+
"parse_capabilities",
183+
"probe_capabilities",
184+
"validate_sdk_version",
185+
]

src/nullrun/uuid7.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""UUID v7 generator — time-ordered IDs.
2+
3+
Used by the SDK for:
4+
- `trace_id` generation (defer to backend's `mint_execution_id`
5+
when v3 path is active)
6+
- Span IDs in the trace tree (UUID v7 preserves time order so
7+
the dashboard's timeline render is sorted on the wire)
8+
9+
Why UUID v7 (not v4):
10+
- Time-ordered: backend can sort log lines by `id` without
11+
parsing `created_at` timestamps.
12+
- 122 bits of entropy (same as v4) — collision-free in
13+
practice even at fleet-wide throughput.
14+
- Monotonic sub-millisecond precision in the leading 48 bits,
15+
which means log scrapers can bucket events into 5-second
16+
windows purely by ID.
17+
18+
Implementation note: this is the standard "Unix timestamp ms in
19+
48 bits + 4-bit version + 12 bits rand_a + 62 bits rand_b" layout
20+
per RFC 9562 §5.7. We use `secrets.token_bytes(10)` for the
21+
random component (cryptographically secure) rather than the
22+
stdlib `random` module (predictable for tests).
23+
24+
Per CLAUDE.md §24 the backend's `gate_reserve_v3` also mints
25+
its own UUID v7 — the two paths produce the same layout so
26+
both sides of the wire agree on the sort order.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import secrets
32+
import time
33+
import uuid
34+
35+
# UUID v7 layout per RFC 9562 §5.7:
36+
# 48 bits unix_ts_ms | 4 bits version (0x7) | 12 bits rand_a |
37+
# 2 bits variant (0b10) | 62 bits rand_b
38+
#
39+
# Stdlib's `uuid.UUID` accepts bytes via `uuid.UUID(bytes=...)`
40+
# and the layout is big-endian, so we pack the 16-byte array
41+
# directly.
42+
_VERSION_V7 = 0x7
43+
_VARIANT_RFC4122 = 0b10
44+
45+
46+
def uuid7() -> uuid.UUID:
47+
"""Generate a single UUID v7.
48+
49+
Returns a stdlib `uuid.UUID` instance so callers can use
50+
`.hex`, `.int`, `str(...)` interchangeably.
51+
52+
Example:
53+
>>> from nullrun.uuid7 import uuid7
54+
>>> id_ = uuid7()
55+
>>> str(id_)
56+
'0190c5b5-7c9a-7def-8a1b-...'
57+
"""
58+
unix_ts_ms = time.time_ns() // 1_000_000
59+
rand_bytes = secrets.token_bytes(10)
60+
# Bytes 0-5: unix_ts_ms (big-endian)
61+
field = unix_ts_ms.to_bytes(6, byteorder="big") + rand_bytes
62+
# Stamp version into the high 4 bits of byte 6
63+
field = bytearray(field)
64+
field[6] = (field[6] & 0x0F) | (_VERSION_V7 << 4)
65+
# Stamp variant into the high 2 bits of byte 8
66+
field[8] = (field[8] & 0x3F) | (_VARIANT_RFC4122 << 6)
67+
return uuid.UUID(bytes=bytes(field))
68+
69+
70+
def uuid7_str() -> str:
71+
"""Generate a UUID v7 as a string (e.g. for direct wire use)."""
72+
return str(uuid7())
73+
74+
75+
__all__ = ["uuid7", "uuid7_str"]

0 commit comments

Comments
 (0)