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

Patch release — Phase-1+ `action_digest` wire-shape fix for non-impact `/gate` calls. Wire-format is additive (new optional field); SDK_MIN_VERSION unchanged. **Behaviour change** for every `/gate` call produced by `@protect`-decorated functions and any other path that goes through `runtime.check_workflow_budget`.

### Changed

- **`runtime.check_workflow_budget` now populates `action_digest` on every `/gate` call.** Pre-0.16.1 the field was only forwarded on `/execute` (where `@sensitive(impact=...)` had already wired a typed Money/ToolCall impact). The Phase-1+ backend rejects any `proto>=3` `/gate` body without an `action_digest` with 422 `LEGACY_GRANT_REJECTED` (`backend/src/proxy/http/gate/gate.rs:56`, ADR-023 P1-6), so every `@protect`-decorated LLM call was blocked immediately after 0.16.0 promoted the SDK to proto=3. The fix:
- new `BusinessImpact.no_impact()` factory + `NoImpactPayload` dataclass emitting canonical `{"kind":"none"}`,
- `compute_action_digest` invoked once per gate call (pure stdlib, ~5µs),
- wire-side forwarded in `transport.check` via `if check_request.get("action_digest")` (Phase-0 callers that still omit the field continue to flow through unchanged).
- **New source-pin regression test** `tests/test_business_impact.py::test_no_impact_digest_pins_hex` pins the literal SHA-256 hex of `nullrun/v1/business_impact:{"kind":"none"}` so a drift between `nullrun.business_impact.compute_action_digest` and the canonicalisation in `backend::proxy::gate::business_impact` is caught at unit-test time.

### Why this is needed

`@protect`-decorated LLM calls produce a `/gate` body that previously had no `action_digest` field — Phase-1+ gate was reject-CLOSED for that case (`LEGACY_GRANT_REJECTED` 422, `details.action_message: "action_digest is required when X-NULLRUN-PROTOCOL >= 3"`). Surfaced 2026-08-20 when the first `langgraph_basic.py` run with SDK 0.16.0 hit the gate for `wf = e4ada1c0-…`. Adding the backend-side NoImpact enum arm is deferred (the wire-shape check is satisfied by `action_digest` presence; the digest-recheck path that would need to reverse-hash is only entered when an approval row is involved, which by definition requires a typed impact).

## [0.16.0] - 2026-08-20

Minor release — backend v3.66.2 wire-validation alignment. **Behaviour change** for callers that invoke `track_llm()` / `track({"type": "llm_call", ...})` outside a paired `/check` scope. Wire-format unchanged. SDK_MIN_VERSION unchanged.
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.0"
version = "0.16.1"
# 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.15.2"
__version__ = "0.16.1"
__platform_version__ = "1.0.0"
64 changes: 63 additions & 1 deletion src/nullrun/business_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,13 @@
# `money` kind for per-call flat amounts.
# `tool_call` kind for free-form tool-call argument bags matched
# against ToolParameters Approval Rules on the backend.
# `none` kind for non-impact LLM/tool calls that still need an
# `action_digest` on the wire (per `backend/src/proxy/http/gate/gate.rs:56`
# v3.62.1 / ADR-023 P1-6 — Phase-1+ SDKs MUST supply `action_digest`
# even when there is no typed business impact to extract).
KIND_MONEY = "money"
KIND_TOOL_CALL = "tool_call"
KIND_NONE = "none"


# Mirrors the backend constant at
Expand Down Expand Up @@ -260,6 +265,42 @@ def business_impact_to_dict(impact: BusinessImpact) -> dict[str, Any]:
# tagged dict at the wire layer and a small class hierarchy at the
# in-process layer. The SDK validates the variant at construction
# time so the backend never sees malformed output.
@dataclass
class NoImpactPayload:
"""Sentinel payload for non-impact calls (plain LLM chat, etc.).

Phase-1+ SDKs MUST populate `action_digest` on every `/gate`
call (per `backend/src/proxy/http/gate/gate.rs:56` v3.62.1 /
ADR-023 P1-6 — fail-CLOSED wire-shape version-gate). Calls
that have no typed business impact (regular LLM chat,
read-only tool calls without an approval rule) need a
deterministic digest that the wire-shape check accepts.

The canonical JSON of this payload is ``{"kind":"none"}``
(compact, key-sorted). The corresponding digest is the SHA-256
of ``nullrun/v1/business_impact:{"kind":"none"}`` and is
pinned as a literal in
``tests/test_business_impact.py::test_no_impact_digest_pins_hex``
so a drift between SDK and backend (or a stray canonicalisation
change) is caught at unit-test time.

This variant exists ONLY on the SDK side. The backend's
`GateRequestBody.business_impact` field stays ``None`` for
non-impact calls — the `action_digest` field is the one the
wire-shape gate checks. Adding a NoImpact arm to the backend's
``BusinessImpact`` enum is a follow-up if / when the digest
re-check path needs to reverse-hash the impact (currently
it doesn't — the re-check only fires when an approval row
is involved, which requires a typed impact by definition).
"""

def validate(self) -> None:
"""No-op: NoImpact carries no field constraints."""

def to_wire_dict(self) -> dict[str, Any]:
return {"kind": KIND_NONE}


@dataclass
class BusinessImpact:
"""Top-level BusinessImpact union.
Expand All @@ -268,19 +309,27 @@ class BusinessImpact:
`Money`: flat per-call money amount (cents, USD-centric).
`ToolCall`: free-form tool-call argument bag matched
against ToolParameters Approval Rules on the backend.
`NoImpact`: sentinel for non-impact calls (regular LLM
chat, tool calls without a typed approval rule).
Wire shape: ``{"kind":"none"}``. Lets the SDK
compute an `action_digest` that satisfies the
Phase-1+ wire-shape version-gate without inventing
a fake typed impact.

The SDK validates the variant at construction time so the
backend never sees malformed output.
"""

impact: Any # MoneyImpact | ToolCallParams
impact: Any # MoneyImpact | ToolCallParams | NoImpactPayload

@property
def kind(self) -> str:
if isinstance(self.impact, MoneyImpact):
return KIND_MONEY
if isinstance(self.impact, ToolCallParams):
return KIND_TOOL_CALL
if isinstance(self.impact, NoImpactPayload):
return KIND_NONE
raise TypeError(f"unknown impact type: {type(self.impact)}")

