# epics-pva-rs Source Review — 2026-05-26

Scope:

- Crate: `crates/epics-pva-rs`
- Upstream reference: pvxs C++ at `/Users/stevek/codes/pvxs/src` (read-only)
- Areas reviewed: server op lifecycle (GET/PUT/RPC/MONITOR/PROCESS INIT and data-phase error
  handling), CONNECTION_VALIDATION buffer-size field, server-side type registry on data-phase
  frame decode.
- Finding-ID series: `R-N` (the global parity-round series; this document records the
  epics-pva-rs slice — R60–R63 here). IDs are globally unique by prefix and never reused;
  see `docs/review-tagging-conventions.md`.

References:

- pvxs `serverget.cpp`: GET/PUT/RPC INIT and data-phase dispatch
- pvxs `servermon.cpp`: MONITOR INIT and data-phase dispatch
- pvxs `serverconn.cpp`: server → client CONNECTION_VALIDATION message
- pvxs `clientconn.cpp`: client → server CONNECTION_VALIDATION reply

Method:

Line-by-line comparison of server op-lifecycle error paths, handshake message fields, and
data-phase frame handling against the upstream C++ reference. Evidence bar: both a Rust
`path:line` and a C++ `path:line` required before classifying a finding as high-confidence.

## Findings

### R60 — INIT with unknown SID is not connection-fatal

Severity: Medium

Status: Fixed

Evidence:

- Rust site: `src/server_native/tcp.rs:3733-3740` — `handle_op` performs the SID lookup before
  checking the `INIT` bit (`subcmd & 0x08`); on unknown SID the current code calls
  `send_op_error(...)` and returns `Ok(())`, keeping the connection alive.
- C++ site: `pvxs/src/serverget.cpp:378-384` (GET/PUT/RPC) and
  `pvxs/src/servermon.cpp:505-511` (MONITOR) — inside the `if(subcmd & 0x08)` (INIT) branch,
  `lookupSID(sid)` returns a null reference on unknown SID; pvxs treats this as a protocol
  error and calls `bev.reset()` (connection-fatal), then returns.

Impact:

A buggy or malicious client can send GET/PUT/RPC/MONITOR INIT frames on a SID it never
received from the server. pvxs closes the TCP connection; our server sends an error reply and
remains connected. The practical effect is that protocol violations are not detected as such —
the peer can keep the connection open indefinitely by sending INITs on garbage SIDs.

Fix direction:

When the subcmd has the INIT bit set and the SID is unknown, propagate a
`PvaError::Decode(...)` so the connection-owner loop closes the connection (mirrors
`bev.reset()`). When the INIT bit is clear (data-phase), silently drop the frame (see
R61 for that half of the fix).

### R61 — Data-phase EXEC on unknown IOID sends error reply instead of silently dropping

Severity: Low-Medium

Status: Fixed

Evidence:

- Rust site: `src/server_native/tcp.rs:4071-4074` — the `None` arm in the `handle_op`
  data-phase IOID lookup calls `send_op_error(...)` and returns `Ok(())`. The same pattern
  appears in the PROCESS data-phase handler at `src/server_native/tcp.rs:3174-3186`.
- C++ site: `pvxs/src/serverget.cpp:423-428` (GET/PUT/RPC data phase) and
  `pvxs/src/servermon.cpp:611-619` (MONITOR non-INIT) — when `opByIOID.find(ioid)` returns
  `end()` or the op is `Dead`, pvxs sets `rxRegistryDirty = true` and returns silently (no
  reply). The comment at `:613-615` explains the design intent: "since server destroy commands
  aren't acknowledged, we can race with traffic sent by the client before processing our
  destroy. so we can't fault hard, so just ignore."

Impact:

A client can race a `DESTROY_REQUEST` with an in-flight EXEC frame. In pvxs, the server's
handler silently drops the stale frame. Our server sends an unexpected error reply on the
already-destroyed IOID, which the client may not be prepared to route. In well-behaved clients
this is harmless (the stale reply will be ignored), but it is a wire-level divergence and
could confuse strict client implementations.

Fix direction:

Change the `None` arm to a silent drop (`return Ok(())` with no `send_op_error`), matching
pvxs's intent. Also: for unknown SID in the data-phase (R60's `None` arm before the
subcmd split), silently drop as well.

### R62 — CONNECTION_VALIDATION `serverReceiveBufferSize` is 87 040 instead of 0x10000

Severity: Low

Status: Fixed

Evidence:

- Rust server site: `src/server_native/tcp.rs:1991` —
  `build_server_connection_validation(order, 87_040, 32_767, ...)`. The constant 87 040 is
  also the value of `DEFAULT_BUFFER_SIZE` in `src/client_native/server_conn.rs:806`, used by
  the client in its `CONNECTION_VALIDATION` reply.
- C++ server site: `pvxs/src/serverconn.cpp:103-104` — `to_wire(M, uint32_t(0x10000))`, with
  comment "serverReceiveBufferSize, not used".
- C++ client site: `pvxs/src/clientconn.cpp:292-293` — `to_wire(R, uint32_t(0x10000))`, with
  comment "serverReceiveBufferSize, not used".

Impact:

The `serverReceiveBufferSize` field is annotated "not used" in pvxs and is skipped by both
sides on decode (`M.skip(4u + 2u, ...)` in `serverconn.cpp:226` on the server receive side;
the field is read but unused on the client receive side per `decode_connection_validation` in
our code). Zero runtime effect. Wire-level divergence is visible in packet captures and may
confuse tooling that infers buffer capacity from this field.

Fix direction:

Change the hard-coded `87_040` to `0x10000` (65536) in both the server builder call and the
client's `DEFAULT_BUFFER_SIZE` constant, matching pvxs.

### R63 — `QosFlags::MONITOR_START` and `MONITOR_STOP` hold wrong subcmd values

Severity: Low (unused in production paths)

Status: Fixed

Evidence:

- Rust site: `src/proto/command.rs:113-115` — `MONITOR_START = 0x40` and `MONITOR_STOP = 0x80`.
  The correct protocol values are `START = 0x44` (`0x04 | 0x40`) and `STOP = 0x04`.
  Additionally `MONITOR_STOP = 0x80` aliases `PIPELINE_ACK = 0x80`, making both names refer to
  different protocol concepts with identical bit patterns.
- C++ site: `pvxs/src/clientmon.cpp:127` — `subcmd = p ? 0x04 : 0x44; // STOP | START`.
  `p` is the pause flag: pause → `0x04` (STOP), resume → `0x44` (START).
- C++ site: `pvxs/src/servermon.cpp:671-675` — `if(subcmd & 0x04) { bool start = subcmd & 0x40; }`
  confirms STOP = `0x04`, START = `0x04 | 0x40 = 0x44`.

Impact:

`MONITOR_START` and `MONITOR_STOP` are not used in any wire-building production code
(`codec.rs` uses hard-coded `0x44` and `0x04` directly). However, the constants are public
API; any caller relying on them to build monitor control frames would produce malformed
subcmds. The `MONITOR_STOP = 0x80` alias with `PIPELINE_ACK` is a silent semantic collision.

Fix direction:

Correct `MONITOR_START = 0x44` and `MONITOR_STOP = 0x04`. Also update the doc-comment for
`MONITOR_START` to note it combines the control bit (`0x04`) with the GET bit (`0x40`).

### R64 — RPC client INIT sends type-only; pvxs server expects type + full value

Severity: High (all Rust client RPC against a pvxs server)

Status: Fixed

Evidence:

- Rust site: `src/client_native/ops_v2.rs:2379-2390` — `op_rpc` builds the INIT payload as
  `encode_type_desc(request_desc)` only, omitting the full value. The comment at
  `src/server_native/tcp.rs:2582-2590` explicitly acknowledges "the Rust client's RPC INIT
  sends only the descriptor — tolerated for interop."
- C++ site: `pvxs/src/serverget.cpp:366-376` — INIT branch calls
  `from_wire_type_value(M, rxRegistry, pvRequest)` which reads type **and** full value, then
  `if(!M.good()) { bev.reset(); return; }`. If any non-null type is present, pvxs expects
  value bytes to follow; absent value bytes leave M bad → connection-fatal.
- C++ client: `pvxs/src/clientget.cpp:348-352` (`createOp`) — sends
  `to_wire(R, Value::Helper::desc(pvRequest)); to_wire_full(R, pvRequest);` at INIT.

Impact:

Any Rust PVA client RPC operation targeting a real pvxs server is immediately connection-fatal
after the RPC INIT frame when the argument type is non-null (i.e. any real RPC argument).
The Rust server tolerates type-only via `decode_init_pv_request_value`'s absent-body path,
so Rust↔Rust interop works; cross-server interop (Rust client → pvxs server) does not.

Fix direction:

Append the full value encoding after the type descriptor in the RPC INIT payload:
`encode_pv_field(request_value, request_desc, order, &mut pv_req)`.

### R65 — GET_FIELD slow-path spawn not cancelled on connection teardown

Severity: Low-Medium

Status: Fixed

Evidence:

- Rust site: `src/server_native/tcp.rs:5574` — the GET_FIELD slow-path `tokio::spawn` stores no
  abort handle. If `src.get_introspection_checked()` hangs on a slow or unresponsive source,
  the task outlives the connection: it only exits when it eventually reaches
  `tx_clone.send(buf).await` and that send fails because the writer mpsc is closed. Every
  sibling task in this file (monitor subscriber via `monitor_abort: Option<Arc<AbortOnDrop>>`,
  data-phase tasks via `data_task_abort: Option<Arc<AbortOnDrop>>`) is tied to a per-connection
  abort chain; the GET_FIELD slow path bypasses it.
- C++ site: `pvxs/src/serverintrospect.cpp:63-68` — `ServerIntrospect` is registered in both
  `conn->opByIOID` and `chan->opByIOID`. On connection teardown,
  `ServerConn::cleanup()` (`serverconn.cpp:366-382`) iterates `opByIOID` and calls
  `op->cleanup()` on every entry, which fires `ServerIntrospectControl::error("Implicit Cancel")`
  — the source's `get_introspection` future receives an explicit cancellation signal and is
  expected to abort promptly. The Rust slow path leaves the source's future running until the
  source itself completes.

Impact:

If a `ChannelSource::get_introspection_checked()` implementation awaits an upstream connection
or network round-trip (e.g., a PVA gateway proxy), a client GET_FIELD followed by a disconnect
leaves one detached task per in-flight introspect alive until the upstream responds. Under a
slow/offline upstream the tasks can accumulate indefinitely.

Fix direction:

Pass the connection's cancellation token into the spawned task and `tokio::select!` between the
introspect future and the token's cancellation. This mirrors the abort-handle pattern used by
monitor and data-phase tasks, and matches pvxs's `opByIOID`-cleanup cancellation.

### R66 — `ChannelSource` trait lacks a per-channel `on_channel_close` hook

Severity: Low (feature gap, no state leak)

Status: Documented — fix deferred (breaking trait API change, needs sign-off)

Evidence:

- Rust site: `src/server_native/source.rs:174` — the `ChannelSource` trait has no
  `on_channel_close` (or equivalent) method. When a channel is destroyed (DESTROY_CHANNEL or
  connection close), the Rust server fires `notify_monitor_start(false)` via
  `MonitorStartControl::drop()` for each executing monitor op, but there is no channel-level
  teardown notification delivered to the source.
- C++ site: `pvxs/src/serverchan.cpp:57-59` — `ServerChan::cleanup()` calls `onClose("")` after
  cleaning up all ops. `ServerChannelControl::onClose()` (`:115-126`) lets a source register a
  per-channel teardown callback. pvxs uses this, for example, to notify `SharedPV` that a
  specific client channel is gone.

Impact:

A source that needs per-channel accounting (e.g., tracking which clients hold a channel open,
or releasing per-channel upstream connections) has no hook to act on channel teardown. The
existing `notify_monitor_start(false)` covers per-monitor-op teardown, but not the channel
level (a channel may have zero monitors and still benefit from an `on_channel_close` signal).
No state is leaked by the omission — RAII cleanup is correct — but sources requiring this
callback must poll or use out-of-band means.

Fix direction:

Add a default-no-op `on_channel_close(&self, name: &str, ctx: &ChannelContext)` method to
`ChannelSource`, called from `handle_destroy_channel` after `channels.remove(&sid)` and from
the connection teardown path. Default impl is empty so existing sources are unaffected; this
is still a semver-minor breaking change for object-safe vtable users (`ChannelSourceObj`).

### R67 — `BACKOFF_SECS` is dead code with a false "matches pvxs clientdiscover.cpp" claim

Severity: Low (doc inconsistency; no production runtime impact)

Status: Fixed (misleading doc corrected; constant retained as public API)

Evidence:

- Rust site: `src/client_native/search_engine.rs:44` —
  `pub const BACKOFF_SECS: &[u64] = &[1, 1, 2, 5, 10, 15, 30, 60, 120, 210];`
  with doc comment "matching pvxs `clientdiscover.cpp`". The constant is only referenced in the
  test `backoff_caps_at_last_value` (line 1928), which asserts the sequence is bounded. No
  production code path consults `BACKOFF_SECS`; retry scheduling is driven exclusively by the
  30-bucket ring mechanism in `run_engine`/`cascade_smoothed_next` (lines 713-732, 1300-1311).
  The module doc header (line 12) also states "pvxs-style backoff (15s → 30s → 60s → 120s →
  210s capped)", which is incorrect — the actual production cap is ~29 s (30-bucket ring at 1 s
  per tick, minus one rotation lag).
- C++ site: `pvxs/src/clientdiscover.cpp` — **no such sequence exists**. The values
  `[1, 1, 2, 5, 10, 15, 30, 60, 120, 210]` do not appear in any pvxs source file. The actual
  pvxs retry geometry is in `client.cpp:1118-1121`: `nSearch = min(nBuckets, nSearch+1u)`,
  placing the next retry at `(idx + nSearch) % 30`, giving linearly-growing waits of 1 s, 2 s,
  3 s, …, capping at 29 s (when nSearch reaches 30, next = idx, and the cursor takes 29 more
  ticks to return). The 210 s cap in `BACKOFF_SECS` has no pvxs equivalent.

Impact:

An external caller reading the public `BACKOFF_SECS` constant to predict channel search retry
timing would receive a sequence that caps at 210 s instead of the actual ~29 s, and would
attribute it to a file that contains no such values. The production search behaviour is correct;
only the documentation is misleading.

Fix direction:

Update the `BACKOFF_SECS` doc comment to clearly state it is not used by the engine and does
not correspond to pvxs. Update the module header to describe the actual bucket-ring geometry
(1 s tick × 30 buckets, cap ~29 s). Do not remove `BACKOFF_SECS` — it is `pub` and removal is
a semver-breaking change.

### R68 — x509 `authority` empty when peer sends partial TLS chain

Severity: Medium (functional parity gap; ACF `AUTHORITY(...)` rules fail)

Status: Fixed

Evidence:

- Rust site: `src/server_native/tcp.rs:1594` —
  `conn.peer_certificates().and_then(|chain| crate::auth::x509_credentials_from_chain(chain))`.
  `rustls::CommonState::peer_certificates()` returns **only the certificate chain the peer sent**
  (RFC 5246 §7.4.2 allows peers to omit the root CA since it is expected to be pre-installed on
  the verifying side). When the peer omits the root, `chain.last()` is the intermediate CA (or
  the leaf itself); `is_self_signed_ca` returns `false`; `authority` is left `""`.
  `ClientCredentials::authority` flows into ACF matching at
  `src/server_native/source.rs:295` (`check_with_roles`) and
  `src/server_native/composite.rs:168` (`check`).
- C++ site: `pvxs-tls/src/ossl.cpp:423-432` — pvxs calls
  `SSL_get0_verified_chain(ctx)` (not `SSL_get_peer_cert_chain`), which returns the **verified
  chain as built by OpenSSL's `X509_verify_cert`** — including root CAs fetched from the local
  trust store even when the peer did not send them. `sk_X509_value(chain, N-1)` is therefore
  reliably the root CA; `X509_check_ca(root) && (EXFLAG_SS)` passes; `authority = root_CN`.

Impact:

In a typical PKI the client sends `[leaf]` or `[leaf, intermediate]` without the root CA (the
root is already in the server trust store). pvxs always sets `authority = root_CA_CN` after a
successful TLS handshake; Rust leaves it empty. Any ACF rule of the form
`METHOD("x509") AUTHORITY("Root CA")` that works against a pvxs server would be silently denied
by the Rust server for every well-behaved client — a functional regression that is hard to
diagnose because the cert is accepted (TLS handshake passes) but access is denied.

Fix direction:

After extracting the leaf `account` from the peer chain, walk the server's `RootCertStore` trust
anchors: find the anchor whose `subject` DER matches the `issuer` DN of the last peer-chain cert.
Extract the CN from that anchor's subject as `authority`. Requires:
1. Adding `trust_roots: Arc<RootCertStore>` to `TlsServerConfig` (stored at config-build time
   when the trust store is constructed).
2. Threading the roots to the credential-extraction call at `tcp.rs:1594`.
3. A new internal helper `authority_from_trust_roots(chain, roots)` using `x509_parser` subject
   DN comparison and CN extraction.
The existing `pub fn x509_credentials_from_chain(chain)` signature is unchanged (it is public
API); the internal call site is changed to use the roots-aware path.

### R69 — PUT_GET INIT comment falsely attributes two-descriptor format to pvxs

Severity: Doc (comment cites pvxs for a protocol format pvxs never implements)

Status: Fixed

Evidence:

- Rust site: `src/server_native/tcp.rs:2863-2865` — comment reads
  "pvxs `serverget.cpp` emits two type descriptors for PUT_GET (the put-request and
  get-response structures)." pvxs `serverconn.cpp:259-260` shows `void ServerConn::handle_PUT_GET() {}`
  — an empty stub. pvxs implements GET/PUT/RPC via `ServerGPR` in `serverget.cpp`; it has no
  PUT_GET server implementation and never emits a PUT_GET INIT response.
- C++ site: `pvxs/src/serverconn.cpp:259-260` — `void ServerConn::handle_PUT_GET() {}`.
  Also `pvxs/src/serverget.cpp:19-158`: `ServerGPR` handles only `CMD_GET`, `CMD_PUT`, `CMD_RPC`;
  no PUT_GET handler. `CMD_PUT_GET=12` is defined in `pvaproto.h:628` but never dispatched
  beyond the empty stub.

Impact:

The comment misleads readers into thinking pvxs servers send PUT_GET INIT responses with two
descriptors. The actual format source is the pvAccessJava protocol specification; the Rust
implementation is correct — the comment is wrong. A reviewer trying to verify the two-descriptor
layout against pvxs would find `handle_PUT_GET()` empty and wrongly conclude the Rust code is
fabricating the format.

Fix direction:

Replace the pvxs cite with a reference to the pvAccessJava protocol wire format that defines
`PUT_GET INIT = ioid + subcmd + status + putIF + getIF`.

## EncodeTypeCache audit — Area A (R69 session)

Investigated: encode/decode round-trip for 0xFD/0xFE type-cache markers (Area A deferred from
prior session).

Outcome: No additional bugs found. Summary of verified-correct properties:

- `EncodeTypeCache` is connection-scoped (one instance at `handle_connection_io:2031`); passed
  `&mut` to all op handlers on the same connection. pvxs never emits 0xFD/0xFE (`to_wire`
  is inline only); Rust emits them when `config.emit_type_cache = true`. Parity gap is intentional
  (pvxs client accepts them via `from_wire` for pvAccessJava compat; Rust server mirrors that).
- Decode TypeCache is connection-scoped: `reader_type_cache` (local to the reader task,
  `server_conn.rs:362`) resolves 0xFD/0xFE in strict wire order via `flatten_type_cache_markers`
  before routing frames to per-op tasks. This structurally prevents 0xFE-before-0xFD races.
  pvxs `ConnBase::rxRegistry` is also per-connection (`conn.h:23`). Scoping matches.
- PUT_GET INIT double-emit (tcp.rs:2871-2872): first call emits `0xFD <slot N> <body>`;
  second call emits `0xFE <slot N>`. `flatten_type_cache_markers` expands both to inline
  before the client decoder sees them. `decode_put_get_init` (ops_v2.rs:2576-2579) decodes
  two inline descriptors correctly. Round-trip verified correct.
- R69 (doc-only fix): the comment attributing the two-descriptor format to pvxs is wrong.

### R70 — `anonymous` method sets `account = ""` instead of `"anonymous"`; `ca` with missing user falls back to `method="ca"/account=""` instead of anonymous

Severity: Medium (identity parity gap; ACF rules matching `USER("anonymous")` or `METHOD("ca")` without user constraint yield wrong decisions)

Status: Fixed

Evidence:

- Rust site: `src/server_native/tcp.rs:1781-1785` — `parse_client_credentials` returns
  `Ok(None)` only when `method.is_empty()`. For `method="anonymous"` (the string a pvxs client
  always sends — pvxs `clientconn.cpp:258`: `selected = "anonymous"; to_wire(R, selected)`) the
  early-return is NOT taken. The function continues, finds a `0xFF` null auth body (pvxs ca
  `clientconn.cpp:301-303`: `to_wire(R, Value::Helper::desc(cred))` where `cred` is empty →
  null type tag), hits the `0xFF` peek-branch at `tcp.rs:1803`, returns
  `Ok(Some({ method="anonymous", account="" }))`. Caller at `tcp.rs:2290` does `cred = claimed`
  → `cred.account` becomes `""` instead of `"anonymous"`.
- C++ site: `pvxs/src/serverconn.cpp:221-234` — for `selected="anonymous"` the
  `if(selected=="ca")` lambda is not entered, so `C->method` stays empty;
  `if(C->method.empty())` at line 229 sets `C->method = C->account = "anonymous"`.
  pvxs always produces `account="anonymous"` for the anonymous method.

Second aspect — `ca` with missing user field:

- Rust site: same `parse_client_credentials` — when `method="ca"` but the auth structure carries
  no `user` field, the extraction loop at `tcp.rs:1813-1836` leaves `creds.account = ""`. The
  caller sets `cred = { method="ca", account="" }`.
- C++ site: `pvxs/src/serverconn.cpp:223-231` — the `auth["user"].as<std::string>(lambda)`
  lambda is only called when the `user` field exists and converts to string; it is the lambda
  that sets `C->method = "ca"`. Without it, `C->method` stays empty →
  `if(C->method.empty())` triggers → `method="anonymous", account="anonymous"`.

Impact:

1. Anonymous: a normal pvxs client sends `method="anonymous"` with a null body. Rust produces
   `account=""`, pvxs produces `account="anonymous"`. Any ACF rule `USER("anonymous")` or
   `ACCOUNT("anonymous")` that works against a pvxs server fails silently against Rust because
   the account field doesn't match.
2. `ca` without user: a client that sends `method="ca"` but omits or empties the `user` field
   gets `method="ca", account=""` in Rust vs `method="anonymous", account="anonymous"` in pvxs.
   An ACF rule with `METHOD("ca")` and no user constraint grants the full `ca`-method privilege
   in Rust but only the anonymous default in pvxs — a privilege escalation vector.

Fix direction:

In `parse_client_credentials`:
1. Extend the early `Ok(None)` return to cover `method == "anonymous"` (matching the existing
   empty-method case), so the caller's pre-initialised `ClientCredentials::anonymous()`
   (account=`"anonymous"`) is preserved.
2. For `method == "ca"`, if after full auth-body decoding `creds.account` is still empty, return
   `Ok(None)` — the `ca`-method identity requires a non-empty user to be meaningful; without one
   pvxs falls back to anonymous.

### R71 — Monitor flow-control / pipeline credit accounting (parity-correct)

Severity: N/A — parity-correct

Status: No change

Evidence — credit ownership:

- Rust site: `tcp.rs:1182-1241` (`MonitorPipelineCredit::acquire()`): the ONLY site that
  decrements `monitor_window` (AtomicU32). CAS loop blocks when window==0, then atomically
  decrements. Called at `tcp.rs:5162` (initial snapshot) and `tcp.rs:5345` (each DATA event).
  No other path touches the counter downward.
- Rust ACK site: `tcp.rs:4537-4618` — the ONLY site that increments the window. On `is_ack =
  subcmd & 0x80`, reads u32 `nack` from frame, CAS saturating_add loop, then fires
  `notify_waiters()` when `prev==0` so blocked `acquire()` wakes.
- C++ sites: `pvxs/src/servermon.cpp:193` (`window--` in `doReply`, after `enqueueTxBody`),
  `servermon.cpp:652` (`op->window += nack` in ACK path). Same two-actor ownership.

Evidence — INIT / initial window:

- Rust site: `tcp.rs:3933-3948` — `pipeline_initial_nack` parsed when INIT subcmd has 0x80
  bit; stored in `monitor_window = Some(Arc<AtomicU32>)` initialized to `nack`.
- C++ site: `servermon.cpp:493-495` — `if(subcmd&0x80) from_wire(M, nack)`;
  `op->window = nack` at line 518.

Evidence — START / STOP:

- Rust site: `tcp.rs:4495-4496`: `is_pause = subcmd == 0x04`; `is_resume = subcmd & 0x40 != 0`.
- C++ site: `servermon.cpp:670-678`: `if(subcmd & 0x04)` (bit test); `bool start = subcmd & 0x40`.
- pvxs clients (`clientmon.cpp:127`): send `subcmd = 0x04` for STOP and `subcmd = 0x44` for
  START — the only two values that contain the 0x04 bit. For both, Rust produces the same
  result as the pvxs bit-test.
- Minor deviation: Rust uses exact-match (`== 0x04`) for STOP vs. pvxs bit-test (`& 0x04`). A
  hypothetical client sending `subcmd = 0x05` (unknown bit + stop bit) would be treated as STOP
  by pvxs but ignored by Rust. No conforming PVA client sends such a value; this is not a
  real-world bug.

Evidence — queue overflow / squash:

- Rust site: `tcp.rs:5230-5243` — after receiving the first event, drains remaining via
  `try_recv()`, coalescing via `coalesce_monitor_update` (keeps latest value, unions changed
  bitsets). During pause: `next_monitor_event` coalesces into `held` and flushes on resume
  (`tcp.rs:5704-5737`).
- C++ site: `servermon.cpp:280-285` — when `queue.size() >= limit && !maybe`, overwrites
  `queue.back()` (squash to the newest queued slot). Both implementations preserve the latest
  value and merge changed fields across overflows.

Evidence — LOW / HIGH watermarks:

- Rust site: `tcp.rs:1225-1238` — after `acquire()`, fires LOW (`WatermarkKind::Pause`) when
  `window_after <= lo` and crossing is freshly below (once per crossing via `cross_watermark`).
  `tcp.rs:4611-4644` — ACK path fires HIGH (`WatermarkKind::Resume`) when window crosses above
  `high`. Watermark levels clamped at INIT to `min(level, ack_at - 1)` (`tcp.rs:125-133`,
  matching `servermon.cpp:331-332`).
- C++ site: `servermon.cpp:195-207` (`onLowMark` when `window <= low`); `servermon.cpp:654-666`
  (`onHighMark` when `window > high` after ACK).

Evidence — DESTROY:

- Rust site: `tcp.rs:2501-2502` — MONITOR DESTROY handled via `Command::DestroyRequest` (a
  separate PVA command), not via subcmd bit 0x10 in the MONITOR message.
- C++ client site: `pvxs/src/clientmon.cpp:570` — "assume op has already sent
  CMD_DESTROY_REQUEST". pvxs clients use DESTROY_REQUEST, not subcmd 0x10.
- The subcmd 0x10 path in `servermon.cpp:690-707` exists but is not used by pvxs clients.
  Rust's DESTROY_REQUEST path covers the actual traffic.

Conclusion:

All six check points — credit decrement ownership, credit increment ownership, INIT window,
START/STOP, queue overflow, watermarks — are functionally equivalent to pvxs. No fix required.

### R72 — Unadvertised auth method: error + keep-alive + credential revert (parity-correct, documented)

Severity: N/A — parity-correct

Status: No change

Context:

pvxs `serverconn.cpp:238-241` rejects any `selected` method that is not exactly `"ca"` or
`"anonymous"` by calling `auth_complete(this, Status{Error,...})` and returning. There is no
`bev.reset()`, so the connection stays alive. pvxs also does NOT close the connection on this
path — the comment at lines 249-250 explains the design choice: `"No practical way to handle
auth failure. So we accept all credentials, but may not grant rights."`.

Evidence — pvxs identity state after rejection:

- C++ site: `serverconn.cpp:221-234` — a copy `C` of the current credential is built. For
  anything other than `selected=="ca"` with a `user` field, the "ca" lambda is NOT entered,
  so `C->method` stays empty. Line 229: `if(C->method.empty()) C->account = C->method =
  "anonymous"` runs for ALL non-ca-with-user selections including "x509". `cred = C` at
  line 234 stores this anonymous identity BEFORE the unadvertised check at line 238.
  Therefore `auth_complete(Status::Error)` at line 240 fires with the connection credential
  already set to `{method="anonymous", account="anonymous"}`.

Evidence — Rust identity state after rejection:

- Rust site: `tcp.rs:2320-2344` — after parse, `cred` may hold the client's claimed identity
  (e.g. `{method="x509", account="alice"}`). Before the advertised check, if the method is
  not in `ADVERTISED_AUTH_METHODS`, Rust explicitly executes `cred =
  ClientCredentials::anonymous()` (line 2344), then sends `Status::Error` in
  `CONNECTION_VALIDATED`, then calls `auth_complete` — which therefore sees
  `{method="anonymous", account="anonymous"}` exactly as pvxs does.

- Rationale documented in tcp.rs:2332-2344 (`EX-R7` comment): before this fix, the parsed
  claimed identity was installed into `cred` before the unadvertised check, so `auth_complete`
  and subsequent ACF-gated operations saw an identity the server had just rejected. The fix
  reverts `cred` to anonymous first, matching pvxs's implicit revert via the empty-method
  path.

Evidence — connection liveness:

- C++ site: `serverconn.cpp:238-241` — no `bev.reset()` call; handler returns, connection
  lives.
- Rust site: `tcp.rs:2358-2359` — frame sent, `handshake_complete = true`, loop continues.
  Both keep the connection open; subsequent operations see anonymous credentials.

Test coverage:

- `stability.rs:1339` (`auth_method_unadvertised_returns_status_error`): asserts
  `CONNECTION_VALIDATED` carries `Status::Error` for unadvertised method.
- `stability.rs:1437` (`ex_r7_unadvertised_auth_reverts_credential_to_anonymous`): asserts
  `auth_complete` hook observes `{method="anonymous", account="anonymous"}`, not the
  rejected claim.

Conclusion:

Rust matches pvxs exactly: Status::Error reply, connection kept alive, credential reverted to
anonymous. The EX-R7 label marks the bug that the credential revert fixed. No further change
required.

### R73 — PUT partial-update BitSet semantics (parity-correct)

Severity: N/A — parity-correct

Status: No change

Context:

A PVA PUT data-phase frame carries a changed-BitSet followed by a partial value that contains
only the fields whose bits are set. The server must apply ONLY those fields, leaving unmarked
fields at their prior values. Incorrect handling (e.g. overwriting the full structure) would
clobber unmarked fields.

Evidence — wire decode:

- Rust site: `tcp.rs:4354-4363` — `changed = BitSet::decode(...)` reads the per-field changed
  mask; `decode_pv_field_with_bitset(&intro, &changed, 0, &mut cur, order)` decodes only the
  bytes present on the wire for marked fields. The comment at `tcp.rs:4342-4353` explains:
  "Decoding the value as a full structure (`decode_pv_field`) desyncs the stream for any
  multi-field structure where not every field is marked."
- C++ site: `serverget.cpp:450-451` — `val = Value::Helper::build(op->type);
  from_wire_valid(M, rxRegistry, val)` reads a BitMask followed by partial fields. Only marked
  fields are populated in `val`; unmarked fields hold their zero defaults.

Evidence — merge:

- Rust site: `pvdata/encode.rs:1651-1721` (`fill_unmarked_from_prior`): for each leaf in the
  type tree, if the corresponding bit is set in `changed` → keep the decoded value; if unset →
  copy from prior. Recurses into Structure children, handling pvData BitSet compression
  (parent-bit set means entire subtree is fresh). Called from both:
  - `SharedPV::put_delta` (`shared_pv.rs:545`) under a single mutex: read-prior, merge, store
    atomically.
  - Default `ChannelSource::put_delta_checked` (`source.rs:410-416`): get-prior, merge, put.
- C++ site: `sharedpv.cpp:431` — `impl->current.assign(val)` — pvxs `Value::assign()` copies
  only the marked fields from `val` into `current`, leaving unmarked fields intact. The marked
  set lives inside `val`'s own BitSet as set by `from_wire_valid`.

Evidence — test coverage:

- `tcp.rs:8468` (`put_delta_multi_field_applies_only_changed_field`): 3-field structure
  `{a, b, c}` opened with `(10, 20, 30)`; PUT EXEC changes only `b` to 99 (bit 2 set).
  Post-PUT assertion: `(10, 99, 30)` — `a` and `c` untouched.
- `encode.rs:2419` (`fill_unmarked_carries_prior_for_unmarked_leaves`): unit test for the
  merge primitive itself.

Conclusion:

Rust correctly decodes the changed-BitSet and partial value from the wire, merges only marked
leaves against the prior value, and stores the result atomically. Parity with pvxs is exact.
No fix required.

### R74 — Server-side message segmentation of large responses (parity-correct)

Severity: N/A — parity-correct

Status: No change

Context:

The PVA wire format has segment flags in the header byte (bit 4 = SegFirst, bit 5 = SegLast,
defined in pvxs `pvaproto.h:599-600` as `0x10` / `0x20`) to allow splitting large application
messages across multiple TCP writes. The question is whether the server emits segmented frames
for large responses, or sends a single unsegmented frame regardless of payload size.

Evidence — pvxs send side:

- C++ site: `pvxs/src/conn.cpp:89-101` (`ConnBase::enqueueTxBody`):
  ```cpp
  to_evbuf(tx, Header{cmd,
                      uint8_t(isClient ? 0u : pva_flags::Server),
                      uint32_t(blen)}, sendBE);
  ```
  The `flags` byte is set to only `pva_flags::Server` (0x40) — no `SegFirst` (0x10) or
  `SegLast` (0x20). Every server-→-client application message is emitted as a single
  unsegmented frame with the full body length in the 32-bit payload_length field.
- C++ site: `pvxs/src/conn.cpp:224-225` — explicit design comment: "so far we do not use
  segmentation to support incremental processing of long messages. We instead accumulate all
  segments of a message".
- The `SegFirst` / `SegLast` constants in `pvaproto.h` are checked only on the RECEIVE path
  (`conn.cpp:228-244`) to reassemble inbound segmented frames from clients that do segment.
  No send site in pvxs ever sets either flag.

Evidence — Rust send side:

- Rust site: `proto/header.rs:95-102` (`PvaHeader::application`): constructs a header with
  `HeaderFlags::new(server, false, order)` — this sets only the SERVER bit (0x40) and the
  byte-order bit (0x80). No SEGMENT_FIRST (0x10) or SEGMENT_LAST (0x20) is set.
- Rust site: `server_native/tcp.rs:1922-1946` — writer task: `while let Some(frame) =
  rx.recv().await { writer_raw.write_all(&frame).await }`. Frames are `Vec<u8>` containing a
  single 8-byte header + full payload built by the per-op send paths. No splitting occurs at
  any point on the send path.
- The inbound segmentation handling (`tcp.rs:2194-2239`, P-G20 label) is the RECEIVE side —
  correctly reassembles inbound segmented client messages. This is a separate concern from the
  outgoing emit path.

Conclusion:

Both pvxs and the Rust server always emit single unsegmented frames for server→client
application messages. Neither implementation ever sets segment flags on outgoing frames.
Parity-correct. No fix required.

### R75 — Server beacon emission: cadence, GUID, change_count, format (parity-correct)

Severity: N/A — parity-correct

Status: No change

#### (1) Cadence / back-off schedule

- Rust site: `server_native/udp.rs:263-267` — `if emitted < beacon_burst_count { beacon_period }
  else { beacon_period_long }`. Defaults: `beacon_period = 15s`, `beacon_burst_count = 10`,
  `beacon_period_long = 180s` (`runtime.rs:243-245`).
- C++ site: `server.cpp:44-46` — `beaconIntervalShort = {15, 0}`, `beaconIntervalLong = {180,
  0}`; `server.cpp:827-832` — `if(beaconCnt<10u) { interval = beaconIntervalShort; beaconCnt++;
  }`.
- Both emit at most 10 short-interval beacons then fall back to 180s. ✓
- First beacon: Rust fires immediately via `first_beacon` skip at `udp.rs:261,268-269`. pvxs
  arms the libevent timer with `&immediate = {0,0}` (`server.cpp:606-607`). Both fire
  immediately on start. ✓

#### (2) GUID — stable, secure-random

- Rust site: `udp.rs:38-50` (`random_guid`) — uses `/dev/urandom`; falls back to
  timestamp+PID on non-Unix. Generated once at server start (`runtime.rs:473`).
- C++ site: `server.cpp:514-554` — seeds `evutil_secure_rng_get_bytes` then XORs in
  timestamp, host IP, PID, port, `this` pointer. Also generated once.
- Both use cryptographic randomness; GUIDs are stable for the server's lifetime. ✓

#### (3) change_count semantics

- Rust site: `udp.rs:275-290` — polls `beacon_source.list_pvs().await` at each beacon
  emission, hashes the sorted PV list, and calls `change_count.wrapping_add(1)` when the
  hash differs from `last_set_hash`. The guard `last_set_hash != 0` prevents a spurious
  change on the very first beacon (no prior reference point).
- C++ site: `server.cpp:95,113,180,189` — `beaconChange++` fires IMMEDIATELY inside
  `addSource`, `removeSource`, `addPV`, `removePV`.
- Semantic difference: pvxs increments on each add/remove call; Rust detects net change at
  beacon period boundary. Rapid add+remove within one period: pvxs count += 2, Rust count +=
  0 (no net change). In both cases clients see the correct current PV set via a subsequent
  SEARCH; the change_count is a hint to trigger re-search, not an exact operation journal.
  No client correctness impact. Noted as intentional simplification.

#### (4) Wire format

Beacon payload (both pvxs `doBeacons` server.cpp:773-782 and Rust `build_beacon`
udp.rs:962-990):

```
 0..12  12B  GUID
12..13   1B  flags / QoS = 0
13..14   1B  beacon_seq  (u8, wrapping monotonic)
14..16   2B  change_count (u16)
16..32  16B  server IP — IPv4-mapped IPv6 (::ffff:0.0.0.0 = UNSPECIFIED)
32..34   2B  tcp_port
34..?   pvString "tcp" (or "tls" for TLS transport)
 ?..?+1  1B  0xFF — null serverStatus
```

- pvxs writes `SockAddr::any(AF_INET)` via `evhelper.cpp:891-908`: 10×0x00 + 2×0xFF +
  4 bytes of INADDR_ANY.
- Rust writes `ip_to_bytes(IpAddr::V4(Ipv4Addr::UNSPECIFIED))` via `proto/ip.rs:9-20`:
  same 16-byte IPv4-mapped encoding.
- Formats are byte-for-byte identical. ✓

Conclusion:

All four check points — cadence, GUID, change_count, format — are functionally equivalent
to pvxs. The only deviation (polling vs. immediate change_count) is a semantic simplification
that has no client-visible correctness impact. No fix required.

### R76 — UDP SEARCH → SEARCH_RESPONSE handling (parity-correct)

**Rust sites:** `udp.rs:588-741` (`process_search_datagram`), `udp.rs:846-908`
(`process_v6_search_datagram`), `udp.rs:920-950` (`build_search_response_proto`),
`udp.rs:1050-1124` (`parse_search_request`), `tcp.rs:3675-3723` (`handle_tcp_search`)

**C++ sites:** `server.cpp:669-763` (`Server::Pvt::onSearch`),
`serverchan.cpp:173-255` (`ServerConn::handle_SEARCH`)

#### (1) Multiple PV names — parsed + answered

- pvxs `server.cpp:689-693`: iterates `msg.names`, sets `_claim = false` on each.
  `server.cpp:721-728`: counts claimed names into `nreply`. `server.cpp:747-751`:
  writes matched IDs — `if(searchOp._names[i]._claim) { to_wire(M, uint32_t(msg.names[i].id)); }`.
