# Bug Audit — 2026-04-30

Audit of `net/crates/net/src/` (153 source files) across core bus, transport, channel/compute, redex/state, and FFI/identity/traversal subsystems. Bugs are grouped by severity and reference paths relative to `net/crates/net/src/`.

## Status (running tally)

**Fixed with regression test:** #1, #2, #3, #4, #5, #6, #7, #8, #10, #11, #12, #14, #15, #16, #17, #19, #25, #26, #27, #31, #32, #33, #35, #38, #40, #42, #44, #46, #47, #48, #49, #50, #51, #52
**Fixed without regression test (rationale below):** #9, #13, #18, #20, #21, #22, #23, #24, #28, #29, #30, #34, #36, #37, #39, #41, #43, #45, #53, #54
**Outstanding:** *(none)*

Test-skip rationale:
- **#9** (JetStream nonce) — fix adds per-process nonce to msg-id prefix. Reproducing the bug requires either a real NATS server with a configured dedup window or a mock for `js.publish_with_headers` that snapshots ids; both are heavy to set up. The fix is verifiable by inspection: `msg_id_buf` now leads with `{nonce:x}:` where `nonce` is sampled from clock+pid at construction.
- **#13** (sendmmsg partial send) — fix loops on the unsent tail. Linux-only path; reliably forcing a partial sendmmsg in a unit test requires kernel-level fault injection. Inspection-verified.
- **#18** (FFI `c_str_to_str` lifetime) — purely a type-system fix; the bound is enforced by the borrow checker. No runtime path produces a different result before vs. after.
- **#20** (UPnP `add_any_port`) — fix switches to `add_any_port` so the returned external port is authoritative. Testable only against a real IGD that re-maps ports, which CI does not have.
- **#23** (`MeshDaemon::restore` default) — fix adds `is_stateful()` and rejects non-empty state on stateless daemons. The trait change is straightforward; existing daemon impls compile unchanged. A direct test requires a custom stateful daemon impl which adds boilerplate without value beyond the inspection-level guarantee.
- **#30** (BatchedPacketReceiver SO_RCVTIMEO) — Linux-only; fix removes `setsockopt(SO_RCVTIMEO)` on the shared fd. Detecting "shared fd is unmodified" requires comparing pre/post `getsockopt` from another consumer, which the test harness doesn't expose.
- **#39** (LoadBalancer RR retry counter) — fix pre-computes the RR offset once outside the retry loop. Reproducing the buggy 4× advance requires forcing `try_record_request` to fail under contention, which the existing endpoint-state API doesn't make easy.
- **#21** (NAT-PMP TTL renewal) — fix tunes renewal cadence to `min(renewal_interval, mapping.ttl/2)`. A deterministic test would require either a stubbed gateway returning short leases or a 30-minute-cadence test, both impractical in CI.
- **#22** (PortMapper short-retry) — adds 200ms retry on transient failure. Same testability constraint as #21.
- **#24** (reconcile tiebreak) — fix adds tier-2 (lex bytes) and tier-3 (sequence) tiebreakers. A direct test requires constructing two divergent chains with byte-identical payloads at the divergence point, which the chain builder API doesn't easily allow without major test scaffolding.
- **#28** (RedexFile Drop) — adds `Drop` that fires the shutdown notify. A deterministic test for "task exits within X" requires a tokio-test runtime hookup that the redex test harness doesn't currently expose.
- **#29** (`net_shutdown` spin_loop) — `yield_now`/`sleep` is observably the same as `spin_loop` to a passing test.
- **#34** (Drop Release vs SeqCst) — purely structural; `&mut self` exclusion makes the mismatch unobservable at runtime.
- **#36** (`hop_count` saturation log) — fix adds a tracing warning at saturation; testable only via tracing-subscriber capture.
- **#37** (dedupe at seq 0) — defended by the chain-validator backstop; the fix is structurally correct but the bug it closes was never observable end-to-end.
- **#41** (NAT-PMP `remove` reads response) — fix adds bounded recv with timeout warning. A test needs a stubbed gateway socket; the existing NAT-PMP suite doesn't have that scaffolding.
- **#43** (`write_causal_events` skips oversized) — fix changes the return type to surface skip count. A test would need to construct an event with a >4 GiB payload; allocating 4 GiB in CI is impractical.
- **#45** (FFI null-checks in `write_*_out`) — purely defensive; no current caller passes null, so a regression test is structural-only.
- **#53** (`set_policy` removed) — dead method removed; nothing left to call.
- **#54** (scaling monitor refresh interval) — would require a runtime policy-mutation API that doesn't exist yet.

