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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,30 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
---


## [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.

### Added

- `nullrun.uuid7` module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs.
- `nullrun.capabilities` module - probe_capabilities(), parse_capabilities(), validate_sdk_version(). Wired into nullrun.init().

### Changed

- __version__ bumped from 0.11.0 to 0.12.0.

## [0.9.1] - 2026-06-29

### Added

- `nullrun.uuid7` module - RFC 9562 section 5.7 time-ordered ID generator. Used internally for trace_id and span IDs.
- `nullrun.capabilities` module - probe_capabilities(), parse_capabilities(), validate_sdk_version(). Wired into nullrun.init().

### Changed

- __version__ bumped from 0.11.0 to 0.12.0.

Patch on top of 0.9.0. Unifies the LLM-call fingerprint scheme so the
dedup LRU at `runtime.track()` can collapse sibling emissions from the
httpx transport and the LangChain callback for the same real call.
Expand Down
51 changes: 51 additions & 0 deletions hatch_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Hatchling build hooks for the nullrun SDK.

``authors`` / ``maintainers`` injection
---------------------------------------
PEP 621 maps the ``authors`` array to PKG-INFO's ``Author-email:``
line but does NOT populate the legacy single ``Author:`` line, and
``pip show`` only renders ``Author:`` (it does not render
``Maintainer:`` at all). As a result a project whose ``authors`` is
``[{name=..., email=...}]`` ships with an empty ``Author:`` field and
the maintainer's name never appears in ``pip show``.

Hatchling makes this worse: in its ``authors`` property parser
(``hatchling/metadata/core.py``), an inline-table only contributes to
the legacy ``Author:`` field when it has a ``name`` and NO ``email``.
If both are set, the name is folded into the ``Author-email:``
display_name and the ``Author:`` line is suppressed entirely.

This hook splits the primary author into two inline-table entries so
hatchling populates both ``authors_data["name"]`` (``Author:``) and
``authors_data["email"]`` (``Author-email:``)::

Author: Anatolii Maltsev
Author-email: support@nullrun.io

It also sets ``maintainers`` to the publishing org for the PyPI
sidebar (pip does not display ``Maintainer:``).

Why ``authors`` / ``maintainers`` are listed in ``project.dynamic``:
hatchling only invokes ``MetadataHookInterface.update()`` when at
least one field is marked dynamic. Removing the static arrays and
keeping the hook as the single source of truth is what actually wires
the update call.
"""

from __future__ import annotations

from hatchling.metadata.plugin.interface import MetadataHookInterface


class CustomMetadataHook(MetadataHookInterface):
PLUGIN_NAME = "custom"

def update(self, metadata: dict) -> None:
# See module docstring for the full rationale.
metadata["authors"] = [
{"name": "Anatolii Maltsev"},
{"email": "support@nullrun.io"},
]
metadata["maintainers"] = [
{"name": "nullrun.io", "email": "support@nullrun.io"},
]
37 changes: 25 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,22 @@ readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.10"

authors = [
{ name = "nullrun.io", email = "support@nullrun.io" }
]

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

keywords = [
"circuit-breaker", "agent", "llm", "observability",
Expand Down Expand Up @@ -166,6 +170,15 @@ include = [
"src/nullrun/py.typed",
]

# Custom metadata hook: rewrites ``project.authors`` into name-only +
# email-only inline tables so hatchling's authors parser populates both
# the legacy ``Author:`` field and the ``Author-email:`` field. See
# ``hatch_build.py`` for the full rationale. The hook lives at the repo
# root because hatchling discovers it by import path, not via the wheel
# ``packages`` list above (which only covers the runtime package).
[tool.hatch.metadata.hooks.custom]
path = "hatch_build.py"

[tool.hatch.build.targets.sdist]
exclude = [
"tests/",
Expand Down
36 changes: 35 additions & 1 deletion src/nullrun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,10 @@ def my_agent():
import logging
import os

logger = logging.getLogger("nullrun")

if debug:
logging.getLogger("nullrun").setLevel(logging.DEBUG)
logger.setLevel(logging.DEBUG)

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

# v3.12 / 0.12.0 — server-minted execution_id default ON. Probe
# the backend's /health endpoint and log any version mismatch
# so the operator sees the gap at startup rather than on the
# first failed /check. We do NOT fail init() — the gate still
# rejects with 400 PROTOCOL_TOO_OLD, and the SDK's role is
# advisory here.
try:
from nullrun.__version__ import __version__
from nullrun.capabilities import (
probe_capabilities,
validate_sdk_version,
)

caps = probe_capabilities(runtime.api_url)
if caps is not None:
warnings = validate_sdk_version(__version__, caps)
for w in warnings:
logger.warning("nullrun.init: %s", w)
else:
# /health unreachable — most likely the operator
# hasn't pointed the SDK at the right host. We don't
# fail init() (the user might intentionally init()
# before network is ready) but we log at INFO so the
# operator sees it.
logger.info(
"nullrun.init: could not probe %s/health — "
"v3 capability negotiation skipped",
runtime.api_url,
)
except Exception as e: # noqa: BLE001 — best-effort probe
logger.debug("nullrun.init: capability probe raised %s", e)

# Phase D6: wire auto-instrumentation AFTER the runtime is fully
# constructed. In 0.3.0 api_key is required, so this branch is
# unconditional — we always have a remote LLM traffic source if
Expand Down
17 changes: 15 additions & 2 deletions src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
"""NullRun Platform SDK."""
"""NullRun Platform SDK.

__version__ = "0.11.0"
v3.12 (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.
"""

__version__ = "0.12.0"
__platform_version__ = "1.0.0"
185 changes: 185 additions & 0 deletions src/nullrun/capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Server capability probe — used by `init()` to validate SDK ↔ backend compatibility.

Per CLAUDE.md §32 the backend exposes a `/health` (and `/.well-known/capabilities`)
endpoint that reports:
- `min_protocol_version` / `max_protocol_version` — wire contract range
- `server_minted_execution_id` — boolean; True means the v3 path is
active and `/check` responses carry a server-minted uuidv7 the
client MUST propagate to `/track`
- `per_execution_reservations` — boolean; True means /track goes
through `gate_consume_v3` which validates the
consume ≤ reserve + ε invariant
- `enforcement_modes_soft` — boolean; True means
`NULLRUN_SOFT_LIMIT_ENABLED` is on (otherwise the gate
downgrades soft → hard)
- `heartbeat_time_based` — boolean; True means /heartbeat uses
the time-based cadence (vs. chunk-count deprecated v2 path)

The SDK_MIN_VERSION check is the operational coordination per
CLAUDE.md §0 pre-flip checklist: if the backend requires
`server_minted_execution_id=true` and the SDK is < 0.12.0, we
raise a loud warning at init() so the operator sees the
mismatch BEFORE the first /check fails with 503.

This module is intentionally lazy: the probe only fires once
at `init()`, not on every transport call.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any

import httpx

logger = logging.getLogger("nullrun.capabilities")

# SDK_MIN_VERSION_FOR_V3 — bumped in 0.12.0. The backend uses this
# constant as the gate: any SDK below 0.12.0 connecting to a
# server that requires v3 will get a 400 PROTOCOL_TOO_OLD with
# this value in the error body. Bumping this constant here is
# how the SDK signals "I support the new contract".
SDK_MIN_VERSION_FOR_V3 = "0.12.0"


@dataclass(frozen=True)
class ServerCapabilities:
"""Mirror of the backend's `/health` capability payload.

Fields default to False for any capability the backend
doesn't yet report — fail-closed on capability mismatch is
the SDK's job, not the gate's.
"""

min_protocol_version: int = 0
max_protocol_version: int = 0
server_minted_execution_id: bool = False
per_execution_reservations: bool = False
enforcement_modes_soft: bool = False
heartbeat_time_based: bool = False
sdk_min_version: str = "0.0.0"
lua_script_version: str = "unknown"

def is_v3_ready(self) -> bool:
"""True if the backend supports the v3 wire contract.

Per CLAUDE.md §0 pre-flip checklist, this is the gate
for SDK_MIN_VERSION coordination. Old SDKs connecting
to a v3-ready backend will get 503 RESERVATION_NOT_FOUND
on /track (their `reservation_id` won't be a Uuid); old
SDKs connecting to a v1/v2 backend work fine.
"""
return (
self.server_minted_execution_id
and self.per_execution_reservations
and self.heartbeat_time_based
)

def as_dict(self) -> dict[str, Any]:
"""Dict form for logging — never sent on the wire."""
return {
"min_protocol_version": self.min_protocol_version,
"max_protocol_version": self.max_protocol_version,
"server_minted_execution_id": self.server_minted_execution_id,
"per_execution_reservations": self.per_execution_reservations,
"enforcement_modes_soft": self.enforcement_modes_soft,
"heartbeat_time_based": self.heartbeat_time_based,
"sdk_min_version": self.sdk_min_version,
"lua_script_version": self.lua_script_version,
"is_v3_ready": self.is_v3_ready(),
}


def parse_capabilities(payload: dict[str, Any]) -> ServerCapabilities:
"""Parse the backend's `/health` JSON into `ServerCapabilities`.

Tolerant of missing keys — defaults to the most conservative
value (False / 0) so the caller sees a fail-closed view.
"""
return ServerCapabilities(
min_protocol_version=int(payload.get("min_protocol_version", 0)),
max_protocol_version=int(payload.get("max_protocol_version", 0)),
server_minted_execution_id=bool(
payload.get("server_minted_execution_id", False)
),
per_execution_reservations=bool(
payload.get("per_execution_reservations", False)
),
enforcement_modes_soft=bool(
payload.get("enforcement_modes_soft", False)
),
heartbeat_time_based=bool(payload.get("heartbeat_time_based", False)),
sdk_min_version=str(payload.get("sdk_min_version", "0.0.0")),
lua_script_version=str(payload.get("lua_script_version", "unknown")),
)


def probe_capabilities(api_url: str, timeout: float = 2.0) -> ServerCapabilities | None:
"""Fetch and parse `/health` from the backend.

Returns `None` on any failure (timeout, non-2xx, malformed
JSON). The caller should NOT treat `None` as a hard error —
it's advisory. The gate still rejects incompatible
requests with 400 PROTOCOL_TOO_OLD; this probe is just for
nicer error messages at `init()`.

The /health path was chosen over a dedicated /capabilities
endpoint to keep the probe cheap (the same call any
operator would make to "is the server up?"). The backend's
/health response includes all capability fields per
CLAUDE.md §32.
"""
url = api_url.rstrip("/") + "/health"
try:
response = httpx.get(url, timeout=timeout)
if response.status_code != 200:
logger.debug(
"capabilities probe: %s returned %d", url, response.status_code
)
return None
return parse_capabilities(response.json())
except (httpx.RequestError, ValueError) as e:
logger.debug("capabilities probe failed for %s: %s", url, e)
return None


def validate_sdk_version(sdk_version: str, caps: ServerCapabilities) -> list[str]:
"""Return a list of warnings for SDK ↔ backend version mismatch.

Empty list means "everything looks good". The caller
decides whether to fail `init()` (we don't — we just log
so the operator sees the gap on startup, not on first
failed /check).
"""
warnings: list[str] = []
if not caps.is_v3_ready():
warnings.append(
f"backend is not v3-ready (capabilities={caps.as_dict()!r}); "
f"SDK {sdk_version} will still work for v1/v2 endpoints"
)
return warnings
# v3-ready backend — check SDK is new enough.
def _parse(v: str) -> tuple[int, ...]:
try:
return tuple(int(p) for p in v.split("."))
except ValueError:
return (0,)

if _parse(sdk_version) < _parse(SDK_MIN_VERSION_FOR_V3):
warnings.append(
f"backend requires SDK_MIN_VERSION={SDK_MIN_VERSION_FOR_V3} "
f"but SDK is {sdk_version}; /track may return 503 "
f"RESERVATION_NOT_FOUND because reservation_id "
f"expectations differ. Upgrade the SDK."
)
return warnings


__all__ = [
"SDK_MIN_VERSION_FOR_V3",
"ServerCapabilities",
"parse_capabilities",
"probe_capabilities",
"validate_sdk_version",
]
Loading
Loading