def validate(self) -> None:
Expand Down Expand Up @@ -329,6 +378,19 @@ def tool_call(
p.validate()
return cls(impact=p)

@classmethod
def no_impact(cls) -> BusinessImpact:
"""Sentinel ``kind="none"`` BusinessImpact for non-impact calls.

Use this in ``runtime.check_workflow_budget`` and other
/gate sites that don't extract a typed impact but still
need to compute an `action_digest` to satisfy the
backend's Phase-1+ wire-shape version-gate.
"""
n = NoImpactPayload()
n.validate()
return cls(impact=n)


def _canonicalize_json(value: Any) -> Any:
"""Sort object keys recursively before serialization.
Expand Down
21 changes: 21 additions & 0 deletions src/nullrun/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1849,6 +1849,12 @@ def check_workflow_budget(self) -> None:
# always-skipped).
metrics.inc_runtime("check_calls")

from nullrun.business_impact import (
BusinessImpact as _BusinessImpact,
)
from nullrun.business_impact import (
compute_action_digest as _compute_action_digest,
)
from nullrun.context import (
get_call_mcp_annotations,
get_call_mcp_class,
Expand Down Expand Up @@ -1911,6 +1917,21 @@ def check_workflow_budget(self) -> None:
"stream": False,
}

# v0.16.1 (Phase-1+ wire-shape fix): Phase-1+ SDKs MUST
# populate `action_digest` on every /gate call, even when no
# typed business impact is extracted (`@protect`-decorated
# LLM-only calls). Per `backend/src/proxy/http/gate/gate.rs:56`
# v3.62.1 / ADR-023 P1-6 the gate fail-CLOSED-rejects any
# proto>=3 client that omits the digest. We always emit a
# NoImpact sentinel here — typed Money/ToolCall impacts are
# forwarded by `runtime.execute(...)` directly (see
# `transport.py::execute`) and do not pass through this
# pre-flight gate. Computing once per call (not cached) is
# fine: compute_action_digest is ~5µs of pure stdlib.
check_req["action_digest"] = _compute_action_digest(
_BusinessImpact.no_impact()
)

# Forward the tool list so backend (T3) can match each tool
# against the workflow's effective `blocked_tools` aggregate.
# Only included when the user actually set it — `[]` means
Expand Down
9 changes: 9 additions & 0 deletions src/nullrun/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,15 @@ def check(
gate_request["idempotency_key"] = check_request["idempotency_key"]
if "stream" in check_request:
gate_request["stream"] = bool(check_request["stream"])
# v0.16.1 (Phase-1+ wire-shape fix): runtime.check_workflow_budget
# always sets `action_digest` so the gate's
# `if req.action_digest.is_none()` version-gate passes
# (`backend/src/proxy/http/gate/gate.rs:56`, ADR-023 P1-6).
# Pre-v0.16.1 / Phase-0 callers can still omit it (forwarded
# only when truthy) without triggering a "field present
# but None" wire-shape drift.
if check_request.get("action_digest"):
gate_request["action_digest"] = check_request["action_digest"]
# Forward the `tool_arguments` bag alongside `tool` so
# the gate can hash it via `signature::compute_schema_hash`
# and write the fingerprint into `mcp_tool_signatures`.
Expand Down
61 changes: 61 additions & 0 deletions tests/test_business_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@

from nullrun.business_impact import (
INFLOW,
KIND_NONE,
OUTFLOW,
BusinessImpact,
MoneyImpact,
NoImpactPayload,
ToolCallParams,
business_impact_to_dict,
compute_action_digest,
Expand All @@ -57,6 +59,24 @@
"dfc96387ca539b7130caebe705e042f2e34e52ab44352ae5e527bcef64f0df27"
)

# v0.16.1 (Phase-1+ wire-shape fix): the NoImpact sentinel
# digest is `sha256("nullrun/v1/business_impact:{" + "\"kind\":\"none\"" + "}")`.
# Pinned here so a drift in canonicalisation (sort-keys,
# non-ASCII handling, prefix bytes) is caught at unit-test
# time, before the SDK ships a /gate body that the backend's
# `gate.rs:56` version-gate still accepts but the audit-event
# row would silently lose the digest equivalence pin.
#
# Cross-language parity note: this hex MUST stay in lockstep
# with the Rust constant used by the hypothetical backend
# mirror if/when a `BusinessImpact::NoImpact` enum arm is
# added there (currently the backend computes digest only
# from typed impacts on the re-check path; the SDK emits
# the NoImpact sentinel so the wire-shape gate passes).
GOLDEN_HEX_NO_IMPACT = (
"0049d93a36f0710269a6deb733ca78d57a770ef640a2698d0fddaa9653b7c3de"
)

# Cross-language parity pin for the
# ``ToolCall`` impact (2026-07-27). The Rust backend
# asserts the same hex literal in
Expand Down Expand Up @@ -114,6 +134,47 @@ def test_direction_change_produces_different_hex(self) -> None:
b = compute_action_digest(BusinessImpact.money(INFLOW, 5_000, "USD"))
assert a != b

def test_no_impact_digest_pins_hex(self) -> None:
# `BusinessImpact.no_impact()` emits canonical
# `{"kind":"none"}` and computes the SHA-256 of
# `nullrun/v1/business_impact:` || `{"kind":"none"}`.
# The hex is pinned so a canonicalisation drift
# trips here (the wire-shape gate would otherwise
# silently accept any 64-hex string).
impact = BusinessImpact.no_impact()
assert impact.kind == KIND_NONE
d = business_impact_to_dict(impact)
assert d == {"kind": "none"}
assert compute_action_digest(impact) == GOLDEN_HEX_NO_IMPACT

def test_no_impact_is_deterministic(self) -> None:
# Two independent NoImpact constructions must produce
# the same digest — the gate uses it as a bind token
# (every /gate from a non-impact call site shares
# this sentinel).
a = compute_action_digest(BusinessImpact.no_impact())
b = compute_action_digest(BusinessImpact.no_impact())
assert a == b == GOLDEN_HEX_NO_IMPACT

def test_no_impact_differs_from_money(self) -> None:
# The NoImpact sentinel must NOT collide with any
# real Money digest — a hash collision would let a
# trivial "no impact" call reuse an existing
# approval row's grant.
no_impact = compute_action_digest(BusinessImpact.no_impact())
money = compute_action_digest(BusinessImpact.money(OUTFLOW, 5_000, "USD"))
assert no_impact != money

def test_no_impact_payload_direct(self) -> None:
# `NoImpactPayload.validate()` is a no-op by design
# (no fields to validate). `to_wire_dict()` always
# returns the single-key dict. Pinning the no-op
# contract so a future refactor that adds a field
# changes the digest literal and the audit row.
p = NoImpactPayload()
p.validate()
assert p.to_wire_dict() == {"kind": "none"}


# ---------------------------------------------------------------------------
# 2. Wire dict round-trip
Expand Down
Loading