## Critical

### 1. Standby promotion silently loses all daemon state
**File:** `adapter/net/compute/standby_group.rs:202-281`

`sync_standbys` only updates bookkeeping (`synced_through`, `buffered_since_sync`); it never restores snapshot bytes onto standby daemons. On `promote()`, the picked standby is still its initial default-constructed state, and `promote()` only replays `buffered_since_sync` (just cleared at the previous sync). Everything before the most recent sync is silently lost.

### 2. Partial dat-write leaves stranded bytes that corrupt later successful entries
**Files:** `adapter/net/redex/disk.rs:258-307` (write); `redex/file.rs:256-262, 392-404, …` (rollback)

`dat_file` is `O_APPEND`. When a multi-byte payload write fails partway, partial bytes commit to disk but `file.rs::append*` only rolls back `next_seq`/in-memory state — it never truncates dat. A retried append then lands *after* the stranded partial bytes, but its idx record points to the original offset. Recovery (`disk.rs:127-148`) only inspects the tail and misses the gap. Reads return `partial_P_old ++ truncated_P_new`. With Bug 3 below this is silent.

## High

### 3. No checksum verification on read or recovery
**Files:** `adapter/net/redex/file.rs:903-913` (`materialize`); `redex/disk.rs::open` recovery

The 28-bit xxh3 in `RedexEntry.flags_and_checksum` is computed at append (`file.rs:227, 292, 375, 445, 499, 568`) and **never** verified on read. Hides Bug 2 plus any bit-rot, FS bug, or external tampering.

### 4. Reliable retransmits guaranteed to be rejected by the cipher's replay window
**Files:** `adapter/net/reliability.rs:274-288` interacting with `adapter/net/mod.rs:540-555`

`on_send` stashes the **already-encrypted** packet bytes. Retransmits replay the same wire counter, so `update_rx_counter` rejects them as replays. NACK-driven recovery is functionally dead the first time it fires.

### 5. `process_packet` discards `on_receive`'s duplicate result, breaking exactly-once on reliable streams
**File:** `adapter/net/mod.rs:569-585`

`r.on_receive(seq)` returns `false` for a duplicate but the bool is dropped — events are queued anyway. Cipher replay window doesn't catch this (a re-encrypt produces a fresh counter).

### 6. `ReplicaGroup::scale_to` co-locates all new replicas on the same node
**File:** `adapter/net/compute/replica_group.rs:175-198`

`used_nodes` is built from current members and never updated inside the placement loop, so `place_with_spread` returns the same first non-excluded node every iteration. Compare to `fork_group.rs:188-222` which correctly inserts after each placement.

### 7. Failed re-placement permanently degrades fork/replica/standby groups
**Files:** `adapter/net/compute/fork_group.rs:241-308`, `replica_group.rs:231-262`, `standby_group.rs:317-344`

`on_node_failure` calls `coord.mark_unhealthy` and `registry.unregister` *before* `place_with_spread` / `registry.register`. If the latter fail, the loop `continue`s without restoring state — the slot is now permanently unhealthy and missing from the registry, so `on_node_recovery` can't fix it.

