Skip to content

Release v0.14.22 - #47

Merged
explosivebit merged 15 commits into
mainfrom
release/v0.14.22
Apr 9, 2026
Merged

explosivebit merged 15 commits into
mainfrom
release/v0.14.22

Conversation

@explosivebit

Copy link
Copy Markdown
Contributor

Release v0.14.22 — PRD-020 bug closure sprint

Brings dev → main for the v0.14.22 release. Contains the full PRD-020 bug-closure sprint merged via #46 plus the protocol lifecycle / heartbeat revert work merged via #44.

Shipped in this release

Bug Fix
#17 — service settings serializer hang New _serializable_settings recursive sanitiser strips callables / non-JSON leaves while preserving valid structure. allow_nan=False probe rejects NaN/inf.
#18 — EVENT wire payload used params transit.send_event now builds Node.js-parity wire payload (data field + broadcast + groups + needAck + caller). New Lifecycle.rebuild_event_context for receive-side translation. send_event_with_ack also uses the new path (audit-caught regression).
P0 — cacher optional-import moleculerpy/cacher/__init__.py no longer hard-requires redis — it's only in the test extra. Clean pip install moleculerpy no longer crashes on ServiceBroker() construction.

Evidence summary

  • Core unit: 2420 passed (+14 regression tests from PRD-020 sprint)
  • Demo suite: 9/9 demos, 184/184 checks, ~150s on dedicated moleculerpy-nats:4223 + moleculerpy-redis:6381
  • Cross-language proven: demo_crosslang 5/5 + demo_crosslang_channels 2/2
  • Smoke install in clean venv (base deps only): all 3 packages import cleanly, versions report 0.14.22 / 0.1.1 / 0.2.1
  • 2-agent pre-release audit (wire + demo-coverage): all HIGH/CRITICAL resolved, see commit d751d0b

Test plan

