Skip to content

chore(release): 0.14.1 — Decimal JSON serialization patch#77

Merged
maltsev-dev merged 2 commits into
masterfrom
fix/release-0.14.1
Jul 24, 2026
Merged

chore(release): 0.14.1 — Decimal JSON serialization patch#77
maltsev-dev merged 2 commits into
masterfrom
fix/release-0.14.1

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

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_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. Both the canonical signed-body serializer AND the on-disk WAL fallback log 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 (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). Other non-JSON-native types (bytes, datetime, UUID) get the same str() 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

pytest -n auto --cov=src/nullrun --cov-branch
        --cov-report=xml --cov-fail-under=0
  → 1367 passed, 7 skipped, 29 warnings in 32.63s, coverage 81.49%
ruff check src/             → All checks passed
mypy src/                  → Success: no issues found in 36 source files

Test plan

  • Local pytest + ruff + mypy
  • CI on the PR — expect all 4 jobs (test 3.10/3.11/3.12 + coverage) green
  • After merge, run publish-test workflow_dispatch to push 0.14.1 to Test PyPI

Diff: +123/-3, 4 files (transport.py, pyproject.toml, src/nullrun/version.py, CHANGELOG.md).

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

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@maltsev-dev
maltsev-dev merged commit fcdb2bd into master Jul 24, 2026
5 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant