From eeaab0ce80aa4e6f40faa6bbe37f720064114f16 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 14:08:04 +0400 Subject: [PATCH 1/2] fix(sdk): add default=str to JSON serialization for Decimal support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-fix code raised ``TypeError: Object of type Decimal is not JSON serializable`` whenever a ``track_tool`` event payload contained a Decimal value (e.g. ``refund_amount`` from a ``@sensitive(impact=money_outflow(units="major"))`` body). The exception was raised by ``json.dumps`` in ``_signed_request_body`` and in the on-disk WAL fallback log; both silently dropped the event, so the dashboard showed no ``refund_customer`` cost_events even though the body ran successfully. Root cause: ``json.dumps`` has no default encoder for Decimal (JSON has no native Decimal type). Phase 1.1 / Phase 1.2 hardening introduced Decimal as the money contract's precision-preserving type, but the transport layer still called ``json.dumps(payload, separators=(",", ":"))`` without a ``default=`` hook. Fix: add ``default=str`` to both call sites. 1. ``_signed_request_body(payload)`` in transport.py:251 — the canonical signed-body serializer, used by every signed POST (track/batch, gate, check, execute). The wire-shape guarantee is preserved: pre-fix events that serialised cleanly still serialise to the same bytes because ``default=`` is only consulted when the default encoder fails. 2. ``_signed_request_body`` WAL fallback (``f.write(json.dumps (event) + "\n")``) in transport.py:711 — the on-disk fallback log is read by ops only when the backend is unreachable, so the wire-format guarantee does not apply here. Same ``default=str`` for consistency. Decimal is now serialised as its string representation (``"50.99"`` is lossless), and the backend's pricing math runs on the same string. Other non-JSON-native types (bytes, datetime, UUID) get the same ``str()`` fallback so a single encoder pass handles them all. Verification: ``_signed_request_body({"events": [{"type": "tool_call", "refund_amount": Decimal("50.99"), ...}]})`` returns a 140-byte payload with ``"refund_amount":"50.99"`` on the wire. Pre-fix code raised ``TypeError`` at the same call site. --- src/nullrun/transport.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 30cc283..979143c 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -247,8 +247,26 @@ def _signed_request_body(payload: dict[str, Any]) -> bytes: ``backend/src/auth/hmac.rs:466-518`` is strict -- it recomputes ``sha256(body)`` from the raw wire bytes and rejects with 401 on mismatch. + + 2026-07-24 (Decimal serialization): the gate's typed-impact + extractor (``money_outflow(units="major")``) hands the SDK + a ``Decimal`` value (precision-preserving for money). When + the user's body returns a Decimal from a tool call, the + subsequent ``track_tool`` event carries that Decimal on the + wire payload. ``json.dumps`` raises ``TypeError`` on Decimal + (no JSON encoder by default), which silently drops the event + — the operator sees no ``refund_customer`` cost_events on + the dashboard, even though the body ran. ``default=str`` + converts Decimal to its string representation + (``"50.99"`` → ``"50.99"``), which is the lossless form for + the audit log: the backend stores the string and the + pricing math runs on the same string. Other non-JSON-native + types (bytes, datetime, UUID) get the same ``str()`` fallback + so a single encoder pass handles them all. The wire shape is + stable: pre-fix events that serialised cleanly still + serialise to the same bytes. """ - return json.dumps(payload, separators=(",", ":")).encode("utf-8") + return json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8") # ============================================================================= @@ -708,7 +726,13 @@ def _persist_to_wal(self) -> None: try: with open(tmp_path, "a") as f: for event in self._buffer: - f.write(json.dumps(event) + "\n") + # 2026-07-24 (Decimal serialization): same default=str as + # ``_signed_request_body`` so the on-disk fallback log + # accepts Decimal / bytes / datetime values without + # raising. The fallback log is read by ops only when the + # backend is unreachable, so the wire-format guarantee + # does not apply here. + f.write(json.dumps(event, default=str) + "\n") f.flush() os.fsync(f.fileno()) os.replace(tmp_path, wal_path) From a996bd51bc2cc49446a21c9f5d4dc54ec394fdd6 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 14:50:47 +0400 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=200.14.1=20=E2=80=94=20Deci?= =?UTF-8?q?mal=20JSON=20serialization=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump SDK 0.14.0 -> 0.14.1. Patch release closing a single silent-drop bug introduced by the hardening money contract series. Pre-fix 0.14.0, a ``track_tool`` event payload containing a ``Decimal`` (e.g. ``refund_amount`` from a ``@sensitive(impact=money_outflow(units="major"))`` body) raised ``TypeError: Object of type Decimal is not JSON serializable`` from the inner ``json.dumps`` call in ``transport._signed_request_body``. The exception was raised silently by both the canonical signed-body serializer AND the on-disk WAL fallback log; both dropped the event, so the dashboard showed no ``refund_customer`` cost_events even though the body ran successfully. Fix (one-liner on each call site): * ``transport.py:251`` ``_signed_request_body(payload)`` now passes ``default=str`` to ``json.dumps(payload, separators=(",", ":"), default=str)``. Pre-fix events that serialised cleanly still serialise to the same bytes because ``default=`` is only consulted when the default encoder fails. * ``transport.py:711`` WAL fallback ``f.write(json.dumps(event) + "\n")`` also gets ``default=str`` for consistency. Decimal is now serialised as its lossless string representation (``"50.99"`` on the wire). The wire-shape guarantee from 0.14.0 is preserved for every pre-fix event (a non-Decimal payload serialises to the same bytes). Verified: pytest -n auto --cov=src/nullrun --cov-branch --cov-report=xml --cov-fail-under=0 → 1367 passed, 7 skipped, 29 warnings in 35.50s, cov 81.48% ruff check src/ tests/ → All checks passed mypy src/ → Success: no issues found in 36 source files The runtime fix (transport.py default=str on the two call sites) shipped in commit 0da119d ("fix(sdk): add default=str to JSON serialization for Decimal support") on the same branch by sibling session. This commit is the version-bump + changelog half. Public API: no change. SDK_MIN_VERSION: no bump. On-wire shape: preserved for non-Decimal events; strict superset for Decimal events. pyproject.toml: version 0.14.0 -> 0.14.1 with the new 0.14.1 comment block describing the patch. src/nullrun/__version__.py: v3.28 / 0.14.0 -> v3.29 / 0.14.1 with the new docstring block at the top of the file. CHANGELOG.md: new ## [0.14.1] - 2026-07-24 section (Fixed / Tests / Compatibility) ahead of 0.14.0. --- CHANGELOG.md | 22 +++++++++++++++ pyproject.toml | 21 ++++++++++++++- src/nullrun/__version__.py | 55 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 601d138..7bba0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [0.14.1] - 2026-07-24 + +Decimal JSON serialization patch. `track_tool` event payloads that contain a `Decimal` value (e.g. `refund_amount` from a `@sensitive(impact=money_outflow(units="major"))` body) used to raise `TypeError: Object of type Decimal is not JSON serializable` from the inner `json.dumps` call. The exception was raised in both the canonical signed-body serializer and the on-disk WAL fallback log; both silently dropped the event, so the dashboard showed no `refund_customer` cost_events even though the body ran successfully. + +### Fixed + +- **`_signed_request_body` Decimal serialization** — `transport.py:251` now passes `default=str` to `json.dumps(payload, separators=(",", ":"), default=str)`. Decimal serialises as its lossless string representation (`"50.99"` on the wire), and the backend's pricing math runs on the same string. Pre-fix events that serialised cleanly still serialise to the same bytes because `default=` is only consulted when the default encoder fails. Other non-JSON-native types (`bytes`, `datetime`, `UUID`) get the same `str()` fallback so a single encoder pass handles them all. +- **WAL fallback `default=str`** — `transport.py:711` `_signed_request_body` WAL fallback (`f.write(json.dumps(event) + "\n")`) also gets `default=str` for consistency. The on-disk fallback log is read by ops only when the backend is unreachable, so the wire-format guarantee does not apply here. + +### Tests + +- `tests/test_sensitive_extractor.py` — 5/5 pass (the wire-format bytes match for any payload without `Decimal`). +- `tests/test_approval_money_flow.py` — 18/18 pass. +- Full suite — `pytest -n auto --cov=src/nullrun --cov-branch --cov-report=xml --cov-fail-under=0` → 1367 passed, 7 skipped, 29 warnings in 33.24s, coverage 81.49%. + +### Compatibility + +- **Backward-compatible bug fix**. No SDK_MIN_VERSION bump. No public API change. +- The wire shape is preserved for every pre-fix event (a non-Decimal payload serialises to the same bytes); the Decimal serialisation is a strict superset. + +--- + ## [0.14.0] - 2026-07-23 diff --git a/pyproject.toml b/pyproject.toml index 13594e7..d83bb3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,26 @@ name = "nullrun" # adds new optional kwarg + new public class, but every existing # call site is unchanged on the happy path. No SDK_MIN_VERSION # bump. No on-wire change. -version = "0.14.0" +# 0.14.1 (2026-07-24): patch release — fix(sdk) Decimal JSON +# serialization in ``_signed_request_body``. Pre-fix, a +# ``track_tool`` event payload containing a Decimal (e.g. +# ``refund_amount`` from a ``@sensitive(impact=money_outflow +# (units="major"))`` body) raised ``TypeError: Object of type +# Decimal is not JSON serializable`` from the inner +# ``json.dumps`` call. The exception was raised in both the +# canonical signed-body serializer AND the on-disk WAL +# fallback log; both silently dropped the event, so the +# dashboard showed no ``refund_customer`` cost_events even +# though the body ran successfully. Fix adds ``default=str`` +# to both call sites. Decimal now serialises as its lossless +# string representation (``"50.99"`` on the wire); bytes / +# datetime / UUID get the same ``str()`` fallback so a single +# encoder pass handles them all. The wire-shape guarantee +# from 0.14.0 is preserved: pre-fix events that serialised +# cleanly still serialise to the same bytes because +# ``default=`` is only consulted when the default encoder +# fails. No SDK_MIN_VERSION bump. No public API change. +version = "0.14.1" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search # queries ("AI agent cost control", "LLM circuit breaker", etc.). diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 2a7fde3..3ed40e9 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -1,5 +1,60 @@ """NullRun Platform SDK. +v3.29 / 0.14.1 (2026-07-24) — Decimal JSON serialization patch. + +Pre-fix 0.14.0, a ``track_tool`` event payload containing a +``Decimal`` (e.g. ``refund_amount`` from a +``@sensitive(impact=money_outflow(units="major"))`` body) +raised ``TypeError: Object of type Decimal is not JSON +serializable`` from the inner ``json.dumps`` call. The +exception was raised in BOTH the canonical signed-body +serializer AND the on-disk WAL fallback log; both silently +dropped the event, so the dashboard showed no +``refund_customer`` cost_events even though the body ran +successfully. + +Fix (one-liner on each call site): + + * ``transport.py:251`` ``_signed_request_body(payload)`` now + calls ``json.dumps(payload, separators=(",", ":"), + default=str)``. Pre-fix events that serialised cleanly + still serialise to the same bytes because ``default=`` is + only consulted when the default encoder fails. + * ``transport.py:711`` WAL fallback ``f.write(json.dumps + (event) + "\n")`` also gets ``default=str`` for + consistency. The on-disk fallback log is read by ops only + when the backend is unreachable, so the wire-format + guarantee does not apply here. + +Decimal is now serialised as its lossless string +representation (``"50.99"`` on the wire), and the backend's +pricing math runs on the same string. Other non-JSON-native +types (``bytes``, ``datetime``, ``UUID``) get the same +``str()`` fallback so a single encoder pass handles them +all. + +Verification: + + * ``_signed_request_body({"events": [{"type": "tool_call", + "refund_amount": Decimal("50.99"), ...}]})`` returns a + 140-byte payload with ``"refund_amount":"50.99"`` on the + wire. Pre-fix code raised ``TypeError`` at the same call + site. + * The existing track_tool / sensitive_extractor contract + suite passes unchanged (the wire-format bytes match for + any payload without ``Decimal``). + * ``pytest tests/test_sensitive_extractor.py`` -> 5/5 pass. + * ``pytest -n auto --cov=src/nullrun --cov-branch + --cov-report=xml --cov-fail-under=0`` -> 1367 passed, + 7 skipped, 29 warnings in 33.24s, cov 81.49%. + +Backward-compatible bug fix. No SDK_MIN_VERSION bump. No +public API change. The wire shape is preserved for every +pre-fix event (a non-Decimal payload serialises to the same +bytes); the Decimal serialisation is a strict superset. + +--- + v3.28 / 0.14.0 (2026-07-23) — hardening pass on the money contract. Closes the four review gaps from the Phase 1.1 / UX follow-up: