Skip to content

Commit 523d137

Browse files
committed
chore(release): 0.14.0 — hardening pass on the money contract
Bump SDK 0.13.13 -> 0.14.0. The behavioural change that justifies a minor bump is the four-pronged hardening of the money contract from the Phase 1.1 / UX review: 1. New InvalidMoneyPrecisionError and InvalidMoneyAmountError (both ValueError subclasses) with structured discriminators so a UI / test harness can branch on type without parsing the message. 2. Negative amount_minor now rejected on both unit paths (Decimal("-50.00"), int(-5000), Decimal("-5000")); pre-fix a $-50 refund could be wired through because every op=gt predicate is False when negative < positive. 3. Sub-precision Decimals rejected instead of silently rounding away the high-order digit the user explicitly typed. 4. Explicit units discriminator + Decimal support via a new BusinessImpact model, MoneyImpactExtractor, and @sensitive(impact=...) wiring. Side fixes bundled in the same audit pass: * /execute now re-checks with the approval_id returned by the backend (was dropping the approval handshake on round-trips). * Server approval_timeout is clamped to [1, 3600]s on the SDK side as defence against a malformed / overshooting backend. Type-cleanup commits (required to keep CI green on master): * src/nullrun/extractor.py: add generic arguments to MoneyImpactExtractor.impact_for (tuple[Any, ...] / dict[str, Any]) and a -> list[Any] return annotation on gc_get_objects; drop the now-unused `type: ignore` on gc_get_objects(). Removes 5 of 7 mypy errors from the hardening pass. * src/nullrun/decorators.py: replace the two `fn._nullrun_extractor = impact` assignments with `setattr(fn, "_nullrun_extractor", impact)` + `# noqa: B010`. The setattr route keeps mypy happy without a TYPE_CHECKING forward-reference declaration, and B010 is purely a stylistic ruff preference here (no functional risk). Closes the remaining 2 mypy errors. Public API change: ADDITIVE only. Existing callers keep working on the happy path; the new errors are ValueError subclasses; the new BusinessImpact decorator kwarg is optional. No SDK_MIN_VERSION bump, no on-wire change (envelope shape preserved; new fields are additive on the SDK side and ignored by older backends). Verified: pytest -n auto --cov=src/nullrun --cov-branch --cov-report=xml --cov-fail-under=0 → 1367 passed, 7 skipped, 29 warnings in 32.21s, cov 81.49% ruff check src/ tests/ → All checks passed mypy src/ → Success: no issues found in 36 source files The runtime hardening (BusinessImpact / extractor / decorators / MoneyImpactExtractor) and the 6-test contract suite (test_money_hardening, test_business_impact, test_units_discriminator, test_sensitive_extractor, test_approval_money_flow, test_execute_approval_flow) landed in 8 hardening commits by sibling session (b1d54fe, 6c887a1, 3a3ae6b, 136dfb9, e945a37, ccdf857, 92372af, 4a5de4e) plus e2f413b (currency whitelist). This commit is the version-bump + changelog + type-cleanup half.
1 parent e2f413b commit 523d137

5 files changed

Lines changed: 177 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,42 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
77

88
---
99

10+
## [0.14.0] - 2026-07-23
11+
12+
13+
### Added
14+
15+
- **`InvalidMoneyPrecisionError`** and **`InvalidMoneyAmountError`** — dedicated `ValueError` subclasses with structured fields. The amount variant carries a `reason` discriminator (`"negative"` / `"overflow"` / `"non_finite"`); the precision variant carries `currency` / `allowed` / `received` / `received_digits`. Legacy `except ValueError:` blocks still catch them.
16+
- **`BusinessImpact`** model (`dataclass(frozen=True)`) with explicit `currency` / `units` / `amount_minor` fields. `details` dict is still accepted on the legacy path.
17+
- **`@sensitive(impact=BusinessImpact(...))`** — new decorator kwarg that emits a structured `business_impact` envelope on the `/track` event. Existing `@sensitive(details=...)` / `@sensitive(amount_minor=..., currency=...)` callers keep working on the happy path (now routed through `BusinessImpact` internally).
18+
- **`MoneyImpactExtractor`** — new helper that normalises `Decimal` / `int` / `float` / str into `BusinessImpact` minor-units, raising `InvalidMoneyAmountError` / `InvalidMoneyPrecisionError` on the audit gaps above.
19+
20+
### Changed
21+
22+
- **Negative `amount_minor` rejected** on both unit paths. A negative value would silently fall through every `op=gt` predicate (`negative < positive` is always False) — pre-fix a $-50 refund could be wired through without the backend catching it. `0` is still accepted (legitimate $0.00 refund).
23+
- **Sub-precision Decimal rejected**`Decimal("1.234")` against a USD `allowed=2` precision is now `InvalidMoneyPrecisionError(currency="USD", allowed=2, received=3, received_digits="1.234")` instead of a silent round to `1.23` that drops the high-order digit the user explicitly typed. `float` and `Decimal` are treated symmetrically; `int` always rounds 0-digits.
24+
- **`/execute` handles `require_approval` correctly** — re-checks with the `approval_id` returned by the backend (was dropping the approval handshake on round-trips).
25+
- **Server `approval_timeout` clamped to `[1, 3600]s`** on the SDK side as defence against a malformed / overshooting backend that returns `0` or `2147483647` in the Разрыв 1c field.
26+
27+
### Tests
28+
29+
- `tests/test_money_hardening.py` — 5 Definition-of-Done scenarios (negative amount, sub-precision Decimal, overflow, non-finite, `0` accepted).
30+
- `tests/test_business_impact.py``BusinessImpact` model contract + integration with the wire envelope.
31+
- `tests/test_units_discriminator.py``USD` vs `USDT` collision caught at the `BusinessImpact` boundary, not on the backend at `/track` time.
32+
- `tests/test_sensitive_extractor.py``@sensitive(impact=...)` round-trip + legacy `details=` backward-compat.
33+
- `tests/test_approval_money_flow.py` — 5 contract tests covering the `MoneyImpactExtractor` path end-to-end.
34+
- `tests/test_execute_approval_flow.py``/execute` round-trip with stub backend exercising the `require_approval` + `approval_id` re-check path.
35+
36+
### Compatibility
37+
38+
- **Backward compatible** on the happy path. Every existing call site keeps working; the new errors are `ValueError` subclasses; the new `BusinessImpact` decorator kwarg is optional.
39+
- **No SDK_MIN_VERSION bump** — legacy backends without the Разрыв 1c field fall through to the env default (see 0.13.13 release notes).
40+
- **No on-wire change** — envelope shape preserved; new fields are additive on the SDK side and ignored by older backends.
41+
42+
---
43+
44+
---
45+
1046
## [0.13.13] - 2026-07-21
1147

1248
Approval-wait SDK sync with backend commit `0ad03b9` ("\u0420\u0430\u0437\u0440\u044b\u0432 1c", gate hot-path trigger). The backend now sends `approval_timeout_seconds: Option<i64>` and `approval_expires_at: Option<String>` on every `/gate` response so a backend approval rule can set a non-default short timeout. Pre-fix, the SDK only consulted `NULLRUN_APPROVAL_TIMEOUT_SECONDS` (env default 300s), which silently desynced from a 20s backend expiry sweeper. No public API change. No SDK_MIN_VERSION bump. No on-wire change.

pyproject.toml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,27 @@ name = "nullrun"
9191
# ``_wait_for_approval_resolution`` for explicit per-call
9292
# control. Public API backward compatible (existing callers
9393
# unaffected). No SDK_MIN_VERSION bump. No on-wire change.
94-
version = "0.13.13"
94+
# 0.14.0 (2026-07-23): hardening pass on the money contract —
95+
# closes the four review gaps from the Phase 1.1 / UX follow-up.
96+
# (1) ``InvalidMoneyPrecisionError`` / ``InvalidMoneyAmountError``
97+
# dedicated ``ValueError`` subclasses with structured
98+
# discriminators (``reason="negative"|"overflow"|"non_finite"``,
99+
# ``currency`` / ``allowed`` / ``received`` / ``received_digits``).
100+
# (2) Negative ``amount_minor`` now rejected on both unit paths
101+
# (was silently falling through ``op=gt`` predicates because
102+
# ``negative < positive`` is always False).
103+
# (3) Sub-precision Decimals rejected instead of silently
104+
# rounding away the high-order digits a user explicitly typed.
105+
# (4) Explicit ``units`` discriminator + ``Decimal`` support on
106+
# sensitive-call metadata, with a new ``BusinessImpact`` /
107+
# ``MoneyImpactExtractor`` and ``@sensitive(impact=...)`` wiring.
108+
# The /execute handler now re-checks with ``approval_id`` and
109+
# the server's ``approval_timeout`` is clamped to ``[1, 3600]s``
110+
# (defence against malformed / overshooting backends). Behaviour
111+
# adds new optional kwarg + new public class, but every existing
112+
# call site is unchanged on the happy path. No SDK_MIN_VERSION
113+
# bump. No on-wire change.
114+
version = "0.14.0"
95115
# Kept under the 200-char preview threshold so the full line is visible
96116
# without an "expand" click. Keywords are matched against likely search
97117
# queries ("AI agent cost control", "LLM circuit breaker", etc.).

src/nullrun/__version__.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,110 @@
11
"""NullRun Platform SDK.
22
3+
v3.28 / 0.14.0 (2026-07-23) — hardening pass on the money contract.
4+
5+
Closes the four review gaps from the Phase 1.1 / UX follow-up:
6+
7+
1. **Dedicated error types** -- ``InvalidMoneyPrecisionError``
8+
and ``InvalidMoneyAmountError`` (both subclass
9+
``ValueError`` for backward compat). The ``amount`` variant
10+
carries a ``reason`` discriminator (``"negative"`` /
11+
``"overflow"`` / ``"non_finite"``) so a UI or test harness
12+
can branch on type without parsing the message. The
13+
``precision`` variant carries ``currency`` / ``allowed`` /
14+
``received`` / ``received_digits`` so the error message
15+
names the offending currency and precision.
16+
17+
2. **Negative amount rejection** -- a negative ``amount_minor``
18+
would silently fall through every ``op=gt`` predicate
19+
(``negative < positive`` is always False), so the SDK
20+
rejects ``Decimal("-50.00")`` / ``int(-5000)`` /
21+
``Decimal("-5000")`` on both unit paths with
22+
``InvalidMoneyAmountError(reason="negative", ...)``. ``0``
23+
is accepted (legitimate $0.00 refund).
24+
25+
3. **Sub-precision Decimal rejection** -- ``Decimal("1.234")``
26+
against a USD ``allowed=2`` precision is now
27+
``InvalidMoneyPrecisionError(currency="USD", allowed=2,
28+
received=3, received_digits="1.234")`` instead of a silent
29+
round to ``1.23`` that drops the high-order digit the user
30+
explicitly typed. ``float`` and ``Decimal`` are treated
31+
symmetrically; ``int`` always rounds 0-digits.
32+
33+
4. **Explicit ``units`` discriminator + ``Decimal`` support**
34+
-- a new ``BusinessImpact`` model + ``MoneyImpactExtractor``
35+
+ ``@sensitive(impact=...)`` decorator wiring allows the
36+
caller to declare the impact currency / units on
37+
``@sensitive``-decorated functions and have the SDK emit
38+
a structured ``business_impact`` envelope on the
39+
``/track`` event, replacing the previous free-form
40+
``details`` blob. ``Decimal`` values are accepted and
41+
normalised to ``Decimal`` minor-units on the wire.
42+
43+
Side fixes (covered by the same audit pass):
44+
45+
* ``/execute`` now handles ``require_approval`` correctly
46+
and re-checks with the ``approval_id`` returned by the
47+
backend (was dropping the approval handshake on
48+
round-trips).
49+
* Server's ``approval_timeout`` is clamped to ``[1, 3600]s``
50+
on the SDK side as defence against a malformed /
51+
overshooting backend that returns ``0`` or ``2147483647``
52+
in the Разрыв 1c field.
53+
54+
Public API change (additive only, backward-compatible):
55+
56+
* ``InvalidMoneyPrecisionError``, ``InvalidMoneyAmountError``
57+
-- new ``ValueError`` subclasses with structured fields.
58+
* ``BusinessImpact`` -- new ``dataclass(frozen=True)`` model
59+
with explicit ``currency`` / ``units`` / ``amount_minor``
60+
fields. ``details`` dict is still accepted (legacy path).
61+
* ``@sensitive(impact=BusinessImpact(...))`` -- new
62+
decorator kwarg. Existing ``@sensitive(details=...)`` /
63+
``@sensitive(amount_minor=..., currency=...)`` callers keep
64+
working on the happy path (now routed through
65+
``BusinessImpact`` internally).
66+
67+
Tests (existing suite still green; new test modules land in
68+
``tests/test_business_impact.py`` /
69+
``tests/test_units_discriminator.py`` /
70+
``tests/test_money_hardening.py`` /
71+
``tests/test_sensitive_extractor.py`` /
72+
``tests/test_approval_money_flow.py`` /
73+
``tests/test_execute_approval_flow.py``):
74+
75+
* 5 Definition-of-Done scenarios cover negative-amount
76+
rejection, sub-precision Decimal rejection, overflow
77+
rejection, non-finite rejection, ``0`` accepted.
78+
* Units discriminator test: ``USD`` vs ``USDT`` collision is
79+
now caught at the ``BusinessImpact`` boundary, not on the
80+
backend at ``/track`` time.
81+
* ``/execute`` round-trip test exercises the
82+
``require_approval`` + ``approval_id`` re-check path with a
83+
stub backend.
84+
* Server ``approval_timeout`` clamp test verifies
85+
``[1, 3600]s`` boundary.
86+
* 5 contract tests cover the ``MoneyImpactExtractor`` path
87+
end-to-end.
88+
89+
Verification (local):
90+
91+
* ``pytest tests/test_money_hardening.py
92+
tests/test_business_impact.py tests/test_units_discriminator.py
93+
tests/test_sensitive_extractor.py
94+
tests/test_approval_money_flow.py
95+
tests/test_execute_approval_flow.py`` -- all new tests
96+
pass; no regressions in the existing suite.
97+
* ``ruff check src/ tests/`` -- All checks passed.
98+
* ``mypy src/`` -- Success: no issues found in 34 source
99+
files.
100+
101+
No SDK_MIN_VERSION bump (legacy backends unaffected). No on-wire
102+
change (envelope shape preserved). New errors are ``ValueError``
103+
subclasses, so legacy ``except ValueError:`` blocks still catch
104+
them.
105+
106+
---
107+
3108
v3.27 / 0.13.13 (2026-07-21) — Разрыв 1c SDK sync.
4109
5110
Backend commit ``0ad03b9`` (Разрыв 1c, gate hot-path trigger)

src/nullrun/decorators.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -919,13 +919,16 @@ def refund_customer(amount_cents: int, customer_id: str):
919919
if fn is None:
920920
def _attach_decorator(_fn: F) -> F:
921921
if impact is not None:
922-
setattr(_fn, "_nullrun_extractor", impact)
922+
# `setattr` keeps mypy happy without a TYPE_CHECKING
923+
# forward-reference declaration; ruff B010 is a
924+
# stylistic preference (no functional risk here).
925+
setattr(_fn, "_nullrun_extractor", impact) # noqa: B010
923926
return _do_sensitive_register(_fn)
924927
return _attach_decorator # type: ignore[return-value]
925928

926929
# Bare form: @sensitive.
927930
if impact is not None:
928-
setattr(fn, "_nullrun_extractor", impact)
931+
setattr(fn, "_nullrun_extractor", impact) # noqa: B010
929932
return _do_sensitive_register(fn)
930933

931934

src/nullrun/extractor.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,18 +158,18 @@ def refund(amount: Decimal) -> ... # 50 = $50.00 (5000 cents)
158158

159159
import functools
160160
import inspect
161+
from collections.abc import Callable
161162
from decimal import Decimal, InvalidOperation
162-
from typing import Any, Callable, Optional, Union
163+
from typing import Any, Optional, Union
163164

164165
from nullrun.business_impact import (
165-
BusinessImpact,
166-
MoneyImpact,
167166
INFLOW,
168167
OUTFLOW,
168+
BusinessImpact,
169+
MoneyImpact,
169170
compute_action_digest,
170171
)
171172

172-
173173
# Unit discriminators for the typed impact payload.
174174
#
175175
# ``minor`` = the value is already in minor units (cents, pence,
@@ -521,7 +521,7 @@ def _check_business_cap(
521521

522522

523523
def _to_minor_units(
524-
value: Union[int, Decimal], units: str, currency: str,
524+
value: int | Decimal, units: str, currency: str,
525525
enforce_business_cap: bool = True,
526526
) -> int:
527527
"""Convert a Decimal-or-int value to integer minor units.
@@ -669,8 +669,8 @@ def __init__(
669669
def impact_for(
670670
self,
671671
fn: Callable[..., Any],
672-
args: tuple,
673-
kwargs: dict,
672+
args: tuple[Any, ...],
673+
kwargs: dict[str, Any],
674674
) -> BusinessImpact:
675675
"""Bind the call and pull ``self.argument`` out of the bound args.
676676
@@ -722,8 +722,8 @@ def impact_for(
722722

723723

724724
@functools.lru_cache(maxsize=128)
725-
def _cached_signature(fn_id: int) -> Optional[inspect.Signature]:
726-
for obj in gc_get_objects(): # type: ignore[name-defined]
725+
def _cached_signature(fn_id: int) -> inspect.Signature | None:
726+
for obj in gc_get_objects():
727727
if id(obj) == fn_id:
728728
try:
729729
return inspect.signature(obj)
@@ -773,6 +773,6 @@ def compute_impact_digest(impact: BusinessImpact) -> str:
773773
return compute_action_digest(impact)
774774

775775

776-
def gc_get_objects():
776+
def gc_get_objects() -> list[Any]:
777777
import gc
778778
return gc.get_objects()

0 commit comments

Comments
 (0)