chore(release): 0.14.1 — Decimal JSON serialization patch#77
Merged
Conversation
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.
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.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
maltsev-dev
added a commit
that referenced
this pull request
Jul 24, 2026
Closes the CI failure mode that run 30088911608 surfaced on the post-merge push to master after #77 (0.14.1 release). Two distinct problems in two commits: 1. **flakefix: ``@pytest.mark.rerunfailures`` wrong kwarg + tight release window.** The Sprint 0 (PR #76) follow-up shipped ``@pytest.mark.rerunfailures(max_retries=2)`` on ``tests/test_approval_timeout_field.py::TestApprovalTimeoutResolution ::test_env_fallback_when_server_value_is_zero``. That kwarg was deprecated in ``pytest-rerunfailures 15.x`` (the version pinned in ``dev``), so the marker was a no-op on CI and the release-window race stayed flaky. Two-part fix: * Use the documented ``reruns=2`` kwarg so the marker actually triggers a retry on the inner helper. * Widen ``release_after_ms`` from 50ms to 200ms. Still well below the 120s env default timeout so the test stays fast on CI, but enough headroom that the spawned worker thread reliably reaches ``event.wait()`` before the main thread fires the WS release. 2. **strict-mode survives ``init_or_die()`` reinit** (commit ``5354e86``, surfaced on branch ``fix/gap-1c-approval-timeout`` after PR #76 was merged). The pre-fix code had a four-cell state space (extractor present × runtime registry has the tool name) for the sensitive-tool check. The ``@sensitive(impact=...)`` decorator stamped ``_nullrun_extractor`` on the @Protect wrapper (not on the user function), so a reinit that tore down the wrapper broke the lookup. The fix pins ``_nullrun_extractor`` to the runtime registry as well so a reinit that replaces the wrapper still finds the extractor. Verification: * ``pytest -n auto --cov=src/nullrun --cov-branch --cov-report=xml --cov-fail-under=0`` -> 1367 passed, 7 skipped, 29 warnings in 34.54s, coverage 81.45%. * ``ruff check src/ tests/`` -> All checks passed. * ``mypy src/`` -> Success: no issues found in 36 source files. Diff vs origin/master: +191/-19 across 4 files (``src/nullrun/decorators.py``, ``src/nullrun/runtime.py``, ``src/nullrun/transport.py``, ``tests/test_approval_timeout_field.py``). No SDK_MIN_VERSION bump. No on-wire change. No public API change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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_toolevent payload containing aDecimal(e.g.refund_amountfrom a@sensitive(impact=money_outflow(units="major"))body) raisedTypeError: Object of type Decimal is not JSON serializablefrom the innerjson.dumpscall intransport._signed_request_body. Both the canonical signed-body serializer AND the on-disk WAL fallback log silently dropped the event, so the dashboard showed norefund_customercost_events even though the body ran successfully.Root cause
json.dumpshas no default encoder forDecimal(JSON has no native Decimal type). Phase 1.1 / Phase 1.2 hardening introducedDecimalas the money contract's precision-preserving type, but the transport layer still calledjson.dumps(payload, separators=(",", ":"))without adefault=hook.Fix (one-liner on each call site)
transport.py:251_signed_request_body(payload)now callsjson.dumps(payload, separators=(",", ":"), default=str). Pre-fix events that serialised cleanly still serialise to the same bytes becausedefault=is only consulted when the default encoder fails.transport.py:711WAL fallbackf.write(json.dumps(event) + "\n")also getsdefault=strfor 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.Decimalis now serialised as its lossless string representation ("50.99"on the wire). Other non-JSON-native types (bytes,datetime,UUID) get the samestr()fallback so a single encoder pass handles them all.Public API change
None. SDK_MIN_VERSION: no bump. 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.
Verification
Test plan
publish-testworkflow_dispatch to push 0.14.1 to Test PyPIDiff: +123/-3, 4 files (transport.py, pyproject.toml, src/nullrun/version.py, CHANGELOG.md).