### 8. SafetyEnforcer resource limits are advisory under concurrency (TOCTOU)
**File:** `adapter/net/behavior/safety.rs:872-924, 1090-1147`

Check-then-add: `concurrent + claim > max` is checked, then `fetch_add` happens. N concurrent acquirers all observe `current=0` and all proceed past the cap. Same pattern in `check_global_rpm` / `check_source_rpm` / `check_tokens`. The kill-switch / safety envelope is breakable under load. Should mirror the `fetch_update` pattern at `loadbalance.rs:304-320`.

### 9. JetStream message-id collision on producer restart causes silent dedup loss
**File:** `adapter/jetstream.rs:270`

`Nats-Msg-Id` = `{shard_id}:{batch.sequence_start}:{i}`. `BatchWorker::next_sequence` resets to 0 on restart (`shard/batch.rs:163`). If the prior process published `shard:0:0…` within JetStream's dedup window (default 2 min), the new process's first batches publish identical msg-ids and JetStream silently discards them. Needs a per-process nonce.

### 10. `SubprotocolManifest::from_bytes` accepts trailing garbage and duplicate IDs (downgrade vector)
**File:** `adapter/net/subprotocol/negotiation.rs:73-95`

Reads `count*6` bytes and returns `Some(...)` without verifying the cursor is empty, and never de-dups `id`. A peer can advertise both `causal v1.0` and `causal v0.1`; whichever ends up in `remote_by_id` last wins, enabling downgrade. Compare with `IdentityEnvelope::from_bytes:353` and `PermissionToken::from_bytes:349` which both enforce strict-length.

### 46. `add_shard_internal` publishes a shard before its drain worker exists
**File:** `bus.rs:290-334`

Order is `shard_manager.add_shard()` → spawn batch worker → register sender → spawn drain worker. The first call atomically registers the new shard with `ShardMapper` as `Active` *and* installs it in the routing table, so `select_shard` / `ingest` can hash to `new_id` immediately. Until the drain worker spawns, nothing reads from that ring buffer. Under high ingest pressure (default `ring_buffer_capacity = 1<<20` is fine; the test path uses 1024) the buffer can fill and start dropping events through `BackpressureMode` before the consumer task even exists. Fix: spawn the drain worker first, register the sender, *then* publish the shard to the routing table (or hold a "not-routable yet" flag until both workers are alive).

### 47. `remove_shard_internal` strands every event still in the ring buffer
**File:** `bus.rs:337-357`

