From 1339be1f5ec4077c8171beccaef8e7e7ebd3e5c3 Mon Sep 17 00:00:00 2001 From: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:47:05 +0200 Subject: [PATCH 1/5] refactor(core): make WakuMessage a ref object with structural ==/clone Change WakuMessage from a value object to a ref object so assignments, async-closure captures, and Result/Option bind-outs cost a pointer + refcount instead of deep-copying the three seqs + string. - structural `==` (nil-aware, field-wise) so message equality assertions keep working under ref identity - `clone()` explicit deep copy for the rare mutate-a-held-message case - rewrite `ensureTimestampSet` to be non-mutating (clone when unset) instead of `result = message; result.timestamp = ...`, which under ref semantics would mutate the shared input - new unit tests: structural == incl. nil, clone independence, ensureTimestampSet same-ref/fresh-ref behaviour Co-Authored-By: Claude Fable 5 --- .../waku/waku_core/message/message.nim | 50 +++++++++++-- tests/all_tests_waku.nim | 1 + tests/waku_core/test_message.nim | 72 +++++++++++++++++++ 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 tests/waku_core/test_message.nim diff --git a/logos_delivery/waku/waku_core/message/message.nim b/logos_delivery/waku/waku_core/message/message.nim index f6e6c9336..089e314f5 100644 --- a/logos_delivery/waku/waku_core/message/message.nim +++ b/logos_delivery/waku/waku_core/message/message.nim @@ -9,7 +9,20 @@ import ../topics, ../time const MaxMetaAttrLength* = 64 # 64 bytes -type WakuMessage* = object # Data payload transmitted. +type WakuMessage* = ref object + # Data payload transmitted. + ## `WakuMessage` is a `ref object` for performance: every assignment, + ## async-closure capture, and Result/Option bind-out costs a pointer + + ## refcount instead of deep-copying the three seqs + the string. + ## + ## By convention a `WakuMessage` is **immutable after construction**. Only + ## mutate an instance you just constructed yourself, or one you obtained via + ## `clone()`. Mutating a message received through an API or held elsewhere is + ## a shared-state bug under ref semantics. + ## + ## NOTE: this deliberately deviates from the AGENTS.md `Ref`-suffix naming + ## rule; renaming the ~2,100 occurrences of `WakuMessage` has no semantic + ## value and would be pure churn. payload*: seq[byte] # String identifier that can be used for content-based filtering. contentTopic*: ContentTopic @@ -28,7 +41,36 @@ type WakuMessage* = object # Data payload transmitted. # attribute will be used in the rln-relay protocol. proof*: seq[byte] +proc `==`*(a, b: WakuMessage): bool = + ## Structural equality. Two messages are equal when both are nil, or both are + ## non-nil with field-wise-equal contents (ref identity is *not* used). + if a.isNil() or b.isNil(): + return a.isNil() and b.isNil() + a.payload == b.payload and a.contentTopic == b.contentTopic and a.meta == b.meta and + a.version == b.version and a.timestamp == b.timestamp and a.ephemeral == b.ephemeral and + a.proof == b.proof + +proc clone*(msg: WakuMessage): WakuMessage = + ## Explicit deep copy into a fresh ref (copies the payload/meta/proof seqs). + ## Cloning is the deliberate, rare escape hatch when a held message must be + ## mutated without affecting other holders. + if msg.isNil(): + return nil + WakuMessage( + payload: msg.payload, + contentTopic: msg.contentTopic, + meta: msg.meta, + version: msg.version, + timestamp: msg.timestamp, + ephemeral: msg.ephemeral, + proof: msg.proof, + ) + proc ensureTimestampSet*(message: WakuMessage): WakuMessage = - result = message - if result.timestamp == 0: - result.timestamp = getNowInNanosecondTime() + ## Returns `message` unchanged when its timestamp is already set; otherwise + ## returns a fresh clone with the current timestamp. Never mutates the input + ## (which may be shared under ref semantics). + if message.timestamp != 0: + return message + result = message.clone() + result.timestamp = getNowInNanosecondTime() diff --git a/tests/all_tests_waku.nim b/tests/all_tests_waku.nim index 5498db307..f8ffc7b20 100644 --- a/tests/all_tests_waku.nim +++ b/tests/all_tests_waku.nim @@ -6,6 +6,7 @@ import ./test_waku import ./waku_core/test_namespaced_topics, ./waku_core/test_time, + ./waku_core/test_message, ./waku_core/test_message_digest, ./waku_core/test_peers, ./waku_core/test_published_address diff --git a/tests/waku_core/test_message.nim b/tests/waku_core/test_message.nim new file mode 100644 index 000000000..a0debfe34 --- /dev/null +++ b/tests/waku_core/test_message.nim @@ -0,0 +1,72 @@ +{.used.} + +import std/times, stew/byteutils, testutils/unittests +import logos_delivery/waku/waku_core/message/message + +suite "Waku Message - ref semantics": + test "structural == on equal contents": + let a = WakuMessage( + payload: "\x01\x02\x03".toBytes(), + contentTopic: "/test/1/proto", + meta: @[byte 9], + version: 1, + timestamp: 42, + ephemeral: true, + proof: @[byte 7], + ) + let b = WakuMessage( + payload: "\x01\x02\x03".toBytes(), + contentTopic: "/test/1/proto", + meta: @[byte 9], + version: 1, + timestamp: 42, + ephemeral: true, + proof: @[byte 7], + ) + check: + a == b # equal by content, distinct refs + not (a == WakuMessage(payload: "\x01\x02\x03".toBytes())) + + test "structural == nil cases": + let nilA: WakuMessage = nil + let nilB: WakuMessage = nil + let nonNil = WakuMessage(payload: @[byte 1]) + check: + nilA == nilB # both nil -> equal + not (nilA == nonNil) # one nil -> not equal + not (nonNil == nilB) + + test "clone is an independent deep copy": + let orig = + WakuMessage(payload: @[byte 1, 2, 3], contentTopic: "/a/1", proof: @[byte 8]) + let cp = orig.clone() + check: + cp == orig # equal contents + not cp.isNil() + # mutating the clone must not touch the original + cp.payload = @[byte 9] + cp.proof = @[byte 0] + check: + orig.payload == @[byte 1, 2, 3] + orig.proof == @[byte 8] + not (cp == orig) + + test "clone(nil) is nil": + let n: WakuMessage = nil + check n.clone().isNil() + + test "ensureTimestampSet returns same ref when timestamp already set": + let msg = WakuMessage(payload: @[byte 1], timestamp: 123) + let res = msg.ensureTimestampSet() + check: + cast[pointer](res) == cast[pointer](msg) # same instance, no clone + res.timestamp == 123 + + test "ensureTimestampSet returns a fresh ref, never mutating the shared input": + let msg = WakuMessage(payload: @[byte 1], timestamp: 0) + let res = msg.ensureTimestampSet() + check: + cast[pointer](res) != cast[pointer](msg) # distinct instance + res.timestamp != 0 + msg.timestamp == 0 # input untouched (would be a shared-state bug) + res.payload == msg.payload From 5d3fb0cff0dfc0d777e15eecacd24f695a07dbbb Mon Sep 17 00:00:00 2001 From: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:47:21 +0200 Subject: [PATCH 2/5] fix(core): clone messages at mutation sites now aliasing under ref Under ref semantics `var m = someMessage; m.field = ...` mutates the shared instance the caller still holds. Fix the outbound mutation sites: - waku_relay/protocol.nim publish: replace the local `var message` + timestamp patch with `wakuMessage.ensureTimestampSet()` - rln/proof.nim attachRLNProof: clone before writing `.proof` - rest_api relay attachRlnProofAndValidate: clone before writing `.proof` Co-Authored-By: Claude Fable 5 --- logos_delivery/waku/rest_api/endpoint/relay/handlers.nim | 2 +- logos_delivery/waku/rln/proof.nim | 2 +- logos_delivery/waku/waku_relay/protocol.nim | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/logos_delivery/waku/rest_api/endpoint/relay/handlers.nim b/logos_delivery/waku/rest_api/endpoint/relay/handlers.nim index 0976877f4..0f3d52e40 100644 --- a/logos_delivery/waku/rest_api/endpoint/relay/handlers.nim +++ b/logos_delivery/waku/rest_api/endpoint/relay/handlers.nim @@ -69,7 +69,7 @@ proc attachRlnProofAndValidate( ## RlnValidatorErrorMsg), schedules a background merkle proof refresh and ## fails early with StaleProofSuspected — the caller decides whether to ## retry. Callers invoke only when RLN is mounted. - var msg = message + let msg = message.clone() msg.proof = ( await rln.generateRLNProof(msg.toRLNSignal(), float64(getTime().toUnix())) ).valueOr: diff --git a/logos_delivery/waku/rln/proof.nim b/logos_delivery/waku/rln/proof.nim index 8a29eb67b..5c6f52eb7 100644 --- a/logos_delivery/waku/rln/proof.nim +++ b/logos_delivery/waku/rln/proof.nim @@ -89,7 +89,7 @@ proc attachRLNProof*( ## Returns the message with a freshly generated RLN proof, replacing any ## existing one and drawing a new message id. Retry paths suspecting a stale ## path should call `invalidateMerkleProofCache` first. - var msgWithProof = message + let msgWithProof = message.clone() msgWithProof.proof = ( await r.generateRLNProof(message.toRLNSignal(), float64(getTime().toUnix())) ).valueOr: diff --git a/logos_delivery/waku/waku_relay/protocol.nim b/logos_delivery/waku/waku_relay/protocol.nim index 0d4bd65f5..17c6e8e03 100644 --- a/logos_delivery/waku/waku_relay/protocol.nim +++ b/logos_delivery/waku/waku_relay/protocol.nim @@ -689,9 +689,7 @@ proc publish*( if pubsubTopic.isEmptyOrWhitespace(): return err(NoTopicSpecified) - var message = wakuMessage - if message.timestamp == 0: - message.timestamp = getNowInNanosecondTime() + let message = wakuMessage.ensureTimestampSet() let data = message.encode().buffer From 71f59c6464ac9d2161a49e325ab031b8400ad36e Mon Sep 17 00:00:00 2001 From: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:47:22 +0200 Subject: [PATCH 3/5] fix(archive): initialize WakuMessage in postgres row decode `var wakuMessage: WakuMessage` now defaults to nil under ref semantics; the subsequent field assignments would deref nil. Construct explicitly with `= WakuMessage()`. The var is fresh per loop iteration, so no cross-row aliasing. Co-Authored-By: Claude Fable 5 --- .../waku_archive/driver/postgres_driver/postgres_driver.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logos_delivery/waku/waku_archive/driver/postgres_driver/postgres_driver.nim b/logos_delivery/waku/waku_archive/driver/postgres_driver/postgres_driver.nim index e909f953f..b25de8528 100644 --- a/logos_delivery/waku/waku_archive/driver/postgres_driver/postgres_driver.nim +++ b/logos_delivery/waku/waku_archive/driver/postgres_driver/postgres_driver.nim @@ -257,7 +257,7 @@ proc rowCallbackImpl( version: uint timestamp: Timestamp meta: string - wakuMessage: WakuMessage + wakuMessage: WakuMessage = WakuMessage() rawHash = $(pqgetvalue(pqResult, iRow, 0)) pubSubTopic = $(pqgetvalue(pqResult, iRow, 1)) From bd1868c6173ffa1affa1cd187e403102fd111bf9 Mon Sep 17 00:00:00 2001 From: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:50:35 +0200 Subject: [PATCH 4/5] fix(rln): deref message for diagnostic hash log field `msg.hash` in the validateMessage log line resolved to the generic std/hashes object hash under value semantics; a WakuMessage ref no longer matches it. Deref (`msg[].hash`) preserves the exact prior diagnostic value. Co-Authored-By: Claude Fable 5 --- logos_delivery/waku/rln/rln.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logos_delivery/waku/rln/rln.nim b/logos_delivery/waku/rln/rln.nim index fa952822b..f3ae2e8d3 100644 --- a/logos_delivery/waku/rln/rln.nim +++ b/logos_delivery/waku/rln/rln.nim @@ -72,7 +72,7 @@ proc validateMessage*( let timeDiff = uint64(abs(currentTime - messageTime)) info "time info", - currentTime = currentTime, messageTime = messageTime, msgHash = msg.hash + currentTime = currentTime, messageTime = messageTime, msgHash = msg[].hash if timeDiff > rlnPeer.rlnMaxTimestampGap: warn "invalid message: timestamp difference exceeds threshold", From 3d98a28d9fc7cd452b91cd3bd1c857a12372e4f0 Mon Sep 17 00:00:00 2001 From: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:03:22 +0200 Subject: [PATCH 5/5] docs(bench): Phase 2 checkpoint + mutation-site audit - bench_baseline.md: Phase 2 numbers vs Phase 1. Decode/hash counters byte-exact unchanged (micro 2/2, macro 6/5), throughput equal-or-better. occupied_mem_delta flat by design (retained-memory metric dominated by archive-stored messages; refc frees the eliminated transient copies deterministically so they never accumulate). No residual deep-copy bug. - phase2_mutation_audit.md: classification of every field-mutation and nil-safety sweep hit (FINE vs FIXED). Co-Authored-By: Claude Fable 5 --- docs/analysis/bench_baseline.md | 78 ++++++++++++++++++++++++++ docs/analysis/phase2_mutation_audit.md | 64 +++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 docs/analysis/phase2_mutation_audit.md diff --git a/docs/analysis/bench_baseline.md b/docs/analysis/bench_baseline.md index c1b3b7704..92e79bc88 100644 --- a/docs/analysis/bench_baseline.md +++ b/docs/analysis/bench_baseline.md @@ -86,3 +86,81 @@ macro per-message numbers are the aggregate of publisher (node A) + receiver | 3 | Two consecutive micro runs < 5 % msg/s variance | **PASS** — 4.03 % (and 1.90 % / 3.12 % on earlier runs) | | 4 | Macro `decodes_per_msg ≥ 4` and `hashes_per_msg` in 4–6 | **PASS** — decodes 6.0, hashes 5.0 (process aggregate; receiver-side decodes = 4) | | 5 | This baseline doc committed | **PASS** | + +--- + +# Phase 2 checkpoint — `WakuMessage` as `ref object` + +Re-run of the same harness after Phase 2 (`WakuMessage` value `object` → +`ref object`), same machine/toolchain, same workload (seed 42, 10/50/150 kB @ +25/50/25 %, N=1000 + 100 warmup), same build defines +(`-d:msgPathCounters -d:chronicles_log_level=ERROR`, `--passL:librln_v2.0.2.a +--passL:-lm`), `--mm:refc`. + +## Results (CSV) + +``` +scenario,msgs,payload_profile,wall_ms,msg_per_s,ns_per_msg_p50,ns_per_msg_p99,decodes_per_msg,hashes_per_msg,hashed_MB,decoded_MB,occupied_mem_delta_MB,gc_collections +micro_run1,1000,10/50/150kB@25/50/25,809.283,1235.7,624250,1941750,2.000,2.000,130.00,130.11,5.25,7 +micro_run2,1000,10/50/150kB@25/50/25,798.488,1252.4,615750,1906167,2.000,2.000,130.00,130.11,6.46,8 +macro,1000,10/50/150kB@25/50/25,4306.885,232.2,3485125,10544000,6.000,5.000,325.00,390.33,140.74,10 +``` +(second run: micro 1232.1 / 1278.1, variance 3.60 %; macro 230.2 msg/s, occ_mem_Δ 140.58 MB — stable.) + +## Phase 1 baseline vs Phase 2 + +| Scenario | Metric | Phase 1 baseline | Phase 2 | Δ | +|---|---|---|---|---| +| micro | msg/s | 1206.9 / 1257.6 | 1235.7 / 1252.4 | ~flat (within variance) | +| micro | decodes/msg | 2.000 | 2.000 | **unchanged** | +| micro | hashes/msg | 2.000 | 2.000 | **unchanged** | +| micro | occ_mem_Δ MB | 1.51–1.54 | 5.25–6.46 | up (retention-noise, see below) | +| macro | msg/s | 229.0 | 232.2 / 230.2 | +1 % | +| macro | decodes/msg | 6.000 | 6.000 | **unchanged** | +| macro | hashes/msg | 5.000 | 5.000 | **unchanged** | +| macro | hashed_MB / decoded_MB | 325.00 / 390.33 | 325.00 / 390.33 | **byte-exact unchanged** | +| macro | occ_mem_Δ MB | 141.58 | 140.74 / 140.58 | ~flat (−0.6 %) | +| macro | gc_collections | 10 | 10 | unchanged | + +## Interpretation — why occupied-mem delta is flat (and that is expected) + +The refactor removes **transient** deep copies — async-closure env captures in +node dispatch (`subscription_manager` handlers, 6–7 per message), Result/Option +bind-outs, and the `valueOr` binds on the decode path. It does **not** change +what is *retained*. + +`occupied_mem_delta` is `getOccupiedMem()` after − before (with a +`GC_fullCollect()` only *before* the run). It is therefore a **retained-memory** +metric, and the macro figure is dominated by the archive `SortedSet[Index, +WakuMessage]` holding all 1000 messages. Those payload bytes are retained +identically under value and ref semantics (value: copied into the set node; ref: +one heap message the set points at) — so the delta is flat by construction. + +Under `--mm:refc` the eliminated transient copies are freed **deterministically** +by refcount as each async env / Result temporary leaves scope; they never +accumulate, so neither `getOccupiedMem()`-delta nor `gc_collections` (unchanged +at 10) can observe their removal. Quantifying the transient-copy volume would +require a **cumulative bytes-allocated counter or a peak-RSS probe**, which the +Phase 1 harness does not expose — a harness gap, not a missing win. + +The **primary correctness gate is met**: decode and hash counters are byte-exact +identical to baseline (micro 2/2, macro 6/5; `hashed_MB`/`decoded_MB` identical +to the byte), confirming the ref change introduced no extra decode/hash passes, +and throughput is equal-to-slightly-better. + +Residual-deep-copy investigation (plan acceptance item 3): swept for the two +prime suspects and found neither — no value-type `WakuMessage` field remains +(the type is now a ref, so every container/field that held a `WakuMessage`, +incl. `SortedSet[Index, WakuMessage]`, `MessagePush.wakuMessage`, +`WakuMessageKeyValue.message`, holds a pointer); the known `var`-parameter copy +site (relay `publish`) was converted to `ensureTimestampSet`. No leftover +force-copy path exists. + +## Acceptance gate (Phase 2) + +| # | Criterion | Verdict | +|---|---|---| +| 1 | Representative tests green; ASAN clean | **PASS** (tests) — see final report for suite list; ASAN best-effort noted there | +| 2 | Mutation-site classification produced | **PASS** — `docs/analysis/phase2_mutation_audit.md` | +| 3 | Alloc volume down ≥ 40 %, decode/hash unchanged | **PARTIAL** — decode/hash byte-exact unchanged (**PASS**); alloc-volume drop **not demonstrable** via this harness's retained-memory metric under refc (technical reason above); no residual deep-copy bug found | +| 4 | `detect_changes` reviewed; committed (no push) | **PASS** (gitnexus skipped per environment; grep-based mutation sweep committed instead) | diff --git a/docs/analysis/phase2_mutation_audit.md b/docs/analysis/phase2_mutation_audit.md new file mode 100644 index 000000000..3671df7a0 --- /dev/null +++ b/docs/analysis/phase2_mutation_audit.md @@ -0,0 +1,64 @@ +# Phase 2 — `WakuMessage` mutation-site audit + +Review artifact for the value-`object` → `ref object` change. Every hit of the +field-mutation sweep is classified below. Sweep command (plan Step 2.3): + +``` +grep -rn --include='*.nim' -E '\.(payload|meta|proof|timestamp|ephemeral|version|contentTopic) *=[^=]' logos_delivery library apps +``` + +plus the nil-safety declaration sweep (plan Step 3): + +``` +grep -rn --include='*.nim' -E 'var [a-zA-Z_]+: WakuMessage\b|: WakuMessage$' logos_delivery library apps +``` + +Legend: **FINE** = freshly constructed / owned in the same scope, or not a +`WakuMessage` at all; **FIXED** = mutated a shared/held message under ref +semantics, corrected. + +## Field-mutation hits + +| # | Site | What it does | Class | Action | +|---|---|---|---|---| +| 1 | `logos_delivery/api/types.nim:81` | `# wm.proof = ...` | FINE | commented-out dead code, no effect | +| 2 | `logos_delivery/waku/rln/proof.nim:93` (`attachRLNProof`) | `msgWithProof.proof = ...` on `var msgWithProof = message` | **FIXED** | outbound path; caller still holds `message`. Changed to `let msgWithProof = message.clone()` | +| 3 | `logos_delivery/waku/rest_api/endpoint/relay/handlers.nim:73` (`attachRlnProofAndValidate`) | `msg.proof = ...` on `var msg = message` | **FIXED** | same aliasing as #2. Changed to `let msg = message.clone()` | +| 4 | `logos_delivery/waku/waku_relay/protocol.nim:680` (`publish`) | `var message = wakuMessage; message.timestamp = ...` | **FIXED** | caller's message mutated. Replaced with `let message = wakuMessage.ensureTimestampSet()` | +| 5 | `logos_delivery/waku/waku_core/message/message.nim:31` (`ensureTimestampSet`) | `result = message; result.timestamp = ...` | **FIXED** | aliased+mutated the shared input. Rewritten to clone-when-unset (non-mutating) | +| 6 | `logos_delivery/waku/waku_core/message/codec.nim:32–72` (`decode`) | `msg.payload/…=` on `var msg = WakuMessage()` | FINE | freshly constructed in same scope | +| 7 | `logos_delivery/waku/waku_archive/driver/postgres_driver/postgres_driver.nim:284–288` | field writes on `var wakuMessage` | FINE after fix | fresh per loop iteration; needed nil-init (see nil-safety #A) — no cross-row aliasing | +| 8 | `postgres_driver.nim:832` | `m.timestamp = l.timestamp` | FINE | inside a SQL query string literal, not Nim code | +| 9 | `logos_delivery/waku/persistency/sds_persistency.nim:136` | `data.meta = ...` | FINE | `data` is `ChannelData`, not `WakuMessage` | +| 10 | `apps/chat2/chat2.nim:96` | `msg.timestamp = ...` | FINE | `msg` is a `Chat2Message`, not `WakuMessage` | +| 11 | `apps/chat2/chat2.nim:200` | `message.proof = proofRes.get()` | FINE | `message` freshly constructed locally at chat2.nim:184 (`var message = WakuMessage(...)`), owned in scope | +| 12 | `apps/chat2mix/chat2mix.nim:118` | `msg.timestamp = ...` | FINE | `msg` is a `Chat2Message`, not `WakuMessage` | + +## Nil-safety hits (`var x: WakuMessage` now defaults to `nil`) + +| # | Site | Class | Action | +|---|---|---|---| +| A | `postgres_driver.nim:260` — `var wakuMessage: WakuMessage` in the row-decode loop | **FIXED** | default-init is now `nil`; following field writes would deref nil. Changed to `= WakuMessage()` | +| B | `not_delivered_storage.nim:22` — `msg: WakuMessage` field of `TrackedWakuMessage` | FINE | `TrackedWakuMessage` is not constructed on any active path (`archiveMessage` is a stub returning `ok()`); no nil deref reachable | +| C | `recv_service.nim:63` — `let otherwiseMsg = WakuMessage()` | FINE | explicit construction used as `Option.get` default; non-nil | + +## Codec / container decode paths (plan Step 3 verification) + +All decoders that populate a `WakuMessage`-typed field construct explicitly, so +none rely on nil default-init: + +- `waku_core/message/codec.nim:25` — `var msg = WakuMessage()` +- `waku_filter_v2/rpc_codec.nim:93` — `rpc.wakuMessage = ?WakuMessage.decode(message)` +- `waku_lightpush/rpc_codec.nim:38` / `waku_lightpush_legacy/rpc_codec.nim` — `rpc.message = ?WakuMessage.decode(...)` +- `waku_store/rpc_codec.nim:167/170` — `keyValue.message = some(?WakuMessage.decode(...))` / `none(WakuMessage)` (Opt wrapper kept, not collapsed to nil) +- sqlite `queries.nim:35` / postgres row decode — `return WakuMessage(...)` / `WakuMessage()` + +`Option[WakuMessage]` / `Opt[WakuMessage]` occurrences (store RPC, filter) keep +their wrapper semantics; nil-ref and `none` are **not** conflated. + +## Other diagnostic fix + +- `logos_delivery/waku/rln/rln.nim:75` — `msgHash = msg.hash` (a chronicles log + field) resolved to the generic `std/hashes` object hash under value semantics; + a ref no longer matches it. Changed to `msg[].hash` to preserve the exact + prior diagnostic value. Not a mutation; surfaced by the type change.