Skip to content

Commit 79a6e7e

Browse files
authored
fix(sdk): make Author field non-empty + v3 capability probe (#49)
* 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. * fix(sdk): populate Author/Author-email via metadata hook PEP 621 maps `authors` to PKG-INFO's `Author-email:` line but not to the legacy single `Author:` line that `pip show` renders, and pip does not display `Maintainer:` either. As a result every previous release shipped with an empty `Author:` and the maintainer's name never appeared in `pip show nullrun`. Hatchling compounds this: its authors parser only adds an entry to `authors_data["name"]` (which becomes `Author:`) when an inline-table has a `name` and NO `email`. When both are present the name is folded into `Author-email:`'s display_name and the legacy `Author:` line is suppressed entirely. Fix: declare `authors` and `maintainers` as dynamic fields and populate them from a custom hatchling metadata hook (`hatch_build.py`). The hook splits the primary author into a name-only + email-only inline-table pair so hatchling populates both `Author:` and `Author-email:`. Declaring at least one dynamic field is what actually wires `MetadataHookInterface.update()` — without it hatchling configures the hook but never invokes it. * fix(sdk): bind logger in init() and cover capability probe paths CI on Python 3.11 failed with `NameError: name 'logger' is not defined` in 5 tests. The `feat(sdk)` commit (2a7886b) added new `logger.warning/info/debug` calls in `init()` after the existing `import logging` but never assigned the `logger` name. Master passed only because its pre-existing `logger.warning` calls sit inside an `if existing is not None:` branch that tests rarely exercise; the new ones run on every `init()` call. Also covers the 9 newly-uncovered lines Codecov flagged: `probe_capabilities` failure paths (non-2xx / ConnectError / malformed JSON) and the four new `init()` logging branches (`debug=True` sets DEBUG; probe unreachable → INFO; probe raises → DEBUG; existing runtime shutdown raises → WARNING). Local verification (.venv-ci, Python 3.14): - pytest: 1154 passed (was 1129; +25 new) - ruff: clean - mypy: clean - coverage: 82.02% (threshold 82.00%) * style: reorder capability probe imports per ruff I001 Ruff's isort rule flagged the import block in `init()` — the `from nullrun.__version__` line was placed after `from nullrun.capabilities` but `__version__` sorts before `capabilities` (underscore is 0x5F, letters are 0x61+), so the correct alphabetical order is reversed. CI `Run ruff` step was failing on this; the previous commit's ruff output was checked against an outdated working copy.
1 parent 18a91e2 commit 79a6e7e

10 files changed

Lines changed: 898 additions & 15 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.

hatch_build.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Hatchling build hooks for the nullrun SDK.
2+
3+
``authors`` / ``maintainers`` injection
4+
---------------------------------------
5+
PEP 621 maps the ``authors`` array to PKG-INFO's ``Author-email:``
6+
line but does NOT populate the legacy single ``Author:`` line, and
7+
``pip show`` only renders ``Author:`` (it does not render
8+
``Maintainer:`` at all). As a result a project whose ``authors`` is
9+
``[{name=..., email=...}]`` ships with an empty ``Author:`` field and
10+
the maintainer's name never appears in ``pip show``.
11+
12+
Hatchling makes this worse: in its ``authors`` property parser
13+
(``hatchling/metadata/core.py``), an inline-table only contributes to
14+
the legacy ``Author:`` field when it has a ``name`` and NO ``email``.
15+
If both are set, the name is folded into the ``Author-email:``
16+
display_name and the ``Author:`` line is suppressed entirely.
17+
18+
This hook splits the primary author into two inline-table entries so
19+
hatchling populates both ``authors_data["name"]`` (``Author:``) and
20+
``authors_data["email"]`` (``Author-email:``)::
21+
22+
Author: Anatolii Maltsev
23+
Author-email: support@nullrun.io
24+
25+
It also sets ``maintainers`` to the publishing org for the PyPI
26+
sidebar (pip does not display ``Maintainer:``).
27+
28+
Why ``authors`` / ``maintainers`` are listed in ``project.dynamic``:
29+
hatchling only invokes ``MetadataHookInterface.update()`` when at
30+
least one field is marked dynamic. Removing the static arrays and
31+
keeping the hook as the single source of truth is what actually wires
32+
the update call.
33+
"""
34+
35+
from __future__ import annotations
36+
37+
from hatchling.metadata.plugin.interface import MetadataHookInterface
38+
39+
40+
class CustomMetadataHook(MetadataHookInterface):
41+
PLUGIN_NAME = "custom"
42+
43+
def update(self, metadata: dict) -> None:
44+
# See module docstring for the full rationale.
45+
metadata["authors"] = [
46+
{"name": "Anatolii Maltsev"},
47+
{"email": "support@nullrun.io"},
48+
]
49+
metadata["maintainers"] = [
50+
{"name": "nullrun.io", "email": "support@nullrun.io"},
51+
]

pyproject.toml

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,22 @@ readme = "README.md"
1414
license = { text = "Apache-2.0" }
1515
requires-python = ">=3.10"
1616

17-
authors = [
18-
{ name = "nullrun.io", email = "support@nullrun.io" }
19-
]
20-
21-
# Maintainer populates the PKG-INFO `Maintainer:` / `Maintainer-email:`
22-
# fields. PEP 621 maps the `authors` array to `Author-email:` but NOT
23-
# to the legacy `Author:` field, which leaves `pip show` displaying an
24-
# empty `Author:` line. Adding `maintainers` populates `Maintainer:`
25-
# instead so every metadata viewer shows non-empty contact info.
26-
maintainers = [
27-
{ name = "Anatolii Maltsev", email = "support@nullrun.io" }
28-
]
17+
# Authors and maintainers are populated dynamically by the custom
18+
# metadata hook in ``hatch_build.py``. Declaring ``authors`` here as a
19+
# dynamic field is what triggers hatchling to call
20+
# ``MetadataHookInterface.update()`` at all — without at least one
21+
# field in ``dynamic``, the hook is configured but never invoked.
22+
#
23+
# Why dynamic in the first place: PEP 621 maps the ``authors`` array to
24+
# PKG-INFO's ``Author-email:`` line but NOT to the legacy single
25+
# ``Author:`` line that ``pip show`` renders. Worse, hatchling's
26+
# authors parser (core.py, ``authors`` property) only populates the
27+
# legacy ``Author:`` field when an inline-table has a ``name`` and NO
28+
# ``email``; if both are present the name is folded into the email's
29+
# display_name and ``Author:`` is suppressed. The hook splits the
30+
# author into two inline-tables (name-only + email-only) so both lines
31+
# appear in the wheel METADATA.
32+
dynamic = ["authors", "maintainers"]
2933

3034
keywords = [
3135
"circuit-breaker", "agent", "llm", "observability",
@@ -166,6 +170,15 @@ include = [
166170
"src/nullrun/py.typed",
167171
]
168172

173+
# Custom metadata hook: rewrites ``project.authors`` into name-only +
174+
# email-only inline tables so hatchling's authors parser populates both
175+
# the legacy ``Author:`` field and the ``Author-email:`` field. See
176+
# ``hatch_build.py`` for the full rationale. The hook lives at the repo
177+
# root because hatchling discovers it by import path, not via the wheel
178+
# ``packages`` list above (which only covers the runtime package).
179+
[tool.hatch.metadata.hooks.custom]
180+
path = "hatch_build.py"
181+
169182
[tool.hatch.build.targets.sdist]
170183
exclude = [
171184
"tests/",

src/nullrun/__init__.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,10 @@ def my_agent():
228228
import logging
229229
import os
230230

231+
logger = logging.getLogger("nullrun")
232+
231233
if debug:
232-
logging.getLogger("nullrun").setLevel(logging.DEBUG)
234+
logger.setLevel(logging.DEBUG)
233235

234236
# T3-S2 (0.3.0): api_key is now required. Previous versions fell back
235237
# to a NullRunNoop stub in `local_mode`, which silently bypassed every
@@ -329,6 +331,38 @@ def my_agent():
329331
# drops span_start/span_end events.
330332
_dec_mod._runtime = runtime
331333

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

0 commit comments

Comments
 (0)