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: 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)