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 @@ -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


Expand Down
21 changes: 20 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down
55 changes: 55 additions & 0 deletions src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
28 changes: 26 additions & 2 deletions src/nullrun/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


# =============================================================================
Expand Down Expand Up @@ -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)
Expand Down
Loading