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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
## [0.16.4] - 2026-08-31

Patch release — ADR-037 Slice B. The wire protocol bumps from 3 → 4 additively: `/gate` response now echoes the SDK-supplied `action_digest` and a `policy_hash` slot (always `None` today; Slice D wires per-request computation). `min_protocol_version` stays at 2 so v3 SDKs are unaffected. Wire-format additive only — no new hashing/computation introduced on either side (both fields echo already-computed values).

### Added

- **`NULLRUN_PROTOCOL_VERSION = 4`** (`src/nullrun/transport.py`). `X-NULLRUN-PROTOCOL` header on every signed POST now serialises the bumped value via the single source of truth `NULLRUN_PROTOCOL_VERSION`; tests are pinned to `str(NULLRUN_PROTOCOL_VERSION)` so a future bump doesn't sweep this file again. `NullRunProtocolError.user_action` and `docs/errors/NR-P001.md` updated to point operators at `X-NULLRUN-PROTOCOL: 4`.
- **`/gate` response wire-evidence echo capture** — `runtime._capture_wire_evidence` (called from `_capture_server_minted_execution_id` on the same `/check` lifetime so the two values always refer to the same gate decision) reads `action_digest` + `policy_hash` off the response and stores them in two new contextvars: `_last_gate_action_digest_var`, `_last_gate_policy_hash_var`. Public accessors `get_last_gate_action_digest()` / `get_last_gate_policy_hash()`; setters `set_last_gate_action_digest()` / `set_last_gate_policy_hash()`. Capture is fail-OPEN: a malformed value (non-str type) is logged at WARNING and dropped — the contextvar stays at `None`. `clear_server_minted_execution_id` (and the underlying direct `set(...=None)` paths) also drop the v4 slots so a `/check` in one block never leaks a stale echo into a `/track` in a sibling block.
- **`ServerCapabilities.wire_evidence_echo`** — informational capability flag surfaced by `/api/v1/capabilities`. Tells the SDK the backend echoes `action_digest` on `/gate` response. NOT included in `is_v3_ready()` (informational, not a hard gate). Defaults to `False` on pre-v4 backends; the canonical shape is `capabilities.wire_evidence_echo: true` at the top level, with the nested `capabilities.*` form also accepted.
- **New test file `tests/test_slice_b_wire_evidence.py`** (10 tests). Pins the SDK-side of the v3→v4 additive bump: protocol-constant value, header serialisation, capture from `/gate` response (happy path + `policy_hash`-when-present + both-set), tolerance of pre-v4 backends (no keys → both `None`), tolerance of malformed wire values (non-str → drop, do not raise), tolerance of `None`-typed responses (defensive — runtime never passes a non-dict, but a bad transport layer might), `clear_server_minted_execution_id` resets the v4 slots, and the protocol-constant + capability-flag source-of-truth wiring.
- **`tests/test_capabilities.py`** — two new assertions: `test_parse_capabilities_wire_evidence_echo_v4_backend` (top-level + nested + missing-key), `test_parse_capabilities_v4_protocol_range` (min=2 stays, max moves to 4).
- **README alpha-status line + roadmap table** — `v0.15` → `v0.15.x` (so the v0.15.x fail-OPEN observability closure isn't squashed); `v0.16` → `v0.16.x` with the new highlights (Phase-1+ `action_digest` on `/gate`, `/execute` `tools` propagation, NR-006 transient-5xx retry, NR-007 error-code parity 41→56 entries, Slice B wire-evidence echo); `v0.17` for the OpenTelemetry exporter / Redis-backed offline queue / hardened init contract that previously sat under `v0.16`.

### Changed

- **`/gate` response handler now reads two more keys.** `action_digest` (SDK-supplied SHA-256 hex of canonical `business_impact`, re-verified server-side by `payload_binding::server_derive_action_digest`, echoed back so the SDK can confirm what the gate saw matches what it intended) and `policy_hash` (slot reserved for future Slice D wiring — today always `None` because the gate doesn't compute per-request hashes; the audit row stores `policy_hash = None` for the same reason at `audit_drain.rs:301`). Pre-v4 backends omit both keys entirely via `skip_serializing_if = "Option::is_none"` — a v4 SDK connecting to a v3 backend reads `None` on both fields and logs "no wire evidence echo" — no false positive.
- **`tests/contract/test_audit_wire.py` + `tests/test_v3_wire_contract.py`** — header assertions now source `str(NULLRUN_PROTOCOL_VERSION)` instead of the literal `"3"` so a future bump doesn't require sweeping either file. Class names kept (`TestSignedPostIncludesProtocolHeader`) for git-blame continuity.

### Compatibility

Wire-format additive — pre-v4 SDKs parsing the response simply ignore the new fields; v4 SDKs parsing a v3 backend response see `None` on both fields (skip_serializing_if on the backend means the JSON keys are absent, not `null`). `min_protocol_version` stays at 2, so v3 SDKs continue to work against a v4 backend. The architectural invariant `GateResponse.action_digest == AuditEvent.action_digest` holds trivially because both sides flow from the SDK's input. No new hashing/computation introduced on either side — both fields echo already-computed values.

### Verification

- Targeted suite: `tests/test_slice_b_wire_evidence.py` — 10/10 pass.
- Capabilities: `tests/test_capabilities.py::test_parse_capabilities_wire_evidence_echo_v4_backend`, `test_parse_capabilities_v4_protocol_range` — pass.
- Wire contract: `tests/test_v3_wire_contract.py` — pass (header assertions now source the constant).
- Audit wire: `tests/contract/test_audit_wire.py` — pass.
- Broader regression suite: `pytest -q` 1613 passed / 4 skipped (12 more than 0.16.3, accounting for the 10 new Slice B pins + 2 new capabilities assertions); `ruff check src tests` all checks pass; `mypy src/nullrun` no issues reported in 37 source files.

### Why this is needed

ADR-037 Slice B closes the SDK/backend wire-trust gap: pre-Slice-B the SDK had no way to verify the gate saw the same `action_digest` it intended — a misconfigured proxy or a future Slice A regression could swallow or rewrite the digest without any SDK-side signal. The echo slot on `/gate` response + the two contextvars give operators a clean diagnostic ("the gate echoed digest X — that's what I sent") and pin the architectural invariant `GateResponse.action_digest == AuditEvent.action_digest` at the SDK layer. `policy_hash` is forward-compat for Slice D; the slot is wired now so Slice D doesn't require another SDK release.

## [0.16.3] - 2026-08-26

Patch release — closes `NR-006` (audit 2026-08-24) and `NR-007` (audit 2026-08-24). No wire-format change. Pure reliability + SDK/backend parity hardening on top of 0.16.2.
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ LlamaIndex, and your own stack.

<!-- BADGES: capability markers -->
<div align="center">
<img src="https://img.shields.io/badge/protocol-v3.31-success?style=flat-square" alt="protocol v3.31" />
<img src="https://img.shields.io/badge/protocol-v4-success?style=flat-square" alt="protocol v4" />
<img src="https://img.shields.io/badge/zero--code-instrumentation-22C55E?style=flat-square" alt="Zero-code instrumentation" />
<img src="https://img.shields.io/badge/server--authoritative-cost-F59E0B?style=flat-square" alt="Server-authoritative cost" />
</div>
Expand All @@ -37,7 +37,7 @@ LlamaIndex, and your own stack.

---

> ⚠️ **Status: alpha (v0.15.0).** The public API may shift between minor versions.
> ⚠️ **Status: alpha (v0.16.4).** The public API may shift between minor versions.
> Pin your dependency and read the [CHANGELOG](https://github.com/nullrunio/nullrun-sdk-python/blob/master/CHANGELOG.md) before upgrading.

---
Expand Down Expand Up @@ -293,8 +293,9 @@ Runnable, copy-pastable examples live in a separate repo so you can adapt withou
| Version | Status | Highlights |
|---|---|---|
| **v0.14.x** | ✅ alpha | Wire protocol v3.31, server-minted execution IDs, MCP, anti-OOM streaming cap |
| **v0.15** (current) | ✅ alpha | ADR-009 governance audit surface, typed `runtime.audit.*`, capability probes for `/audit-log/verify` |
| **v0.16** | 📋 planned | OpenTelemetry exporter, Redis-backed offline queue, hardened init contract |
| **v0.15.x** | ✅ alpha | ADR-009 governance audit surface, typed `runtime.audit.*`, capability probes for `/audit-log/verify`, fail-OPEN observability closure |
| **v0.16.x** (current) | ✅ alpha | Phase-1+ `action_digest` on `/gate`, `/execute` `tools` propagation, transient-5xx retry on gate (NR-006), error-code parity (NR-007, 41→56 entries) |
| **v0.17** | 📋 planned | OpenTelemetry exporter, Redis-backed offline queue, hardened init contract |
| **v1.0** | 🎯 beta target | Stable wire contract, full async support, type-safe decisions |

[Full roadmap & RFCs →](https://nullrun.io/roadmap)
Expand Down
6 changes: 3 additions & 3 deletions docs/errors/NR-P001.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
| **Category** | **P**rotocol |
| **Exception class** | `NullRunProtocolError` |
| **Retryable** | No |
| **Default `user_action`** | "The NullRun backend rejected the SDK's wire-protocol version. Upgrade the SDK to a version that supports protocol `X-NULLRUN-PROTOCOL: 3` — see https://docs.nullrun.io/wire-protocol." |
| **Default `user_action`** | "The NullRun backend rejected the SDK's wire-protocol version. Upgrade the SDK to a version that supports protocol `X-NULLRUN-PROTOCOL: 4` — see https://docs.nullrun.io/wire-protocol." |

## When

Expand All @@ -18,8 +18,8 @@ the matching protocol is rejected with HTTP 400.

## Common causes

1. **SDK is too old** — the user is on a pre-v3 release. v3 became
the canonical wire on 2026-06-29 (0.11.0).
1. **SDK is too old** — the user is on a pre-v4 release. v4 became
the canonical wire on 2026-08-31 (0.16.3, ADR-037 Slice B).
2. **Backend hasn't rolled out the new wire yet** — the user is on
a recent SDK but the backend is still on an older release.
3. **Custom transport stripped the header** — a wrapper (proxy,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "nullrun"
# Full release history lives in CHANGELOG.md; only the current version
# is pinned here.
version = "0.16.3"
version = "0.16.4"
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. The headline is the canonical §1 statement
# from positioning.md — "runtime decision layer for tool-using AI agents"
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
string and the SDK_MIN_VERSION constant.
"""

__version__ = "0.16.3"
__version__ = "0.16.4"
__platform_version__ = "1.0.0"
2 changes: 1 addition & 1 deletion src/nullrun/breaker/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ class NullRunProtocolError(NullRunInfrastructureError):
user_action = (
"The NullRun backend rejected the SDK's wire-protocol version. "
"Upgrade the SDK to a version that supports protocol "
"X-NULLRUN-PROTOCOL: 3 — see "
"X-NULLRUN-PROTOCOL: 4 — see "
"https://docs.nullrun.io/reference/wire-protocol for the "
"current compatibility matrix."
)
Expand Down
20 changes: 20 additions & 0 deletions src/nullrun/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ class ServerCapabilities:
# included in `is_v3_ready()` -- it's informational, not a
# hard gate.
execution_graph: bool = False
# ADR-037 Slice B (2026-08-31, protocol v4): /gate response
# echoes the SDK-supplied `action_digest` and a `policy_hash`
# slot (None today; Slice D wires per-request computation).
# Backend always sends the fields (skip_serializing_if elides
# only when None); the flag is informational so SDKs can
# surface a clean diagnostic at `init()` ("server echoes
# action_digest on /gate response — you can verify the gate
# saw the same digest you sent"). NOT included in
# `is_v3_ready()` — it's informational, not a hard gate.
wire_evidence_echo: bool = False
rate_limit_fail_scope: RateLimitFailScope = field(
default_factory=lambda: RateLimitFailScope()
)
Expand Down Expand Up @@ -183,6 +193,7 @@ def as_dict(self) -> dict[str, Any]:
"outbox_async_drain": self.outbox_async_drain,
"idempotency_keys": self.idempotency_keys,
"execution_graph": self.execution_graph,
"wire_evidence_echo": self.wire_evidence_echo,
"rate_limit_fail_scope": {
"aggregate": self.rate_limit_fail_scope.aggregate,
"per_key": self.rate_limit_fail_scope.per_key,
Expand Down Expand Up @@ -345,6 +356,15 @@ def _v3_flag(name: str) -> bool:
# the field entirely) yield a fail-closed view where the
# SDK does NOT send `parent_execution_id`.
execution_graph=_v3_flag("execution_graph"),
# ADR-037 Slice B (2026-08-31, protocol v4): additive
# flag — defaults to False so pre-Slice-B backends yield
# a fail-closed view where the SDK does NOT log the
# wire-evidence echo as "server confirmed". Pre-v4
# backends return the JSON without `action_digest` /
# `policy_hash` keys at all (skip_serializing_if on the
# backend), so a v4 SDK sees None on both fields and
# logs "no wire evidence echo" — no false positive.
wire_evidence_echo=_v3_flag("wire_evidence_echo"),
rate_limit_fail_scope=_parse_rate_limit_scope(caps.get("rate_limit_fail_scope")),
)

Expand Down
101 changes: 101 additions & 0 deletions src/nullrun/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,35 @@ def set_chain_op(op: str) -> None:
_server_minted_idempotency_key_var: ContextVar[str | None] = ContextVar(
"server_minted_idempotency_key", default=None
)
# ADR-037 Slice B (2026-08-31, protocol v4): wire-evidence echo
# from /gate response. Both fields are ADR-009 governance columns
# that the backend now echoes on the /gate response (additive —
# pre-v4 backends omit the keys entirely via skip_serializing_if).
#
# `action_digest` is the SDK-supplied SHA-256 hex of the canonical
# `business_impact` payload, re-verified server-side by
# `payload_binding::server_derive_action_digest` and echoed back
# on the wire so the SDK can confirm what the gate saw matches
# what it intended. The architectural invariant
# `GateResponse.action_digest == AuditEvent.action_digest` holds
# trivially because both sides flow from the SDK's input.
#
# `policy_hash` is reserved for future Slice D wiring (per
# ADR-037 §3 deferral — gate doesn't compute per-request hash
# today; in-memory KeyPolicy cache carries no hash and the hot
# path cannot load PolicyRow). Today this field is always None
# on the wire; the audit row stores `policy_hash = None` for the
# same reason (audit_drain.rs:301), so the invariant
# `GateResponse.policy_hash == AuditEvent.policy_hash` holds
# trivially.
#
# Both default to None; clear_ functions reset to None.
_last_gate_action_digest_var: ContextVar[str | None] = ContextVar(
"last_gate_action_digest", default=None
)
_last_gate_policy_hash_var: ContextVar[str | None] = ContextVar(
"last_gate_policy_hash", default=None
)


def get_server_minted_execution_id() -> str | None:
Expand Down Expand Up @@ -449,6 +478,10 @@ def clear_server_minted_execution_id() -> None:
_server_minted_execution_id_var.set(None)
_server_minted_reservation_at_var.set(0.0)
_server_minted_idempotency_key_var.set(None)
# Also drops the v4 wire-evidence echo slots so the next
# /check in scope doesn't read a stale echo from a prior block.
_last_gate_action_digest_var.set(None)
_last_gate_policy_hash_var.set(None)

Use:func:`reset_server_minted_execution_id` instead when you
have a Token to consume — that path restores the previous
Expand All @@ -457,13 +490,81 @@ def clear_server_minted_execution_id() -> None:
_server_minted_execution_id_var.set(None)
_server_minted_reservation_at_var.set(0.0)
_server_minted_idempotency_key_var.set(None)
# ADR-037 Slice B (2026-08-31, protocol v4): also drop the
# wire-evidence echo slots so a /check in one block never leaks
# a stale echo into a /track in a sibling block.
_last_gate_action_digest_var.set(None)
_last_gate_policy_hash_var.set(None)


def set_attempt_index(index: int) -> None:
"""Set current attempt index for retry correlation."""
_attempt_index_var.set(index)


# ---------------------------------------------------------------------------
# ADR-037 Slice B (2026-08-31, protocol v4): wire-evidence echo
# ---------------------------------------------------------------------------
# Read by tests + operators to confirm the gate saw the same
# `action_digest` the SDK sent (and to surface the architectural
# invariant `GateResponse.action_digest == AuditEvent.action_digest`
# from the SDK side). `policy_hash` is informational only today;
# Slice D will populate it per-request.


def get_last_gate_action_digest() -> str | None:
"""Return the `action_digest` echoed by the last /gate response, or
``None`` if no echo captured in scope (legacy backend, or a /check
that didn't carry a typed business impact).

Wire-additive — pre-v4 backends omit the field entirely
(``skip_serializing_if = "Option::is_none"`` on the backend); a
v4 SDK connecting to a v3 backend reads None and behaves like
pre-Slice-B. No false positive.

See ADR-037 Slice B (2026-08-31) for the wire contract.
"""
return _last_gate_action_digest_var.get()


def get_last_gate_policy_hash() -> str | None:
"""Return the `policy_hash` echoed by the last /gate response, or
``None`` if no echo captured in scope.

Slot reserved for future Slice D wiring (per ADR-037 §3
deferral — gate doesn't compute per-request hash today; the
audit row stores `policy_hash = None` for the same reason at
`audit_drain.rs:301`). Today this field is always None on the
wire, so this getter is informational only.

See ADR-037 Slice B (2026-08-31) for the wire contract.
"""
return _last_gate_policy_hash_var.get()


def set_last_gate_action_digest(value: str | None) -> None:
"""Capture the `action_digest` echoed by a /gate response.

Called by ``runtime._capture_wire_evidence`` immediately after
``_capture_server_minted_execution_id`` — the two captures share
the same lifetime (one /check → one execution_id + one
action_digest). See ADR-037 Slice B (2026-08-31).
"""
_last_gate_action_digest_var.set(value)


def set_last_gate_policy_hash(value: str | None) -> None:
"""Capture the `policy_hash` echoed by a /gate response.

Slot reserved for Slice D (per ADR-037 §3 deferral). Today
this is always set to None on the wire; this setter is the
forward-compatible hook for Slice D.

See ADR-037 Slice B (2026-08-31).
"""
_last_gate_policy_hash_var.set(value)


# ---------------------------------------------------------------------------
# F-19 (2026-08-14): legacy _trace_id / _span_id token-based setters
# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def format_user_message(exc: BaseException | object, locale: str = "en") -> str:

Args:
exc: A NullRun exception (or any object exposing ``error_code``).
locale: DEPRECATED — reserved for a future locale-pack release. Currently ignored; the catalog is English-only. Will emit a DeprecationWarning in 0.14.0 if the catalog is not yet localised by then.
locale: DEPRECATED — reserved for a future locale-pack release. Currently ignored; the catalog is English-only.
non-``"en"`` value falls back to the English message. The
parameter is reserved for future locale packs.

Expand Down
Loading
Loading