- Rust `parse_search_request` (udp.rs:1108-1112): builds `Vec<(u32, String)>` from all
  (cid, name) pairs. `process_search_datagram:694-704`: calls `source.searchable(name)` for
  each, accumulates `matched_cids`. `build_search_response_proto:936-938`: writes matched
  cids. Multi-PV parsing correct on both sides. ✓

#### (2) Reply policy — mustReply

- pvxs UDP: `server.cpp:730-732` — `if(nreply==0 && !msg.mustReply) return;`
- Rust UDP: `udp.rs:712` — `if !matched_cids.is_empty() || req.must_reply`
- pvxs TCP: `serverchan.cpp:230-231` — `if(nreply==0 && !mustReply) return;`
- Rust TCP: `tcp.rs:3711` — `if !matched.is_empty() || req.must_reply`
- Both paths (UDP + TCP) honour `mustReply` for `pvlist`-style discovery. ✓

#### (3) Response contents

pvxs `server.cpp:738-752` wire layout:

```
 0..12  12B  GUID
12..16   4B  searchID (u32)
16..32  16B  SockAddr::any(AF_INET) — all zeros, 16-byte IPv4-mapped
32..34   2B  tcp_port (bind port)
34..?   pvString "tcp"
 ?..+1   1B  found = (nreply!=0 ? 1 : 0)
 +..+2   2B  nreply
+2..    u32 × nreply — matched searchInstanceIDs
```

Rust `build_search_response_proto` (udp.rs:929-939): identical layout —
`ip_to_bytes(IpAddr::V4(Ipv4Addr::UNSPECIFIED))` produces the same 16-byte
IPv4-mapped zeros as pvxs `SockAddr::any(AF_INET)` (same encoding confirmed by R75).
`found` byte: `0 if cids.is_empty() else 1` matches pvxs `nreply!=0 ? 1 : 0`. ✓

#### (4) TCP search path

- pvxs `serverchan.cpp:173-255` (`handle_SEARCH`): reads searchID + flags (mustReply),
  skips replyAddr/port (`M.skip(3+16+2)` — "we always and only reply to TCP peer"),
  reads protocol list to advance M (but does **not** gate on it), reads nchan, calls
  `onSearch`, replies via `enqueueTxBody(CMD_SEARCH_RESPONSE)`.
- Rust `handle_tcp_search` (tcp.rs:3675-3723): reuses `parse_search_request` (same wire
  shape), gathers matches, sends via `tx.send(response)` on the established connection.
- Minor deviation: Rust applies `protocol_ok` filter (PVA-R10) on the TCP path; pvxs
  TCP path reads but does not gate on the protocol list. Effect: Rust refuses matches when
  the client asked for a protocol the server does not speak (e.g. "udp" over a tcp
  connection). pvxs would answer anyway. Conservative and intentional under PVA-R10. ✓

#### (5) Unicast-vs-broadcast reply address

- pvxs: `replyDest` is resolved by the UDP manager before `onSearch` is called; the
  `msg.reply()` callback routes to the correct address. `udp_collector.cpp:351-371`
  prefers the SEARCH payload's announced address; falls back to UDP source when payload
  addr is unspecified; drops an ORIGIN_TAG SEARCH with unspecified reply addr.
- Rust `process_search_datagram:667-681`: identical three-way resolution —
  `Some(ip)` → payload addr, `None` + Direct → UDP source IP + payload port,
  `None` + FromOriginTag → drop. v6 path always uses UDP source. ✓

Conclusion:

All five check points — multi-PV parsing, mustReply policy, response wire format, TCP
search path, unicast/broadcast reply — are functionally equivalent to pvxs. The only
deviation (protocol_ok gate on TCP path) is a conservative intentional extension of
PVA-R10. No fix required.

### R77 — Channel/request DESTROY + CANCEL lifecycle (parity-correct)

**Rust sites:** `tcp.rs:3501-3559` (`handle_destroy_channel`),
`tcp.rs:3581-3617` (`handle_cancel_request`), `tcp.rs:3646-3665`
(`handle_destroy_request`), `tcp.rs:983-1117` (`OpState` + `AbortOnDrop`)

**C++ sites:** `serverchan.cpp:43-59` (`ServerChan::cleanup`),
`serverconn.cpp:262-295` (`handle_CANCEL_REQUEST`),
`serverconn.cpp:297-321` (`handle_DESTROY_REQUEST`),
`serverchan.cpp:371-411` (`handle_DESTROY_CHANNEL`),
`serverconn.cpp:487-511` (`ServerOp::cleanup`)

#### (1) DESTROY_CHANNEL — removes SID + all ops, stops monitors

- pvxs `serverchan.cpp:371-411`: finds SID; unknown → debug + return (no reply).
  CID mismatch logged but proceeds. `chanBySID.erase(it)` then `chan->cleanup()`
  which iterates `opByIOID`, calls `op->cleanup()` on each: fires `onCancel` if
  Executing, sets Dead, calls `onClose`, removes from both `conn->opByIOID` and
  `chan->opByIOID`.
- Rust `tcp.rs:3501-3559`: unknown SID → debug + early return (no reply) ✓.
  CID mismatch logged, proceeds. `channels.remove(&sid)` drops `ChannelState`,
  which drops `HashMap<ioid, OpState>`; each `OpState` drop fires `monitor_abort:
  Arc<AbortOnDrop>` (subscriber task aborted) and `data_task_abort: Arc<AbortOnDrop>`
  (exec task aborted), and `monitor_start_ctl` (terminal `notify_monitor_start(false)`).
  Sends DESTROY_CHANNEL echo (sid + cid). ✓
- Tested: `destroy_channel_on_unknown_sid_emits_no_reply` (tcp.rs:7834) +
  `destroy_channel_on_known_sid_emits_echo` (tcp.rs:7865).

#### (2) DESTROY_REQUEST — removes IOID, stops monitor, aborts in-flight

- pvxs `serverconn.cpp:297-321`: `lookupSID(sid)` returns null ptr on unknown SID
  (static empty shared_ptr); `!chan` → logs debug. Unknown IOID → logs debug,
  no cleanup. Known IOID → removes from `opByIOID` (both conn and chan), calls
  `op->cleanup()`.
- Rust `tcp.rs:3646-3665`: unknown SID → no-op (silent). Unknown IOID →
  `ch.ops.remove(&ioid)` returns None → no-op. Known IOID → drops `OpState` →
  fires `AbortOnDrop` chain as in (1). ✓
- Tested: `bfr15_destroy_request_aborts_in_flight_exec_task` (tcp.rs:11318) —
  DESTROY_REQUEST while a blocking GET exec is running cancels the task.

#### (3) CANCEL_REQUEST — Executing→Idle without aborting

- pvxs `serverconn.cpp:262-295`: finds op by IOID globally (not gated by SID);
  unknown IOID → `log_warn` + return. SID mismatch → `log_err` + return.
  Executing → `op->state = Idle` + call `onCancel`. Non-executing → debug
  ("allowed race").
- Rust `tcp.rs:3581-3617`: unknown SID → no-op (silent). Unknown IOID → no-op
  (silent). Known op → `monitor_paused.store(true)` + `monitor_start_ctl.set(false)`
  (no-op if already paused or never started per PVA-FR-11). Subscriber task stays
  alive; subsequent START clears the pause via the resume path. ✓
- Minor difference: Rust silently ignores unknown SID/IOID; pvxs logs a warning.
  Log-only; no correctness impact.
- Rust lookup is `channels[sid] → ops[ioid]` (structurally nested), so a stale
  SID with known IOID cannot be found; pvxs's separate `opByIOID` + SID-check
  path is logically equivalent in practice.
- Tested: `cancel_request_pauses_monitor_without_aborting` (tcp.rs:7492).

#### (4) Unknown SID/IOID graceful; double-destroy; destroy-while-in-flight

- Unknown SID/IOID: all three Rust handlers return `Ok(())` without panic or
  spurious reply. `HashMap::get_mut` / `HashMap::remove` return `None` on miss
  → silent. DESTROY_CHANNEL sends no reply on miss (matches pvxs:382-386). ✓
- Double-destroy (DESTROY_REQUEST × 2): second call hits `ch.ops.remove(&ioid)
  = None` → no-op. Double DESTROY_CHANNEL: second call hits `!channels.
  contains_key(&sid)` → early return, no double-reply. ✓
