From fc926b21bf075ced20edb64633cfd2c206e989da Mon Sep 17 00:00:00 2001 From: Anatolii Date: Thu, 20 Aug 2026 21:26:59 +0400 Subject: [PATCH 1/3] chore(sdk): bump __version__ to 0.16.1 to match pyproject.toml pyproject.toml was bumped to 0.16.1 in v0.16.1 prep but __version__.py was left at 0.15.2, causing source-of-truth drift. Align __version__ to 0.16.1 so downstream tools that grep __version__ see the same value as pyproject. --- src/nullrun/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 7926f4c..197e73c 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -5,5 +5,5 @@ string and the SDK_MIN_VERSION constant. """ -__version__ = "0.15.2" +__version__ = "0.16.1" __platform_version__ = "1.0.0" From 16940c10fd89a032969f2972efcc23440e853970 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Thu, 20 Aug 2026 21:27:48 +0400 Subject: [PATCH 2/3] =?UTF-8?q?chore(release):=200.16.1=20=E2=80=94=20Phas?= =?UTF-8?q?e-1+=20wire-shape=20action=5Fdigest=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1+ SDKs (proto>=3) MUST populate action_digest on every /gate call (backend v3.62.1 / ADR-023 P1-6, gate.rs:56) — the gate fail-CLOSED-rejects any client that omits the digest. This release: - Adds BusinessImpact.no_impact() sentinel + compute_action_digest for Phase-1+ SDKs to compute a deterministic NoImpact digest. - Wires action_digest into check_workflow_budget so @protect calls always emit a digest, even when no typed business impact is extracted. - Adds test_business_impact.py covering canonical JSON, digest computation, and the NoImpact digest pinning (sha256 hex literal). - Bumps pyproject.toml to 0.16.1. Ref: ADR-023 P1-6, backend/src/proxy/http/gate/gate.rs:56 Ref: backend/src/proxy/http/gate/internal.rs:2687-2715 (digest re-check) --- CHANGELOG.md | 16 +++++++++ pyproject.toml | 2 +- src/nullrun/business_impact.py | 64 +++++++++++++++++++++++++++++++++- src/nullrun/runtime.py | 19 ++++++++++ src/nullrun/transport.py | 9 +++++ tests/test_business_impact.py | 61 ++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5295589..525c08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 34b94d1..7abade5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/nullrun/business_impact.py b/src/nullrun/business_impact.py index 5ca6ca9..ee6100e 100644 --- a/src/nullrun/business_impact.py +++ b/src/nullrun/business_impact.py @@ -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 @@ -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. @@ -268,12 +309,18 @@ 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: @@ -281,6 +328,8 @@ def kind(self) -> str: 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: @@ -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. diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index dff9743..ed0f8ac 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -1858,6 +1858,10 @@ def check_workflow_budget(self) -> None: get_chain_op, get_workflow_id, ) + from nullrun.business_impact import ( + BusinessImpact as _BusinessImpact, + compute_action_digest as _compute_action_digest, + ) # Prefer the user-set contextvar (explicit `with workflow(...)` # block), fall back to the API key's bound workflow. Returns @@ -1911,6 +1915,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 diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 1e2f739..e50add9 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -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`. diff --git a/tests/test_business_impact.py b/tests/test_business_impact.py index e1205dd..b8dae9b 100644 --- a/tests/test_business_impact.py +++ b/tests/test_business_impact.py @@ -41,9 +41,11 @@ from nullrun.business_impact import ( INFLOW, + KIND_NONE, OUTFLOW, BusinessImpact, MoneyImpact, + NoImpactPayload, ToolCallParams, business_impact_to_dict, compute_action_digest, @@ -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 @@ -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 From 06d7b06107a76b68e1892ec4b82431f93759589d Mon Sep 17 00:00:00 2001 From: Anatolii Date: Thu, 20 Aug 2026 23:01:22 +0400 Subject: [PATCH 3/3] fix(ci): split business_impact imports to satisfy ruff I001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on PR #92 failed on ruff I001 (import-block ordering) in src/nullrun/runtime.py:1852. Ruff wants business_impact imports before context AND a separate from-import statement per aliased name when a block contains more than one alias. Pre-fix the file had a single block that combined two as-aliased names from the same module; ruff's stricter rule requires one import statement per aliased name. Behaviour is identical — both names are still imported as the same locals. 1575/7skipped pass locally before and after the change. --- src/nullrun/runtime.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index ed0f8ac..5eabad0 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -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, @@ -1858,10 +1864,6 @@ def check_workflow_budget(self) -> None: get_chain_op, get_workflow_id, ) - from nullrun.business_impact import ( - BusinessImpact as _BusinessImpact, - compute_action_digest as _compute_action_digest, - ) # Prefer the user-set contextvar (explicit `with workflow(...)` # block), fall back to the API key's bound workflow. Returns