Removes the manager-side sender, drops `JoinHandle`s (tasks keep running), then unmaps the shard from the routing table. The drain worker's loop calls `shard_manager.with_shard(shard_id, ...)` (`bus.rs:1047`); once the shard is unmapped this returns `None` and the worker exits at the next iteration — without making one final `pop_batch_into` pass on the ring buffer it still owns through the shard's `Arc`. Any events queued at that moment are permanently stranded. The healthy `Draining → finalize_draining (only when fill_ratio == 0)` path is safe, but `remove_shard_internal` is also reachable as the `on_shard_removed` callback (`mapper.rs:786-790`) and that callback fires immediately when finalize succeeds — same code path, but no global guarantee the buffer is observably empty (a producer can still race in via #46 if ordering changes, or via the draining-shard fallback in #51).

### 48. `ShardManager::drain_shard` is a no-op for routing
**File:** `shard/mod.rs:744-755`

```rust
pub fn drain_shard(&self, shard_id: u16) -> Result<(), ScalingError> {
    if let Some(collector) = mapper.metrics_collector(shard_id) {
        collector.set_draining(true);
    }
    Ok(())
}
```

This flips the metrics-collector's `draining` atomic but never transitions the corresponding `MappedShard.state` from `Active` to `Draining`. Consequently `ShardMapper::select_shard` (`mapper.rs:441`) still picks the shard for new producers (it filters on `state == Active`), and `evaluate_scaling` (`mapper.rs:508-527`) likewise filters on `state` rather than `last_metrics.draining`, so the "drained" shard is still counted toward overload/underutilization. The scaling monitor mitigates by also calling `mapper.scale_down(...)` immediately *before* `drain_shard` (`bus.rs:794-797`), but `drain_shard` is `pub` — any direct caller gets no draining behavior whatsoever. Either delete the method or have it call `mapper.scale_down_specific(shard_id)`.

### 49. `ShardMapper::finalize_draining` invokes the user callback while holding `shards.write()`
**File:** `shard/mapper.rs:745-792`

```rust
let mut shards = self.shards.write();
// …populate `stopped`…
if !stopped.is_empty() {
    if let Some(callback) = self.on_shard_removed.read().as_ref() {
        for &id in &stopped { callback(id); }   // write lock still held
    }
}
```

`on_shard_removed` is user-supplied (`pub fn set_on_shard_removed<F>`). If the callback re-enters the mapper (`shard_state`, `select_shard`, `metrics_collector`, `active_shard_ids`, …) it tries to acquire `shards.read()` while the same thread holds `shards.write()` — `parking_lot::RwLock` is not recursive and **deadlocks**. The matching `scale_up` path (`mapper.rs:653-662`) already drops the lock before notifying; mirror that here: collect `stopped` while locked, drop the lock, then notify.

## Medium

### 11. Unauthenticated heartbeats are trivially spoofable
**Files:** `adapter/net/pool.rs:238-246` (`build_heartbeat`); `adapter/net/mod.rs:530-537` (accept)

`build_heartbeat` emits a 64-byte header with no AEAD tag. Receiver only checks `source == peer_addr` (UDP source — spoofable) and `session_id` match. Off-path attacker who guesses or observes the 64-bit `session_id` can call `session.touch()` indefinitely, defeating both idle and failure-detector timeouts.

### 12. `apply_authoritative_grant` lets a peer permanently lock out future grants
**File:** `adapter/net/session.rs:1007-1049`

`max_consumed_seen` is advanced to whatever the peer claims (CAS only checks `total_consumed > prev`). The `.min(window)` clamp on credits doesn't bound `max_consumed_seen`. After a single grant with `total_consumed = u64::MAX`, every subsequent legitimate grant trips `if total_consumed <= prev { return; }` and is dropped — stream stalls forever. Should clamp `total_consumed.min(tx_bytes_sent.load(Acquire))` before CAS.

### 13. `BatchedTransport::send_batch` returns partial sendmmsg as success without re-queueing
**File:** `adapter/net/linux.rs:144-158`

Returns `Ok(sent as usize)`; no caller in this subsystem checks `sent == count`. Packets `[sent..count)` are silently lost. For reliable streams `on_send` was already called, so they sit in `pending` "in flight" without ever reaching the wire.

### 14. `FailureDetector` callbacks fire while holding a DashMap shard lock
**File:** `adapter/net/failure.rs:188-202, 209-226`

`heartbeat()`'s `entry().and_modify(|state| { … cb(node_id) … })` runs the user-supplied recovery/failure callback under the shard's write lock. `MeshNode` wires `ReroutePolicy` callbacks here that touch `routing_table` and `peer_addrs`. Any callback recursion into `heartbeat`/`check_all` or cross-DashMap dependency deadlocks. Even without deadlock, every concurrent heartbeat hashing to the same shard stalls.

### 15. Handshake responder accepts whichever source emits msg1 first
**Files:** `adapter/net/mod.rs:438-491`; `mesh.rs:6147-6208`

Loop is `recv_from; if is_handshake { return source }` — no per-source pacing, no expectation of which source should be replied to. An attacker who knows the PSK (PSKs are typically multi-tenant) can race the legitimate initiator's msg1; even without the PSK an attacker can flood handshake-flagged datagrams to monopolize the recv loop.

### 16. `flush()` waits a fixed `batch.max_delay` after ring buffers drain — too short for the rest of the pipeline
**File:** `bus.rs:548-552`

After `all_shards_empty()` returns true, sleeps `max_delay` (default 10ms) then `adapter.flush()`. Events may still be in transit through the per-shard mpsc channel (cap 1024), the batch worker's pending batch, or the in-progress `adapter.on_batch` (bounded only by `adapter_timeout`, default 30s). Callers using `flush()` as a delivery barrier (the doc at lines 524-527 advertises exactly that) can miss events.

### 17. Per-worker `BatchWorker.next_sequence` resets on restart, breaking documented "idempotent retry" guarantee
**Files:** `shard/batch.rs:147,163,219-225`; doc at `event.rs:288-291`

Adapters that persist `(shard_id, sequence_start)` for dedup get false-positive duplicates after process restart. Same root cause as #9 in JetStream.

### 18. `ffi::c_str_to_str` returns `Option<&'a str>` with an unconstrained lifetime
**File:** `ffi/mesh.rs:160-165`

`'a` is unbound by any input — caller can pick `'static` and produce a dangling reference. The `cortex.rs::c_str_to_owned` side already migrated to `Option<String>` to dodge this exact footgun (with a docstring calling it out); the `mesh.rs` side wasn't migrated. Latent — current call sites only use the borrow within the FFI call.

### 19. `net_mesh_send` family doesn't verify stream/node handle match
**File:** `ffi/mesh.rs:1120-1209`

All variants take both `MeshStreamHandle` and `MeshNodeHandle` and use the latter to send, without verifying the stream belongs to that node. A C caller juggling two `MeshNode`s gets silent cross-session traffic. `Arc::ptr_eq(&sh._node, &nh.inner)` would catch it in O(1).

### 20. UPnP install assumes external port == internal port; misses gateway auto-rewrites
**File:** `adapter/net/traversal/portmap/upnp.rs:189`

Some IGD implementations silently re-map `NewExternalPort` to a free port and return success. The returned `PortMapping` then carries the wrong external port. Should use `add_any_port` (which returns the actually-mapped port).

### 21. NAT-PMP renewal cadence ignores granted TTL
**Files:** `adapter/net/traversal/portmap/mod.rs:573` + `natpmp.rs:506`

`install` reads granted TTL into `PortMapping.ttl` but the renewal task uses fixed `renewal_interval` (default 30 min). A gateway that grants 60s leases (some hotel routers) leaves the mesh advertising a dead address ~29 minutes per cycle. Should be `min(renewal_interval, mapping.ttl / 2)`.

### 22. PortMapper renewal has no short-retry on transient failure
**File:** `adapter/net/traversal/portmap/mod.rs:583-600`

On `Err`, `consecutive_failures += 1` but the next retry waits the full 30-min ticker. With a 60-min granted TTL and 3-failure threshold, the mesh advertises a dead address for ~90 min before revoking.

### 23. Default `MeshDaemon::restore` accepts arbitrary state for stateless daemons
**File:** `adapter/net/compute/daemon.rs:55-57`

`fn restore(&mut self, _state: Bytes) -> Result<(), DaemonError> { Ok(()) }`. Migration into a stateless target silently discards the entire serialized state with no signal. Trait should expose `is_stateful()` so the migration path can refuse mismatch.

### 24. Reconcile tiebreak makes both partition sides pick themselves on payload-hash equality
**File:** `adapter/net/contested/reconcile.rs:165-172`

`if our_hash <= their_hash { Side::Ours } else { Side::Theirs }`. On equality (true xxh3 collision OR identical payloads at divergence — easy under idempotent retries) both sides take `Side::Ours`. Partitions don't converge. Need a second tier (lexicographic on payload bytes, or hash with side marker).

### 25. Snapshot restore is unusable: `EntityLog::from_snapshot` requires `head_payload` not in `StateSnapshot`
**Files:** `adapter/net/state/log.rs:45-60`; `adapter/net/state/snapshot.rs:43-77, 159-174`

`StateSnapshot` has `chain_link` but not the head event's payload bytes. `validate_chain_link` for the next event needs `prev_payload` (`causal.rs:107-115`). Migration/catchup over a snapshot alone can't validate any subsequent event.

### 26. Recovered entries get `now()` as timestamp, defeating age-based retention across restart
**File:** `adapter/net/redex/file.rs:130-134`

After restart, every recovered entry is timestamped "now" — none are eligible for age-eviction for a full retention window. A 1-hour retention on a process restarted every 30 minutes never evicts anything. Compliance bypass.

### 27. `sweep_retention` mutates memory only; idx + dat grow unbounded
**File:** `adapter/net/redex/file.rs:773-813`

Updates `state.index/timestamps/segment` but never touches `disk`. On reopen, the full dat is replayed and entries the previous generation evicted are resurrected. `retention_max_bytes` and `retention_max_age_ns` violated across restart.

### 28. `FsyncPolicy::Interval` background task leaks if the file is dropped without `close()`
**File:** `adapter/net/redex/file.rs:144-174, 821-856`

The interval-fsync task holds `Arc<DiskSegment>` + a `task_shutdown: Arc<Notify>`. The notify only fires inside `close()`. No `Drop` impl on `RedexFileInner` mirrors `redex/index.rs:182-192`, which does have one.

### 29. `net_shutdown` busy-spins for up to 5s with `spin_loop()`
**File:** `ffi/mod.rs:918-933`

`spin_loop()` doesn't yield. On a single-threaded executor this can starve the very tokio worker that needs to make progress on the in-flight flush, *causing* the deadline to expire when it otherwise wouldn't, then leaks bus + runtime.

### 44. `IngestionError::Backpressure` masks "shard not in routing table"
**Files:** `shard/mod.rs:534-537` (`ingest`); `shard/mod.rs:553-556` (`ingest_raw`); compare `shard/mod.rs:586-605` (batch path)

Single-event ingest collapses `resolve_idx → None` (concurrent scale-down removed the chosen shard from the index) into `IngestionError::Backpressure`. The batch path correctly distinguishes this case: it bumps `events_unrouted` and continues — the comment at lines 587-591 declares `events_unrouted` is exactly what bus-vs-shard reconciliation depends on. As a result (a) reconciliation drifts during scale-down because the per-event paths never increment the counter; (b) callers that back-off-and-retry on `Backpressure` apply the wrong remediation (the issue is routing, not buffer fullness); (c) the secondary `table.shards.get(idx).ok_or(Backpressure)?` masks a structural invariant break — by the `shard_index ↔ shards` invariant this `get` should never return `None`. Add an `IngestionError::Unrouted` variant, route both single-event paths to increment `events_unrouted`, and replace the second `ok_or` with `unreachable!()` or a distinct error.

### 50. `PollMerger::poll` returns `has_more=true, next_id=None` → caller infinite-loops
**File:** `consumer/merge.rs:423-436`

```rust
let cursor_advanced = final_cursor.positions != cursor.positions;
let all_filtered = request.filter.is_some() && all_events.is_empty() && cursor_advanced;
let has_more = any_has_more || had_extra || all_filtered;
let next_id = if !all_events.is_empty() || cursor_advanced {
    Some(final_cursor.encode())
} else { None };
```

If any adapter returns `ShardPollResult { events: [], next_id: None, has_more: true }` (legal under the trait contract — nothing forbids it), then `any_has_more = true` → `has_more = true`, but `all_events.is_empty() && !cursor_advanced` → `next_id = None`. The caller observes `(has_more: true, next_id: None)` and re-polls from the same cursor (`None`), getting the same response indefinitely. Either suppress `has_more` when no progress was made, or refuse to forward an adapter's `has_more=true` that arrives with neither events nor a cursor advance (warn-and-clear).

### 51. `select_shard` falls back to a `Draining` shard when no `Active` shard exists
**File:** `shard/mapper.rs:444-451`

```rust
if active.is_empty() {
    return shards.iter()
        .find(|s| s.state != ShardState::Stopped)   // matches Draining!
        .map(|s| s.id).unwrap_or(0);
}
```

Pushing into a draining shard increments `pushes_since_drain_start` (`mapper.rs:160-168`), which is the exact sentinel `finalize_draining` checks (`mapper.rs:769-781`) to decide a shard is *not* finalizable. Result: every push restarts the "no pushes since drain" clock, the shard never finalizes, and the system gets stuck routing to a draining target. Today `scale_down` enforces `min_shards >= 1` so the natural path can't reach this state — but the `drain_shard` no-op (#48) and any future direct `MappedShard.state` mutation can. Better to return `Err`/`None` than silently route to a draining shard.

### 52. `merge.rs` sort + truncate is non-deterministic at the boundary
**File:** `consumer/merge.rs:339-360`

```rust
all_events.sort_by_key(|e| e.insertion_ts);   // stable sort
// …
all_events.truncate(request.limit);
```

`insertion_ts` is monotonic *per shard*, not globally (`event.rs:233` documents this explicitly). Two shards can independently produce the same `insertion_ts`. `sort_by_key` is stable, but the input order across shards comes from `futures::future::join_all` (`merge.rs:260`), whose ordering of completed futures is implementation-defined. So at the truncation boundary — between events with equal `insertion_ts` — which shard's event "wins" varies poll to poll. Combined with the cursor-rollback step at `merge.rs:386-408` (which only fires for shards whose match-count shrank), the same logical event can be returned twice (different polls keep different boundary events) or skipped entirely. Stable secondary key on `(insertion_ts, shard_id, id)` would resolve it.

## Low

### 30. `BatchedPacketReceiver::new` mutates shared socket fd's `SO_RCVTIMEO`
`adapter/net/transport.rs:307-319` — `SO_RCVTIMEO` is a per-fd attribute; the `Arc<UdpSocket>` is also used by sync handshake recv at `mod.rs:396`/`mesh.rs:6098-6101`, which now inherits a 500ms timeout.

### 31. `FairScheduler::dequeue` advances `round_robin_idx` on every call regardless of pop
`adapter/net/router.rs:216-287` — rotation becomes a function of poll frequency, not service events; combined with stale `keys` snapshot, biases service away from streams that became non-empty mid-pass.

### 32. `scale_up(0)` bumps cooldown and spuriously fails at `u16::MAX`
`shard/mapper.rs:553-651` — should short-circuit `count == 0`.

### 33. `set_draining` reset of `pushes_since_drain_start` not synchronized with `draining` flag
`shard/mapper.rs:184-190` — concurrent `record_push` straddling the transition can leave the counter at 1 instead of 0; delays finalization by one metrics tick.

### 34. `Drop` for `EventBus` uses `Release` while `shutdown()`/`try_enter_ingest` use `SeqCst`
`bus.rs:703, 711-712` — sound today via `&mut self` exclusion; latent footgun if concurrency model evolves.

### 35. `evict_oldest` is `pub(crate)` on RingBuffer with no consumer-thread guard
`shard/ring_buffer.rs:204-216`, `shard/mod.rs:232-234` — SPSC contract upheld only by the single legitimate caller; no debug-build assertion to catch a future second one.

### 36. `RoutingHeader::forward` saturating-adds `hop_count`
`adapter/net/route.rs:230-239`; `swarm.rs:115-124` — at saturation, indirect-route metric (`hop_count + 2`) undercounts true distance, biasing best-route selection on long paths. TTL still bounds loops correctly.

### 37. `EntityLog::append` skips dedupe when `current_seq == 0`
`adapter/net/state/log.rs:78` — chain validator backstops it today, but the defense is structural-incidental, not direct.

### 38. `EnhancedPingwave::from_bytes` coerces unknown HealthStatus to `Unknown`
`adapter/net/behavior/proximity.rs:191-196` — flipping a single byte downgrades a peer to Unknown (`can_receive_traffic() = false`). Mostly mitigated if pingwaves are authenticated upstream.

### 39. `LoadBalancer::select` round-robin retry can advance counter up to 4× per logical select
`adapter/net/behavior/loadbalance.rs:633-668` — distorts observed RR sequence; weighted-RR tests indirectly assume 1:1.

### 40. `StateSnapshot::from_bytes_v0` accepts trailing garbage
`adapter/net/state/snapshot.rs:322-371` — v1 path enforces strict-length; v0 doesn't.

### 41. NAT-PMP `remove()` doesn't read response
`adapter/net/traversal/portmap/natpmp.rs:523-556` — UDP delivery doesn't imply server-side processing; some routers refuse `lifetime=0` and the client never knows.

### 42. `ForkRecord::from_bytes` doesn't auto-`verify()`
`adapter/net/continuity/discontinuity.rs:191-214` — caller responsibility, but a footgun (no doc warning at the deserializer site).

### 43. `write_causal_events` silently skips events larger than `u32::MAX − 24`
`adapter/net/state/causal.rs:242-261` — log + `continue`; framing layer's count then mismatches actual events. Bounded by 4 GiB practical ceiling.

### 45. `write_json_out` / `write_string_out` deref `out_ptr` and `out_len` without null check
`ffi/mesh.rs:167-184, 186-196`; `ffi/cortex.rs:100-117` — three FFI helpers accept `*mut *mut c_char` and `*mut usize` and unconditionally write through both, but are declared `fn` (not `unsafe fn`), advertising a safe-to-call signature with unstated raw-pointer preconditions. All current call sites null-check before invoking, so unreachable today; latent footgun for any future caller — the cleaner-migrated `cortex.rs::c_str_to_owned` (cf. #18) shows the project is already moving these helpers toward sound signatures. Add an internal `if out_ptr.is_null() || out_len.is_null() { return NetError::NullPointer.into(); }` to all three.

### 53. `ShardMapper::set_policy` requires `&mut self` but the mapper is always shared via `Arc`
`shard/mapper.rs:855-862` — In production `ShardMapper` lives behind `Arc<ShardMapper>` (see `ShardManager::with_mapper`, `bus.rs:184-200`). `Arc::get_mut` requires the strong count to be 1, which is never the case once any worker (or the manager itself) has cloned the `Arc`. The method is effectively unreachable from any real caller. Either move `policy` behind interior mutability (the file already uses `RwLock` extensively) or remove the method.

### 54. Scaling monitor caches `metrics_window` once at task start
`bus.rs:743-750` — `let interval = match weak.upgrade() { Some(bus) => match &bus.config.scaling { ... p.metrics_window, ... } };` is read outside the loop; subsequent ticks `tokio::time::sleep(interval)` use the cached value forever. Dormant today (`EventBusConfig` is owned by the bus and not mutated), but a latent footgun if the project grows runtime policy updates — the monitor would not adopt new cadences without a process restart.

---

## Top priorities to fix first

1. **#1** — silent state loss on standby promotion
2. **#2 + #3** — silent on-disk corruption with no checksum check
3. **#4 + #5** — reliable streams broken (retransmits rejected, duplicates delivered)
4. **#8** — safety envelope is advisory under load
5. **#10** — subprotocol downgrade vector
6. **#46 + #47** — dynamic shard add/remove leaks events out of the bus's "no loss between shutdown calls" contract
7. **#48** — `drain_shard` doesn't drain (silent footgun for any direct caller)
8. **#49** — `finalize_draining` deadlocks on user callback re-entry

Each has a clear concrete trigger and a small fix.