- Destroy-while-in-flight: exec task holds no clone of `data_task_abort` Arc
  (only `OpState` owns the arc). `ch.ops.remove` drops the Arc; if refcount
  reaches zero `AbortOnDrop::drop()` aborts the task. If the task already sent
  its response the abort is a no-op on a completed task (tokio no-op). This
  "allowed race" matches pvxs's single-threaded model where queued responses
  can already be in the tx buffer before cleanup. ✓
- Last-request path (`finish_exec_data_task`, tcp.rs:794-832): op removed
  WITHOUT installing abort guard so the spawned task can finish normally. A
  subsequent DESTROY_REQUEST for the same IOID finds no entry → no-op. ✓

Conclusion:

All four check points — DESTROY_CHANNEL cleanup, DESTROY_REQUEST IOID removal,
CANCEL_REQUEST Executing→Idle, and graceful unknown/double/in-flight races —
are functionally equivalent to pvxs. The only difference is Rust is silent on
unknown SID/IOID in CANCEL_REQUEST + DESTROY_REQUEST where pvxs logs; log-only,
no correctness impact. No fix required.

### R78 — pvRequest FIELD-MASK filtering on GET + MONITOR (parity-correct with behavioral note)

**Rust sites:** `pv_request.rs:194-248` (`request_to_mask`), `pvdata/encode.rs:1248-1293`
(`canonical_changed_bitset`), `pvdata/encode.rs:1433-1501`
(`encode_pv_field_with_bitset`), `tcp.rs:3866-3887` (INIT mask computation),
`tcp.rs:4204` (GET reply), `tcp.rs:5756` (MONITOR emit)

**C++ sites:** `pvrequest.cpp:13-72` (`request2mask`), `dataencode.cpp:414-438`
(`to_wire_valid`), `serverget.cpp:200` (INIT: `pvMask = request2mask(...)`),
`serverget.cpp:104` (GET reply: `to_wire_valid(R, value, &pvMask)`),
`servermon.cpp:173` (MONITOR emit: `to_wire_valid(R, ent, &pvMask)`)

#### (1) pvRequest `field()` node parsed into field selection

- pvxs `request2mask` (pvrequest.cpp:13-72): extracts `fields = pvRequest["field"]`.
  Iterates `rdesc->mlookup` (the "field" sub-struct's flat lookup including dotted nested
  paths like "alarm.status"). For each name found in the VALUE structure's mlookup,
  marks `ret[it->second] = true`. When the pvRequest sub-entry is an empty struct AND the
  VALUE node is a struct, iterates that node's children to mark them too
  (`pvrequest.cpp:44-45`). No "field" sub-struct → wildcard (all bits).
- Rust `request_to_mask` (pv_request.rs:194-248): identical structure. Extracts
  "field" sub-struct from pvRequest descriptor; if absent/empty → all-bits wildcard.
  Otherwise recurses via `mark_path` for each top-level name that exists in `value_desc`.
  `mark_path`: sets the field's bit; if sub-request is empty → sets entire subtree; else
  recurses into specifically-named sub-fields.
- Both reject an entirely-empty result as `EmptyMask` error
  (`pvrequest.cpp:61-62`; `pv_request.rs:244-246`, returned to caller as INIT error
  at `tcp.rs:3876-3886`). ✓

#### (2) Changed/overrun BitSet restricted to selected fields

- pvxs GET (`serverget.cpp:104`): `to_wire_valid(R, value, &pvMask)` — builds a `valid`
  BitMask from store marks AND pvMask, then emits only the set bits.
- pvxs MONITOR (`servermon.cpp:173`): same `to_wire_valid(R, ent, &pvMask)`.
- Rust GET (`tcp.rs:4204`):
  ```rust
  let changed = canonical_changed_bitset(&intro_t, &mask_t);
  encode_pv_field_with_bitset(&value, &intro_t, &changed, 0, order, &mut payload);
  ```
- Rust MONITOR (`tcp.rs:5756`, `udp.rs:5796-5810`): same pattern, with
  `diff_changed_bitset` intersected with `mask` for partial-update sources.
- Both restrict the wire changed-bitset and value bytes to selected fields. ✓

#### (3) Nested selection — behavioral note

For `field(value.index)` on a structure where "value" is a struct with multiple children
(e.g., NTEnum `value { index, choices }`):

**pvxs:**
- `request2mask` marks the "value" struct bit AND the "value.index" leaf bit (via the
  dotted-path iteration of `rdesc->mlookup`).
- `to_wire_valid` (dataencode.cpp:423-430): when `pvMask[value_struct_bit]` is set,
  it immediately sets `valid[value_struct_bit] = true` and jumps
  `bit += desc[value_struct_bit].size()` past all value children. Result: wire
  changed-bitset has only the struct bit set; the full value subtree (index + choices) is
  encoded.
- **pvxs effectively treats `field(value.index)` as `field(value)` — it sends the entire
  "value" subtree whenever any part of it is in the pvMask.**

**Rust:**
- `request_to_mask` marks the "value" struct bit AND the "value.index" leaf bit.
- `canonical_changed_bitset`: "value"'s subtree is not fully selected (choices bit not in
  selection) → recurse; only sets bit 2 (index leaf) in the wire BitSet. NOT bit 1
  (value struct).
- `encode_pv_field_with_bitset`: bit 1 not set → recurse into value's children; only
  encodes index bytes (bit 2 set).
- **Rust sends ONLY the requested sub-field ("index"), not the entire parent subtree.**

Summary: pvxs sends a superset (entire parent struct); Rust sends exactly the selection.
Both are valid PVA wire formats — pvxs clients parse the Rust `{index_leaf_bit}` bitset
correctly (they decode only the present leaf). The wire content differs but clients
observe the correct logical result either way. No client-visible correctness defect on
either side; the Rust behavior is more spec-compliant.

#### (4) Empty/absent field() = whole structure

- pvxs `pvrequest.cpp:54-56`: `!fields.valid()` → `foundrequested=true`; later empty
  mask expands to all bits.
- pvxs `pvrequest.cpp:24-25`: `rdesc->mlookup.empty()` → `foundrequested=true` (empty
  `field {}` = wildcard).
- Rust `pv_request.rs:206-218`: no "field" sub-structure → all bits set, return.
- Rust `pv_request.rs:222-228`: `request_field.is_empty()` → all bits set, return.
- Both: absent or empty `field()` → full structure selected. ✓

Conclusion:

All four check points are functionally correct. The only deviation is in how nested
sub-field selection is encoded in the wire changed-bitset: pvxs promotes to the nearest
struct parent (sends entire subtree), Rust emits exactly the selected leaf. Both formats
are valid PVA and interoperable with pvxs clients. Rust is more spec-compliant for nested
sub-field requests (`field(value.index)`). No fix required.

## Round 2 findings (R79+) — epics-pva-rs vs pvxs

Continuation of the global `R-N` series (CA panel owns R56–R59; R60–R78 used,
never reused). Round 2 reviews the already-fixed code on the integration tip
(`66a5b006`) for NEW divergences. All findings below cite BOTH the Rust site
and the upstream pvxs C++ site.

### R79 — `NTScalar::create()` omits `display`/`control`/`valueAlarm` sub-structures that `build()` includes

- **Severity:** Low (no server-emit wire desync — `encode_pv_field` fills
  missing value fields from the descriptor; affects direct consumers of the
  `create()` value and the documented "matching `build()`" contract).
- **Status:** Fixed (structural — `create()` now derives its value from
  `build()`).
- **Evidence:**
  - Rust `src/nt/scalar.rs:175-191`: `create()` pushes only
    `value`/`alarm`/`timeStamp`, ignoring `self.display` / `self.control` /
    `self.value_alarm`, even though its own doc says "default-initialised
    value matching `build()`". `build()` (`src/nt/scalar.rs:93-169`) DOES add
    `display` / `control` / `valueAlarm` gated on those flags.
  - C++ pvxs `src/pvxs/nt.h:96-97`: `inline Value create() const { return
    build().create(); }` — pvxs derives the value directly from the
    descriptor, so `create()` can never drift from `build()`. `build()`
    (`src/nt.cpp`) adds the `display`/`control`/`valueAlarm` members under the
    same flags.