Post-merge

  1. `git tag -a v0.14.22 -m "..." on main head
  2. git push origin v0.14.22 → triggers .github/workflows/publish.yaml
  3. Watch test → build → publish → GitHub release pipeline
  4. Verify on PyPI (moleculerpy 0.14.22)
  5. Sync: git checkout dev && git merge main && git push origin dev

Refs: PRD-020, KNOWN-ISSUES.md #16/#17/#18/#19

🤖 Generated with Claude Code

explosivebit and others added 15 commits April 7, 2026 14:30
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rallel

Sprint executed via TeamCreate with 6 teammates coordinated by team-lead.
Node.js parity fixes for protocol + graceful lifecycle.

## Tasks completed

1. **Broker hook rename with aliases** (hook-renamer)
   - middleware/base.py: added short aliases starting/started/stopping
   - broker.py: _call_middleware_hooks invokes both long + short names
   - Backward compatible — existing broker_* middleware works unchanged
   - Skipped `stopped` alias due to collision with existing no-arg cleanup hook

2. **PacketHeartbeat proto schema extension** (proto-extender)
   - serializers/proto/packets.proto: added fields 4-7 (seq, instanceID, memory, cpuSeq)
   - Regenerated packets_pb2.py
   - protobuf.py: _HEARTBEAT_MAX_FIELDS 4 → 8
   - ADR-heartbeat-schema.md documenting the decision
   - Wire-compatible with Node.js (ignores unknown fields)

3. **TrackingConfig in Settings** (tracking-configurator)
   - settings.py: TrackingConfig dataclass (enabled=False, shutdown_timeout=5.0s)
   - Validation in Settings._validate()
   - Exported via moleculerpy/__init__.py

4. **Connection drain on broker stop** (connection-drainer)
   - transit.py: send_disconnect_info() broadcasts INFO(services=[]) before disconnect
   - broker.stop(): calls send_disconnect_info() BEFORE transit.disconnect()
   - Matches Node.js service-broker.js:531-539 pattern
   - e2e test with 2 memory brokers verifies remote node drops math.add before DISCONNECT

5. **ContextTracker auto-registration** (tracker-integrator)
   - broker.py __init__: auto-append ContextTrackerMiddleware if settings.tracking.enabled
   - Converts float seconds → int ms for middleware
   - e2e test: slow action in-flight, stop() waits for completion

6. **Protocol lifecycle test suite** (test-author)
   - tests/unit/protocol_lifecycle_test.py (12 tests, ~270 LOC)
   - System-level integration tests for all 5 changes above

## Evidence

- ruff format + check: clean
- mypy --strict: 0 errors
- pytest: **2396 passed** (2378 baseline + 18 new)
- demo_matrix: 28/28 OK
- demo_comprehensive: 90/90 OK

Refs: Protocol gap audit report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…INFO, fix caveats

Executed via TeamCreate with 5 parallel teammates. Fixes 3 caveats from previous sprint + adds proper Node.js parity for service registration.

## Protocol parity (the real fix)

**Problem**: Previous sprint extended PacketHeartbeat proto with seq/instanceID/memory/cpuSeq (fields 4-7) to enable heartbeat-driven restart detection. This broke Python↔Node.js cross-language interop.

**Investigation**: Node.js Moleculer doesn't carry seq/instanceID in heartbeat — it relies on IMMEDIATE INFO broadcast on service changes (registry.js: localNodeInfoInvalidated="seq" + servicesChanged → localServiceChanged → sendLocalNodeInfo). Heartbeat carries only {cpu}. The seq/instanceID checks in heartbeatReceived are defensive dead code.

**Fix**:
1. Revert proto schema (fields 4-7 marked `reserved` so never reused)
2. Revert heartbeat payload to {cpu} only
3. Add seq++ + broadcast INFO in broker.register() when connected
4. Keep seq/instanceID checks in _handle_heartbeat as graceful degradation
5. Cross-language interop now works fully via INFO round-trip

## Caveats fixed

1. **stopped alias collision**: signature introspection in _call_middleware_hooks — backward compatible with legacy Middleware.stopped() no-arg cleanup
2. **Heartbeat cross-language**: full fix via revert + seq++ + INFO broadcast
3. **ContextTracker units**: refactored middleware from int ms → float seconds (Python convention)

## Tasks

| # | Agent | Changes |
|---|-------|---------|
| 1 | proto-reverter | Revert PacketHeartbeat schema, fields reserved, ADR updated |
| 2 | heartbeat-reverter | beat() sends {cpu} only |
| 3 | seq-incrementer | register(): seq++ + send_node_info() + e2e test |
| 4 | stopped-alias-fixer | signature introspection for stopped alias |
| 5 | tracker-unit-fixer | ContextTracker → float seconds |

## Evidence

- ruff format + check: clean
- mypy --strict: 0 errors
- pytest: 2403 passed (+7 new)
- demo_matrix: 28/28 OK
- demo_comprehensive: 90/90 OK

Node.js ↔ Python ProtoBuf interop now works for heartbeat + all protocol features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Critical fixes:
- service_starting → service_created (was never dispatched by broker, service tracking was dead code)
- _is_tracking_enabled supports TrackingConfig dataclass (was only checking dict)

High fixes:
- _wait_for_contexts uses monotonic wall-clock deadline instead of elapsed drift
- TrackingConfig.__post_init__ validates shutdown_timeout > 0 (prevents 0 → immediate timeout bug)
- _HEARTBEAT_MAX_FIELDS = 3 (was 4, off-by-one for 3-field schema)
- Alias dispatch guards against double-invoke when middleware overrides both broker_stopped and stopped
- ContextTracker auto-register checks for existing instance (prevents double-registration)
- broker.register() uses self.node_catalog directly (no transit detour)

Medium fixes:
- $shutdownTimeout camelCase alias for Node.js parity
- Test fixture mock_node_catalog.local_node initialized with seq=0

Evidence: 2403 tests pass, 28/28 demo matrix, 90/90 comprehensive, mypy 0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After Sprint Protocol Lifecycle + Sprint Protocol Fixes retro:

1. docs/SPRINT-CHECKLIST.md — Definition of Done checklist for all future sprints:
   - Protocol & Reference Compliance
   - Architecture & Design (SRP, no private attr access)
   - Code Quality & Typing (no getattr abuse, no Any abuse)
   - Testing (unit + integration + real services)
   - Documentation (CHANGELOG, ADR, Roadmap)
   - Audit (3+ agents mandatory for Standard+)
   - Release Readiness
   - Technical Debt Tracking
   - Sprint Retro questions
   - Red Flags section

2. KNOWN-ISSUES.md — deferred fixes tracker with P0-P3 priorities:
   - 4 P1 items (seq++ guard, inspect caching, real cross-lang test, proto CI guard)
   - 5 P2 items (getattr private, God Object, ADR location, stopped hack, Protocol typing)
   - 6 P3 items (codecov, retro enforcement, fixture pollution, missing regression tests)

Going forward every sprint must complete the checklist before merge.
Deferred items MUST land in KNOWN-ISSUES.md with priority and effort estimate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes P1 + P2 items from KNOWN-ISSUES.md via parallel TeamCreate sprint.

## Tasks completed (6 teammates, 1 wave)

1. **seq++ re-registration guard** (seq-guard)
   broker.register() now checks if service already registered before
   bumping local_node.seq / broadcasting INFO. Prevents duplicate wire
   storms on hot reload / test teardown+reregister.

2. **inspect.signature caching** (sig-cacher)
   _alias_args now caches param count per (id(mw), method_name) in
   self._hook_signature_cache. Reduces repeated introspection on hook
   dispatch.

3. **ProtoBuf regeneration CI check** (proto-ci)
   Makefile targets `proto-gen` + `proto-check`, CI workflow fails
   if packets_pb2.py out of sync with packets.proto.

4. **Transit.is_connected public property** (transit-property)
   Replaces `getattr(transit, "_was_connected", False)` private access
   in broker.register() with typed public API.

5. **TrackingConfigProtocol** (tracking-protocol)
   context_tracker._is_tracking_enabled uses @runtime_checkable Protocol
   instead of getattr duck-typing. TrackingConfig(enabled=False) now
   properly detected.

6. **Audit regression tests** (regression-author)
   New tests/unit/audit_regression_test.py with 7 consolidated
   regression tests covering: camelCase/snakeCase shutdown timeout keys,
   double-registration guard, heartbeat proto extra field drop,
   wall-time deadline, TrackingConfig validation, alias no-double-invoke.

## Cross-language demo stand (bonus)

New examples/demo_crosslang.py — REAL Python ↔ Node.js Moleculer cluster:
- Pre-flight checks Docker + NATS + Node.js toolchain
- Spawns Node.js Moleculer broker subprocess
- Spawns Python MoleculerPy broker on same NATS
- Tests: discovery, Python→Node RPC, event emit, graceful stop
- **Result: 4/4 PASS** — proves cross-language interop works

This closes KNOWN-ISSUES P1 item #3 ("No real Python ↔ Node.js cluster
test"). The theoretical claim is now backed by a working demo.

## Evidence

- ruff format + check: clean
- mypy --strict: 0 errors
- pytest: **2415 passed** (+12 from sprint)
- demo_matrix: 28/28 OK
- demo_comprehensive: 90/90 OK
- **demo_crosslang: 4/4 PASS** (real Node.js ↔ Python via NATS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…6/166 tests)

Sprint Component Demos via TeamCreate (7 teammates + team-lead).

## New demo stands

| Demo | Tests | Verifies on REAL services |
|------|-------|---------------------------|
| demo_cacher | 7/7 | Memory + LRU + Redis (Valkey 6381) |
| demo_channels | 6/6 | Pub/Sub on Redis + NATS |
| demo_repl | 10/10 | REPL commands programmatically |
| demo_web | 11/11 | HTTP gateway via httpx |
| demo_observability | 9/9 | Logging + Metrics (Prometheus) + Tracing |
| demo_crosslang | 5/5 | REAL Python ↔ Node.js with file-based feedback |
| run_all_demos.py | orchestrator | 8/8 demos with summary table, 131s total |
| docs/DEMOS.md | docs | Per-demo documentation |

## Bugs DISCOVERED via real demos (added to KNOWN-ISSUES)

P1:
- #17 moleculerpy-web route hooks + memory transport hangs broker
- #18 EVENT payload Python ships params, Node.js reads data (cross-lang gap)

P2:
- #16 ChannelsMiddleware drops DeadLetteringOptions instance
- #19 MoleculerClientError(code=401) → HTTP 400 instead of 401

P3:
- #20 HTTP gateway streaming doesn't drain on broker.stop()

## Cross-language demo (T3/T4/T5 REAL verification)

Created tests/integration/node_services/crosslang_test.service.js — Node.js
service that writes to /tmp marker files when receiving Python calls/events.
Python reads files to verify ACTUAL bidirectional delivery.

## Final orchestrator result
8/8 demos | 166/166 tests | 131s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… + EVENT schema

Tactical P1 fixes from PRD-020 (bug closure sprint v0.14.22). Discovered by
real demo stands (demo_web hang, demo_crosslang T4 empty payload), invisible
to symmetric Python<->Python unit tests with mocks.

#17 — Web route hooks + memory transport hang:
  node.py build_service_definition previously shipped service.settings as-is.
  ApiGatewayService stores callable route hooks (onBeforeCall, authorization,
  ...) in settings — when INFO packet hit json/msgpack/cbor serializers the
  wire layer crashed or hung mid-serialize. New _serializable_settings helper
  strips every non-JSON-encodable top-level value using a json.dumps probe
  (whitelist, not blacklist — also catches datetime, Path, open file, dataclass
  instances, etc.) and logs a WARNING with the dropped keys so operators see
  data is being omitted instead of silently losing it.

#18 — EVENT payload `params` vs Node.js `data`:
  Python transit.send_event forwarded context.marshall() which put the event
  payload under "params" — Node.js handlers read ctx.data and saw undefined.
  Real root cause: Python conflated REQUEST and EVENT wire schemas through a
  single Context.marshall(). Node.js has separate packet schemas (transit.js
  #sendEvent vs #request).

  Fix keeps the internal Context.params name (REQUEST is unchanged) but builds
  an explicit EVENT wire payload in send_event that matches Node.js 1:1:
    {id, event, data, groups, broadcast, meta, level, tracing, parentID,
     requestID, caller, needAck}
  A new broadcast=False|True kwarg on send_event is passed from broker's
  _emit_core (False) and _broadcast_core (True) so receivers can honour
  emit/broadcast semantics per Moleculer v4 protocol.

  Receive side: new Lifecycle.rebuild_event_context method owns the EVENT
  wire schema and translates data -> internal params, with fallback to
  legacy "params" so a freshly upgraded node keeps accepting traffic from
  older Python peers mid-rolling-upgrade. REQUEST's rebuild_context is left
  untouched.

Evidence:
  * tests/unit/audit_regression_test.py — 4 new regression tests locking
    both fixes (#17 sanitizer keeps JSON, drops callables + returns {}
    for non-dict; #18 send_event wire schema + receive-side data/params
    aliasing)
  * tests/unit/transit_test.py / broker_test.py / event_ack_test.py —
    updated to match new kwarg (broadcast=...) and rebuild_event_context
    dispatch so existing coverage tracks the new contract
  * 2417/2417 core unit tests pass (+2 regression), 380/380 web, 134/134
    channels, 8/8 component demo stands (166/166 checks)
  * demo_crosslang T4: /tmp log confirms Node.js receives real Python
    payload ({"from":"python","marker":"run-..."}) for the first time —
    previously 8-byte empty-object marker only

Refs: PRD-020, KNOWN-ISSUES.md #17, #18
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ra at 4223/6381

Motivation: demos passing ≠ demos validating. Audit of the 166/166 green
Sprint-5 suite against the 4 PRD-020 fixes (#16/#17/#18/#19) showed 3 of
them were NOT actually exercised by any demo:

  #17 settings sanitizer — demo_web used memory:// transport, so no
      real serializer ever ran on the INFO packet. The callable-in-settings
      hang reproducing the original bug was impossible on memory.
  #18 EVENT data field — demo_crosslang T4 asserted only that the Node
      handler fired; it passed with an explicit "payload empty — EVENT
      params/data gap" note. The marker was never required in the wire
      payload, so the test stayed green both before and after the fix.
  #19 client error code — demo_web asserted `status in (400, 401)`, which
      passed both the buggy 400 and the fixed 401.
  #16 DLQ instance — demo_channels passed `{"dead_lettering": {...}}`
      dict form, never the typed instance form the fix was about.

Each fix is now covered by a test that measurably FAILS without the fix,
verified via surgical counterfactual: revert the single source line,
rerun the demo, observe failure, restore.

Changes per bug:

  #17 — demo_comprehensive: new t9_wire_safety group registers
       CallableSettingsService (lambdas + object() in instance settings).
       Two scenarios per transport: single-broker start/stop and two-broker
       remote discovery over the actual wire (NATS/Redis/MQTT/AMQP/Kafka,
       plus memory+tcp). On NATS this adds 13 real data points across 7
       transports where json/msgpack/cbor serializers actually encode the
       INFO packet. Counterfactual: revert node.py:ensure_local_node ->
       service.settings -> callable-settings-start hangs for the full
       10s timeout, exactly matching the original bug.

  #18 — demo_crosslang T4 assertion tightened. Old logic accepted
       "handler fired; payload empty" as PASS; new logic requires both
       handler firing AND the full marker string present in the Node-
       written /tmp/crosslang_test_T4_*.log. Counterfactual: revert
       transit.send_event to forwarding context.marshall() verbatim ->
       T4 fails with "handler fired but marker missing — EVENT payload
       gap; log='PING {}'" — exactly the pre-fix wire state.

  #19 — demo_web: tightened test_custom_middleware -> test_client_error_401
       with exact `status == 401`, not `in (400, 401)`. Added two new
       actions (users.forbidden_op raising code=403,
       users.missing_resource raising code=404) and corresponding
       test_client_error_403 / test_client_error_404 with exact
       status matches. Counterfactual: revert errors.py to
       unconditional BadRequestError -> all three fail with status=400.

  #16 — demo_channels: new test_dlq_typed_options case. Constructs
       DeadLetteringOptions(enabled=True, ...) and RedisOptions(...)
       instances directly (not dicts), passes them through service
       schema, then after broker.start() looks up the parsed Channel
       in channel_registry and asserts identity (`channel.dead_lettering
       is typed_dlq`) — not dict-roundtrip equality. Counterfactual:
       revert middleware.py to dict-only branch -> test fails with
       "DeadLetteringOptions instance dropped (dict-roundtrip)".

Infrastructure consolidation:

  docker-compose.yml moved into the moleculerpy repo (was floating in
  the parent workspace dir, which is not under version control — fresh
  clones would lose it entirely). The compose now publishes NATS on
  4223 and Redis on 6381 instead of the usual 4222/6379, so MoleculerPy
  demos do not collide with other locally running NATS instances
  (e.g. graphrag-nats on 4222 which triggered this whole investigation
  when its volume went read-only mid-session). Ports chosen to match
  the long-standing "demo-valkey" convention (6381) and picked 4223 as
  the least-collisiony nearby free slot. Monitoring port similarly
  shifted 8222 -> 8223.

  All demo files updated to match:
    demo_channels.py:NATS_PORT -> 4223
    demo_comprehensive.py:Transport("nats", ...) -> :4223
    demo_matrix.py:Transport("nats", ...) -> :4223
    demo_crosslang.py:NATS_PORT -> 4223, subprocess env now exports
        NATS_URL so node_services/index.js picks up the same endpoint.
    demo_repl.py:NATS_URL -> :4223, error hint rewritten.

  node_services/index.js reads NATS_URL from process.env (default
  "nats://localhost:4222" for standalone Node experiments) so Python
  drivers that set NATS_URL=nats://localhost:4223 are picked up.

  run_all_demos.py expected counts updated:
    demo_comprehensive 90/90 -> 103/103  (+13 from t9_wire_safety)
    demo_channels      6/6   -> 7/7      (+1 from dlq_typed_options)
    demo_web           11/11 -> 13/13    (+2 for 403 + 404 scenarios;
                                          7th was tightened in-place)

Evidence:
  * 8/8 demos | 182/182 tests | 148s on isolated moleculerpy-nats:4223
    + moleculerpy-redis:6381 (zero dependence on graphrag-nats)
  * Counterfactual confirmed per bug: each fix's revert makes the
    corresponding new/tightened demo measurably fail. Full evidence
    trail in the commit that precedes this one (44811f5).
  * Unit suites unchanged: core 2417/2417, web 380/380, channels 134/134.

Refs: PRD-020, KNOWN-ISSUES.md #16, #17, #18, #19
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes the last unanswered PRD-020 capability gap: until now the
project claimed "any Moleculer-v4 language can talk to MoleculerPy"
but only the CORE protocol (actions, events, discovery, lifecycle)
was actually verified cross-language via demo_crosslang. The
SEPARATE channels wire (raw JetStream streams + durable consumers
with their own envelope) had no automated interop proof — a Node.js
channels_interop.js scaffold had been committed to tests/ months ago
and abandoned without a Python counterpart.

This demo closes the gap on the only level that matters for the
user question ("can my Python + Node apps interoperate through
channels?"): the JetStream wire contract. Two bidirectional tests:

  T1. Python → Node
      moleculerpy-channels NatsAdapter.publish on "payments.completed"
      → Node consumer on the same subject deserializes and writes a
      JSONL log. Python asserts the per-run marker is present with
      full payload intact.

  T2. Node → Python
      Python asks Node over a core NATS request subject to publish
      on "orders.created" with a per-run marker. Node publishes via
      the raw nats.js JetStream client. Python's moleculerpy-channels
      consumer fires its handler and records the payload. Assertion
      requires the marker to round-trip.

Why a "direct nats.js" harness, not @moleculer/channels
-------------------------------------------------------
Building the demo surfaced an upstream regression in
@moleculer/channels 0.2.0 when paired with the current nats.js
2.29.x client: manager.streams.add() calls return did_create: true
in debug logs but the streams never actually land on the NATS
server, and the subsequent subscribe() silently registers zero
consumers (verified via a separate nats client connection to the
same broker showing 0 consumers against the claimed streams on both
NATS 2.10.29 and 2.12.6). moleculerpy-channels' own NATS adapter is
NOT affected (demo_channels 7/7 passes end-to-end against the same
broker), so the defect is strictly on the Node.js high-level
wrapper, not the wire protocol itself.

The correct level to prove cross-language compatibility is the
JetStream wire — if both sides agree on stream naming convention
(dots → underscores), publish subject, and JSON envelope, they
interop regardless of which middleware they layer on top. The new
channels_interop_direct.js Node harness uses the raw `nats` package
(which demonstrably works) to subscribe/publish on the same subjects
a @moleculer/channels (or any other) library would use. Any future
fixed @moleculer/channels release will interoperate the same way
because the wire contract is identical.

The old @moleculer/channels-based channels_interop.js is left in
place for reference but no longer wired into the demo suite — its
first line is updated to read NATS_URL from env so it is still a
valid standalone exploration script when paired with a future
working @moleculer/channels.

Counterfactual verification
---------------------------
Proved this demo is a real regression guard, not a noisy always-green
test:

  * Break the publish subject (NatsAdapter.publish → subject + ".BROKEN")
    → demo fails loudly with MessagePublishError "no response from stream".
  * Corrupt the payload envelope (NatsAdapter.publish → payload =
    b'{"corrupted":true}') → T1 fails with "marker not observed",
    T2 still passes (Node→Python path untouched), proving the assertion
    discriminates "delivered but corrupt" from "delivered intact".

Infrastructure change
---------------------
docker-compose.yml pinned from nats:2-alpine to nats:2.10-alpine.
NATS 2.12 enables JetStream strict mode and API level 3 which the
cross-language investigation flagged as a potential @moleculer/channels
compat hazard. moleculerpy-channels works fine on 2.12+, but the pin
gives us a stable baseline for any future Node.js channels interop
work. Documented inline in docker-compose.yml.

Evidence
--------
  * 9/9 demos | 184/184 tests | 146s on dedicated moleculerpy-nats:4223
    + moleculerpy-redis:6381 (demo_crosslang_channels adds 2/2)
  * Counterfactual per test case confirmed (subject break + envelope
    corrupt) — see above.
  * Core unit suites unchanged: 2417/2417 core, 380/380 web, 134/134
    channels.
  * tests/integration/node_services/index.js and
    channels_interop.js both read NATS_URL from env so cross-language
    tests all flow through the compose file's single endpoint.

Refs: PRD-020, KNOWN-ISSUES.md (cross-lang channels was previously
unverified, now proven at the wire-contract level).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gression

Release prep for v0.14.22 (PRD-020 bug closure sprint). Two kinds of
changes in one commit, kept together because they cannot ship apart:

1. Version bump — pyproject.toml, moleculerpy/__init__.py fallback,
   README "Current status" header, and a comprehensive CHANGELOG entry
   documenting the #17/#18 fixes, the new demo coverage (comprehensive
   T9, tightened crosslang T4, new crosslang_channels), the counterfactual
   evidence, and the infrastructure changes (compose relocation, port
   choices, NATS 2.10 pin).

2. **P0 packaging fix** — smoke install step caught that
   `moleculerpy/cacher/__init__.py` was unconditionally doing
   `from .redis import RedisCacher`, but `redis` is only declared in
   the `test` extra of pyproject.toml, not in the core dependencies.
   As a result, a plain `pip install moleculerpy` would crash on
   ANY ServiceBroker construction with `ModuleNotFoundError: No module
   named 'redis'`. This regression has been latent since 0.14.10
   (commit e2fa6b8 introduced the unconditional import) and was only
   caught now because the pipeline's smoke-install step runs in a
   clean venv with only the declared base dependencies — exactly what
   a PyPI user gets.

   Fix mirrors the pattern already used by moleculerpy-channels/adapters:

       try:
           from .redis import RedisCacher
           _HAS_REDIS_CACHER = True
       except ImportError:
           _HAS_REDIS_CACHER = False
           RedisCacher = None  # type: ignore

   and the "redis" / "Redis" entries are only added to _CACHER_REGISTRY
   when _HAS_REDIS_CACHER is True. `resolve("redis")` on a base install
   now fails loudly with the informative "Unknown cacher type: 'redis'"
   instead of crashing earlier at import time.

   Fixing this was not in the original PRD-020 scope but the sprint
   goal is "make v0.14.22 releasable" and the smoke step is a quality
   gate — an un-importable package cannot ship regardless of whether
   its symptom is PRD-020-related.

Evidence post-change:
  * Full core unit suite: 2417/2417 pass (1 skipped, pre-existing)
  * Demo suite: 9/9 | 184/184 | 149s on dedicated
    moleculerpy-nats:4223 + moleculerpy-redis:6381
  * Smoke install in /tmp/mpy-smoke-0.14.22 (python 3.12.7, only
    nats-py + structlog + psutil installed):
      - moleculerpy         0.14.22
      - moleculerpy-web     0.1.1
      - moleculerpy-channels 0.2.1
      ServiceBroker, ChannelsMiddleware, ApiGatewayService, and all
      401/403/404 error classes import cleanly; ``_serializable_settings``,
      ``Lifecycle.rebuild_event_context``, and ``Transit.send_event``'s
      ``broadcast: bool = False`` kwarg all present and typed correctly;
      ``resolve("memory")`` works, ``resolve("redis")`` raises
      ``ValueError: Unknown cacher type: 'redis'`` as expected without
      the redis package.

Refs: PRD-020, KNOWN-ISSUES.md #16 #17 #18 #19
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e sanitiser + NaN probe

Addresses the HIGH/CRITICAL findings from the 2-agent pre-release
audit of the PRD-020 v0.14.22 branch:

## CRITICAL — `send_event_with_ack` bypassed the EVENT wire schema fix

Wire auditor (sonnet, audit run 2026-04-09) flagged
``moleculerpy/transit.py:1502`` as CRITICAL. Before this commit,
``Transit.send_event_with_ack`` published via
``self.publish(Packet(Topic.EVENT, ..., context.marshall()))`` —
``context.marshall()`` serialises the event payload under the legacy
``params`` key, which bypassed the KNOWN-ISSUES #18 fix entirely for
the reliable-event (needAck) code path. Any event sent with ``needAck=True``
to a Node.js peer would arrive with ``ctx.data === undefined``. The
reliable-event path had no production caller yet (no ``broker.emit_with_ack``
surface exposed), but it IS a public ``Transit`` API and was exercised
by ``event_ack_test.py`` — the regression would have landed silently
the moment the broker-level API was added later.

Fix: ``send_event_with_ack`` now delegates to ``send_event`` instead of
building its own publish. ``send_event`` was already updated in 44811f5
with the Node.js-parity wire payload, so the ACK path inherits all of
``data``, ``broadcast``, ``groups``, ``needAck``, ``caller``, ``parentID``,
``requestID``, ``meta``, ``level``, ``tracing``. The ``broadcast=False``
default is correct for ACK paths (they are always directed to a specific
endpoint, never multicast).

Regression guard: ``test_bug17_send_event_with_ack_uses_data_field`` in
``tests/unit/audit_regression_test.py`` does both a static source probe
(``"self.send_event(" in source``) AND a functional probe constructing
a real ``Transit``, calling ``send_event_with_ack`` against a mock
transporter, and asserting the captured packet has ``data`` but not
``params``, plus ``needAck`` is True. A future regression that inlines
the publish back would fail both probes.

## HIGH — ``_serializable_settings`` permitted NaN/inf via json.dumps(allow_nan=True)

Wire auditor flagged ``moleculerpy/node.py:67`` as HIGH. Python's
``json.dumps`` defaults to ``allow_nan=True``, so ``float('nan')``,
``float('inf')``, and ``float('-inf')`` pass the probe as the literal
strings ``"NaN"`` / ``"Infinity"`` — which are not valid JSON per
RFC 8259 §6 and which MsgPack's ``msgpack.packb`` rejects with
``PackException``. A service with a NaN in its settings would have
crashed the INFO packet mid-handshake on NATS + MsgPack the moment it
reached any non-JSON transporter.

Fix: new ``_is_wire_scalar`` helper uses ``json.dumps(value, allow_nan=False)``
as the probe. NaN/inf now fail the probe and get stripped alongside
callables. The change also rejects non-scalar leaves at arbitrary depth,
which feeds into the next finding.

## HIGH — ``_serializable_settings`` top-level drop was too coarse

Wire auditor flagged the old behaviour where a nested structure like
``{"routes": [{"path": "/api", "hook": <callable>}]}`` would fail the
top-level probe because ``json.dumps`` recurses into the list and chokes
on the callable — causing the *entire* ``routes`` list to be dropped,
including the ``path``/``method``/``aliases`` structure that remote
nodes legitimately want to see. Losing the whole ``routes`` config
defeats the point of shipping settings over the wire.

Fix: ``_sanitize_wire_value`` is a new recursive walker that preserves
dict / list shape and only strips the actual bad leaves. A
``{"routes": [{"path": "/api", "hook": <callable>}]}`` input now becomes
``{"routes": [{"path": "/api"}]}``. Non-serialisable dict keys are
dropped too. The WARNING message now says "affected top-level keys"
rather than "dropped keys" to reflect the granular sub-rewriting, and
adds a hint that peer services should receive runtime values via
action params or metadata, not settings.

Regression guards:
- ``test_bug17_rejects_nan_and_inf_for_binary_serializer_safety`` —
  NaN/inf stripped, strict JSON round-trip.
- ``test_bug17_recursive_sanitisation_preserves_siblings`` — a
  ``routes`` list with one good entry and one entry containing nested
  callables round-trips with ``path``/``method``/``aliases`` preserved
  and the callables removed. The good sibling entry (``/health``)
  survives intact.
- ``test_bug17_service_settings_with_callables_are_stripped`` tightened
  to assert the exact surviving key set (``{"port", "host", "routes"}``)
  per demo auditor recommendation — guards against a future regression
  that would over-zealously drop safe sibling keys.

## MEDIUM — ``ServiceNotFoundError`` does NOT shadow
## ``MoleculerClientError(code=404)`` in the gateway

Demo auditor verified that the 404 branch in ``demo_web`` is reached
cleanly: ``ServiceNotFoundError`` inherits from ``MoleculerRetryableError``,
not ``MoleculerClientError``, so an explicit ``MoleculerClientError(code=404)``
falls through to the new numeric code branch and is correctly mapped
to HTTP 404. No fix needed — this is a positive observation.

## LOW — old ``channels_interop.js`` stale and unreferenced

Demo auditor noted that ``tests/integration/node_services/channels_interop.js``
was no longer referenced by any demo after 8a1fbf6 introduced
``channels_interop_direct.js``. Leaving both files risks confusing
future contributors about which harness is the active one. Deleted
``channels_interop.js`` (the old ``@moleculer/channels`` reference
implementation — now known to have the upstream stream-creation
regression documented in 8a1fbf6). The ``channels_interop_direct.js``
file that bypasses ``@moleculer/channels`` is the only interop harness
going forward until an upstream fix ships.

## Evidence after all audit fixes

- Core unit: 2420/2420 pass (+3 new regression tests from this commit),
  1 skipped pre-existing.
- Channels unit: 135/135 (+1 new ``test_parse_channel_accepts_redis_options_instance``
  mirror test per demo auditor recommendation, committed separately in the
  channels repo).
- Demo suite: 9/9 | 184/184 | 157s on moleculerpy-nats:4223 +
  moleculerpy-redis:6381.
- Smoke install in /tmp/mpy-smoke-0.14.22 still clean (no change to
  the cacher optional-import fix).
- Lint / format / mypy: all pass on touched files.

Refs: PRD-020, wire-audit 2026-04-09, demo-coverage-audit 2026-04-09
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolves version-field conflicts in pyproject.toml and
moleculerpy/__init__.py. Dev had an independent 0.14.19 → 0.14.21 bump
path through commits aaa1719 and 201ca64, while this branch already
carries the 0.14.21 → 0.14.22 bump in d4b0b6b. Resolution: take 0.14.22
(HEAD) everywhere since it is strictly ahead of dev's 0.14.21 on both
version strings.

Post-merge checks:
* ruff / mypy / unit suite (2420 passed) all still green
* demo_crosslang sanity smoke 5/5

Refs: PRD-020 release merge prep for v0.14.22
[PRD-020] v0.14.22 bug closure sprint — cross-lang wire schema + settings sanitiser
@codecov

codecov Bot commented Apr 9, 2026

Copy link
Copy Markdown

@explosivebit
explosivebit merged commit a5c5c01 into main Apr 9, 2026
10 of 11 checks passed
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