- **Impact:** A caller that does
  `NTScalar::new(Double).with_display().create()` and then inspects/mutates
  the `display` sub-field (the intended use of `create()`) finds no such
  field in the Rust value, whereas pvxs's value has it. The wire bytes the
  server emits are unaffected because the encoder is descriptor-driven, but
  the `create()` API contract and pvxs parity are violated.
- **Fix direction:** Make the value derive from the descriptor —
  `create()` = `default_value_for(&self.build())` — mirroring pvxs's
  `build().create()`. Single source of truth: the field set can no longer
  drift from `build()` by construction.

### R80 — CREATE_CHANNEL failure reply sends `serverChannelID = 0` instead of pvxs's `-1` (0xFFFFFFFF) sentinel

- **Severity:** Low (the non-OK status governs the client's handling of a
  failed CREATE_CHANNEL; a spec-correct client ignores the sid on failure.
  Still a genuine wire-byte divergence from the pvxs sentinel, and `0` could
  be misread as a literal channel id by a lenient client).
- **Status:** Fixed.
- **Evidence:**
  - Rust `src/server_native/tcp.rs:2123` (unknown-PV branch) and
    `src/server_native/tcp.rs:3436` (per-connection max-channels branch):
    both build the reply as `cid` + `put_u32(0u32)` (sid) + error `Status`.
    The success branch writes the real `cc.sid`.
  - C++ pvxs `src/serverchan.cpp:273` initialises `uint32_t cid = -1, sid =
    -1;`, leaves `sid = -1` on the not-found / error path (`:286`) and resets
    `sid = -1` on fatal create failure (`:338`), then emits
    `to_wire(R, cid); to_wire(R, sid); to_wire(R, sts);` (`:349-351`). So the
    failure reply carries `sid = 0xFFFFFFFF`.
- **Impact:** CREATE_CHANNEL failure replies differ from pvxs by one field
  (sid `0x00000000` vs `0xFFFFFFFF`). This server's `alloc_sid()`
  (`tcp.rs:40-43`) starts `NEXT_SID` at 1, so `0` is never a live channel id
  here — hence no internal collision — but the wire value still diverges from
  the documented pvxs sentinel.
- **Fix direction:** Emit the pvxs sentinel `0xFFFFFFFF` at both failure
  sites. Define it once (`CREATE_CHANNEL_NO_SID = u32::MAX`, co-located with
  the SID allocator) so both sites share one sentinel definition, mirroring
  pvxs's single `sid = -1`.

### R81 — Standalone `NTAttribute` builder uses struct id `:1.1`; pvxs (and this crate's NTNDArray embed) use `:1.0`

- **Severity:** Low–Medium (the standalone `nt::NTAttribute` builder is public
  API but not wired into any server-emit path today; the NTNDArray-embedded
  attribute already uses `:1.0`. A consumer that emits a value from this
  builder would put a type id pvxs does not define onto the wire).
- **Status:** Fixed.
- **Evidence:**
  - Rust `src/nt/attribute.rs:42` (`build()` struct_id), `:57` (`create()`
    struct_id), and the pinned test `:99` all use
    `"epics:nt/NTAttribute:1.1"`; the module doc (`:1`, `:6`) and
    `src/nt/mod.rs:8` repeat `:1.1`. The field set
    (name/value/tags/descriptor/alarm/timeStamp/sourceType/source) already
    matches pvxs.
  - C++ pvxs `src/nt.cpp:238`: the only NTAttribute id in pvxs is
    `StructA("attribute", "epics:nt/NTAttribute:1.0", { String("name"),
    Any("value"), StringA("tags"), String("descriptor"), Struct("alarm",
    ...), time_t.as("timeStamp"), Int32("sourceType"), String("source") })`.
    There is no `:1.1` anywhere in pvxs.
  - Internal inconsistency: `src/nt/nd_array.rs:288` (`attribute_desc()`)
    already uses `:1.0` for the NTNDArray-embedded attribute.
- **Impact:** A pvxs client doing NT-aware introspection (type-id match on
  `epics:nt/NTAttribute:1.0`) would not recognise the `:1.1` id this builder
  emits. The common NTNDArray path is unaffected (it uses its own `:1.0`
  descriptor).
- **Fix direction:** Change `:1.1` → `:1.0` at all source sites (`build()`,
  `create()`, module docs, the `mod.rs` re-export doc) and update the pinned
  test assertion. The field set is already correct, so no other change.

### R82 — NTNDArray module-doc layout block is stale (contradicts the already-correct `nt_nd_array_desc()`)

- **Severity:** Low (documentation only — NO code or wire change. The
  descriptor is already parity-correct and asserted by a test).
- **Status:** Fixed (doc only).
- **Evidence:**
  - Rust `src/nt/nd_array.rs:7-45` (module `//!` doc layout block) shows an
    OLD shape: trailing `string descriptor` + `display_t display` fields, a
    5-field `attribute` substructure
    (`name`/`value`/`descriptor`/`sourceType`/`source`), and a field order
    that does not match the actual descriptor.
  - The actual descriptor `src/nt/nd_array.rs:359-379` (`nt_nd_array_desc()`)
    emits exactly 10 top-level fields — `value`, `codec`, `compressedSize`,
    `uncompressedSize`, `uniqueId`, `dataTimeStamp`, `alarm`, `timeStamp`,
    `dimension`, `attribute` — with an 8-field `attribute` substructure
    (`attribute_desc()`, `:284-300`). Asserted by the test
    `descriptor_matches_canonical_layout` (`:565-594`).
  - C++ pvxs `src/nt.cpp:196-251` (`NTNDArray::build()`) is the source of that
    10-field layout; the descriptor code already matches it (a prior round
    closed the code divergence — in-code note at `nd_array.rs:219-226`).
    (kodex node `aba2467e` describing the *code* divergence is stale/pre-fix.)
- **Impact:** A reader using the module doc to understand the NTNDArray wire
  layout is misled (it lists fields that are not emitted and the wrong order).
  No runtime/wire effect.
- **Fix direction:** Rewrite the module-doc layout block to match
  `nt_nd_array_desc()` / pvxs `nt.cpp:196-251`. No code change.

### R90 — DiscoverPing response silently dropped; active discovery broken

- **Severity:** Medium
- **Status:** Fixed
- **Evidence:**
  - Rust: `src/client_native/search_engine.rs:1515-1517` — `if !resp.found { return consumed; }`
    unconditionally drops ALL `found=false` SEARCH_RESPONSE frames on both the UDP search
    socket and the TCP name-server path. This includes the server's mandatory reply to a
    `DiscoverPing` (empty SEARCH with `MustReply` set): that reply has `found=false` and
    `nSearch=0`, so it is silently discarded. The `_beacons` parameter was passed to the
    function but was never used (underscore prefix confirms the code was set up for this path
    but left unfinished).
  - C++: `pvxs/src/client.cpp:866-878` — `if(!found && !istcp && seq==search_seq &&
    nSearch==0u && !discoverers.empty())` treats a UDP `found=false, nSearch=0`
    SEARCH_RESPONSE as a fake beacon: creates a synthetic `UDPManager::Beacon` from the
    reply's GUID and server address and calls `self.onBeacon(fakebeacon)`, which registers
    the server in the tracker and fires `Discovered::Online` to all subscribers.
- **Impact:** `SearchEngine::discover_ping()` sends the correct wire packet
  (`build_discover_search`: empty SEARCH with `MustReply`, zero channels) but the server's
  response is discarded. No `Discovered::Online` event fires for servers discovered only via
  active ping (servers that do not send periodic beacons). Normal PV connect/get/put/monitor
  are unaffected — only the Discovery subscription API is broken.
- **Fix direction:** Before the early `!found` return in `handle_search_response`, add a
  guard for the UDP discovery pong case (`!is_tcp && resp.cids.is_empty()`). When matched:
  check `ignore_guids`, call `beacons.observe(server, guid)`, update `announced`, notify
  `subscribers` with `Discovered::Online`, and set `poke_request` to accelerate pending
  searches (same logic as `handle_beacon`). Add `subscribers`, `announced`, and
  `poke_request` to the function signature; update all three call sites. Rename `_beacons`
  to `beacons` to use the parameter.

## Uncertain Candidates

None. All investigated paths reached a definite conclusion (correct, bug, or documented gap).
