diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ce9eb37d..b56c235f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -275,8 +275,7 @@ jobs:
# absent from the sysroot entirely. The prebuilt-sysroot thumb job
# above SHIPS alloc, so an `extern crate alloc` regression passes
# there and still breaks halo. This job is the halo-certification
- # gate (phase 22 / measurement PR 0 — see
- # docs/simple_someip/plans/2026-06-09-phase22-125-memory-reduction-design.md).
+ # gate.
name: build-std core gate (no alloc in sysroot)
needs: check
runs-on: ubuntu-latest
diff --git a/docs/simple_someip/plans/2026-06-09-phase22-125-memory-reduction-design.md b/docs/simple_someip/plans/2026-06-09-phase22-125-memory-reduction-design.md
deleted file mode 100644
index e336f18b..00000000
--- a/docs/simple_someip/plans/2026-06-09-phase22-125-memory-reduction-design.md
+++ /dev/null
@@ -1,274 +0,0 @@
-# Memory-footprint reduction: issue #125 + Phase 22 close-out
-
-**Date:** 2026-06-09 (rev 2 — post adversarial review, same day)
-**Base:** `feature/phase21_api_symmetry` (PR #114), after PR #124 merges into it
-**Closes:** issue #125; completes the Phase 22 server alloc-elimination plan
-
-## Problem
-
-Issue #125 reports Embassy arena exhaustion in the consuming firmware
-build (halo / TC4): `TaskStorage` entries for simple_someip tasks
-dominate the arena, and static pool symbols are oversized. Two root
-causes:
-
-1. **Async state-machine bloat.** `Inner::run_future` is a single
- `select_biased!` loop that inlines the entire
- `handle_control_message` call tree (~370 lines, itself awaiting
- `bind_unicast` → socket spawn → send → oneshot recv). Rustc reserves
- layout for the sum of all nested awaited futures along the deepest
- path. The same pattern exists in `socket_loop_future` and the
- server's `recv_loop` / `announce_loop`.
-2. **Buffers and pools held by value.**
- - `socket_manager.rs:569` holds a `[u8; UDP_BUFFER_SIZE]` (1500 B)
- live across the whole socket loop, and `socket_manager.rs:632`
- adds a second 1500 B buffer during E2E sends. With
- `UNICAST_SOCKETS_CAP = 8` (`inner.rs:40`), that is **~12 KiB
- always-live and ~24 KiB worst case** during concurrent E2E sends
- — all of it inside future state, i.e. inside the Embassy arena.
- - Static channel pools holding `SendMessage` / `ReceivedMessage`
- elements that embed full `Message
` payloads by value. The
- `define_static_channels!` macro already lets consumers tune
- **pool sizes** per type; what is hardcoded in crate code is the
- bounded **slot caps** (`C::bounded::()` call sites), the
- control queue `Deque<_, 32>`, and the pending-responses map (64).
-
-Separately, the Phase 22 plan (make `server,bare_metal` build under
-halo's `-Zbuild-std=core`, i.e. no alloc in the sysroot) gates any
-on-target measurement of the server loops. PR #124 (Feliciano) has
-since implemented most of Phase 22 — **verified 2026-06-09**: on
-#124's branch, both `--features server,bare_metal` and
-`--features client,server,bare_metal` compile clean under
-`cargo +nightly build -Zbuild-std=core --target thumbv7em-none-eabihf`.
-
-### Framing: arena vs. `.bss`
-
-Moving buffers out of futures does **not** reduce total RAM — it moves
-bytes from the Embassy arena (TaskStorage) into consumer-declared
-statics (`.bss`), and shrinks them where the consumer right-sizes the
-declarations. That is the correct fix for #125's failure mode: the
-arena is the thing that exhausts, and `.bss` is sized explicitly and
-predictably. Reviewers of the before/after tables should expect
-TaskStorage entries to shrink while some static symbols grow or move
-to the consumer's crate; the headline win is arena predictability
-plus whatever the consumer saves by right-sizing.
-
-## Decisions already made
-
-- **Measurement:** in-repo harness for development and regression
- gating; the locally available TC4 build is the final acceptance
- check once hooked up. On-target "before" numbers remain capturable
- later by building the pre-optimization commit — baselines do not
- block on TC4 bring-up.
-- **Scope:** everything in #125 (client, server, pools), with Phase 22
- folded into the same stack.
-- **Client restructuring aggressiveness:** moderate — buffer
- extraction plus handler-tree flattening, staying in ordinary async
- Rust. No hand-written poll state machines (the polled module from
- PR #126 already serves users who need exact layouts).
-- **Buffer sizing (rev 2):** buffers become **caller-sized**. Once
- extracted from the futures, socket loops take `&'static mut [u8]`
- slices, so the buffer count and length are chosen by the consumer's
- static declaration at runtime-slice granularity — no const-generic
- threading through `Client`'s parameters. Halo can declare e.g.
- 2 × 512 B = 1 KiB instead of 12 KiB. The tokio path provisions
- 8 × 1500 internally (API and behavior unchanged).
-
- *512 B rationale (sized against Iris generic interface 0.11):* the
- largest defined payload is `SoftwareApplicationInfo` at 256 B
- (≈284 B on-wire with SOME/IP + E2E P04 headers); `ScanCmd` is 88 B.
- ScanCmd's command list is the growth risk (`uint16` length field),
- but the crate's hard ceiling is already one UDP datagram
- (`UDP_BUFFER_SIZE = 1500`, no SOME/IP-TP), so nothing larger was
- ever sendable; outgrowing 512 B is a logged drop fixed by a
- one-line bump in the consumer's declaration. Any halo-side traffic
- beyond the generic interface (e.g. HWP1 method requests) needs the
- same size check before the declaration is locked.
-- **Pool capacities:** narrowed from "promote everything to
- const-generic knobs". Pool sizes are already consumer-tunable via
- `define_static_channels!`. The remaining hardcoded numbers (slot
- caps 16, `Deque<_, 32>`, pending map 64) can only become knobs on
- stable Rust as **literal const parameters threaded through
- `Client`/`Inner`'s public types** (associated-const capacities at
- the call sites would require unstable `generic_const_exprs`). That
- churn is taken only where PR 0/TC4 measurement shows the win
- justifies it; otherwise the numbers stay hardcoded and the decision
- is recorded.
-- **PR #124:** merges as-is; our review findings are fixed by us in
- this stack rather than requested from the author.
-- **Stack hygiene:** all 37 stale phase PRs beneath #114 were closed
- without merging on 2026-06-09 (branches retained).
-
-## PR #124 coverage of Phase 22 (reviewed 2026-06-09)
-
-| Phase 22 item | Status in #124 |
-|---|---|
-| Item 4 — `_alloc`-gate `Server::run` | Done (`run` + `run_inner`) |
-| Item 5 — remove `Pin>` GATs | Done via `core::future::Ready` (simpler than the planned hand-written futures; supersedes the saved pre-flight patch) |
-| Item 2 — started latch without `Arc` | Done via cfg-switched `StartedLatch` alias instead of an `Hstart` generic |
-| Item 3 — Arc type-param defaults | Done via cfg-switched `Default*Handle` aliases instead of dropping defaults |
-| Items 1+10 — import reshape, `server` feature drops `_alloc` | Done |
-| CI gate `server,bare_metal -Zbuild-std=core` | **Missing** (gap-filled in PR 0; verified locally that it passes) |
-
-**Why the existing CI doesn't already cover this:** phase21's CI does
-build `server,bare_metal` for thumbv7em-none-eabihf — but against the
-**prebuilt sysroot, which ships `alloc`**, so an `extern crate alloc`
-regression would never E0463 there. Halo's proxy builds with
-`-Zbuild-std=core`, where `alloc` is absent from the sysroot entirely.
-PR 0's build-std job is the only configuration that certifies halo's
-actual constraint. Relatedly, the existing `nm` alloc-symbol audit
-covers only the `client,bare_metal` rlib; PR 0 extends it to
-`server,bare_metal` (stable-toolchain, nearly free).
-
-**Accepted trade-off:** the cfg-switched aliases violate strict feature
-additivity (enabling `_alloc` changes type identities). This is
-documented as a hazard rather than redesigned — halo builds with a
-fixed feature set, and explicit `new_with_handles` callers spell their
-types. The generic-parameter design remains available if a real
-unification break ever appears.
-
-**Pre-flight note:** the Phase 22 plan's open risk — whether a concrete
-(non-boxed) future satisfies `Server::run`'s phase-21F
-`for<'a> Sub::SubscribeFuture<'a>: Send` HRTB — was verified resolved
-on 2026-06-09 against phase21 tip `892cb5b`. `core::future::Ready`
-satisfies the same bounds.
-
-## The stack
-
-Four PRs off `feature/phase21_api_symmetry`, post-#124.
-
-### PR 0 — Measurement harness + CI gates
-
-- **Future-size regression tests:** `size_of_val`-based assertions on
- client `run_future`, `socket_loop_future`, and the server run
- future, in both tokio and bare-metal-deps configurations (modeled on
- the existing `client_new_run_future_is_send_static` witness, which
- already returns the run future by value). These are **host-arch
- proxies**: x86_64 layouts differ from thumbv7 (pointer width,
- alignment), so budgets are generous regression tripwires, not
- targets. Budgets start at current size (recording the baseline) and
- tighten as PRs 2–3 land.
-- **`-Z print-type-sizes` capture script** in `tools/`, producing the
- TaskStorage-style table for a thumbv7em build. This is the
- **authoritative** size number. Baseline committed.
-- **New CI jobs:** `--no-default-features --features server,bare_metal`
- and `client,server,bare_metal` under `-Zbuild-std=core` for
- thumbv7em (nightly + rust-src — new CI infrastructure; the current
- workflows are stable-only). Verified passing locally on #124's
- branch. Plus the `nm` alloc-symbol audit extended to the server
- rlib.
-
-### PR 1 — #124 follow-ups (small, lands the breaking change early)
-
-The #124 review findings, fixed by us:
-
-- (a) Document (or deliberately change) the eager-vs-lazy semantics of
- the `Ready`-based `subscribe`/`unsubscribe` — the locked mutation now
- happens at future construction, not first poll.
-- (b) `NonSdRequestCallback` gains a context argument. **Design note:**
- a stored `*mut c_void` would make `Server` `!Send` and break
- `Server::run`'s declared `+ Send` bound. The shape is decided in the
- implementation plan from: `ctx: usize` (caller casts), a newtype
- with a documented `unsafe impl Send`, or a generic observer
- parameter. Breaking now is free (nothing published); breaking later
- is not. Flag to Feliciano before this lands so no further FFI builds
- on the bare `fn` shape.
-- (c) Record the shared-socket-topology rationale for
- `announce_only_future` (it partially reintroduces the split-future
- shape phase 21 removed). The originally-planned MSRV check is moot:
- the crate is edition 2024 (requires Rust ≥ 1.85); `use<>` precise
- capture needs only 1.82.
-- (d) Strengthen the non-SD-observer negative test (currently cannot
- fail — the witness callback is never registered).
-
-### PR 2 — #125 client async-state reduction
-
-- **Buffer extraction via a claim/release buffer pool.** Socket loops
- are spawned dynamically per bind/unbind (up to
- `UNICAST_SOCKETS_CAP` live), so buffers need checkout/return
- semantics: a buffer pool in the consumer's static storage (same
- shape as `OneshotPool` — claim on bind, release on unbind, no
- `&'static mut` aliasing). Loops take `&'static mut [u8]` slices;
- count and length are the consumer's choice (halo: ~1 KiB total).
- The tokio path provisions 8 × 1500 internally — API unchanged.
- Defined behavior changes: an inbound datagram larger than the
- claimed buffer is dropped with a log; the existing oversize-send
- rejection (`socket_manager.rs:447`) checks `buf.len()` instead of
- `UDP_BUFFER_SIZE`. The E2E `protected` buffer gets the same pool
- treatment, or is restructured to not be live across the
- `protect().await` point — whichever measures better.
-- **Handler-tree flattening:** `handle_control_message` splits into a
- synchronous "decode + decide" section returning a small action
- value; the actual awaits are hoisted to shallow helpers at
- `run_future`'s top level. **Expectation setting:** the awaited
- futures remain part of `run_future`'s layout — the wins are locals
- no longer held across awaits, avoided per-nesting-level argument
- duplication, and better variant overlap. The buffers are expected to
- be the dominant win; flattening is secondary and is kept only where
- PR 0's numbers move.
-- Doc debt: rewrite `src/client/mod.rs:12-30` (describes the old
- buffer-in-future architecture and the 12 KiB math).
-- Every change is validated against PR 0's numbers; changes that don't
- move the measurement are dropped, not merged on faith.
-- **Deferred follow-up (recorded, not scheduled):** a readiness-split
- receive (`await readiness, then synchronous copy-out`) would let one
- shared buffer serve all socket loops on a single-threaded executor
- (~1.5 KiB total regardless of socket count). It requires a
- `TransportSocket` trait change; only worth it if caller-sizing
- proves insufficient.
-
-### PR 3 — #125 server + pools, final numbers
-
-- Same flatten/extract treatment for `recv_loop`, `announce_loop`,
- `send_subscribe_nack_from_view`, on the post-#124 code.
-- Pool-capacity knobs per the narrowed decision above (literal const
- params only where measurement justifies the churn; otherwise record
- and keep).
-- Final before/after tables (TaskStorage sizes + `llvm-nm` pool
- symbols) in the PR description — issue #125's acceptance criteria.
-
-### Scope cuts from issue #125 (recorded)
-
-- **SD encode monomorphization** (`Header::encode`,
- `ServiceEntry::encode`, `EventGroupEntry::encode`, generic over
- `embedded_io::Write`): code-size pressure, not arena/RAM pressure.
- Out of scope for the arena-exhaustion failure mode. If flash size
- becomes the constraint, the cheap fix is an inner non-generic
- `&mut dyn embedded_io::Write` function — separate issue.
-- **`unbind_discovery`:** addressed only implicitly via the
- `run_future` flattening in PR 2 (it is one arm of the same control
- path); no dedicated work item unless PR 0's table shows it as an
- independent hotspot.
-
-## Invariants
-
-- No behavioral changes except the agreed `NonSdRequestCallback`
- signature change and the two defined buffer-size behaviors in PR 2.
-- Existing suite (~543 tests as of #114, plus #124's additions) green
- on every PR; embassy-net loopback live-wire test guards the announce
- path.
-- No nightly-only features in the crate itself (halo's consumer is
- nightly; the crate stays stable; CI may use nightly for measurement
- and build-std jobs).
-- Wire format untouched.
-
-## Risks / coordination
-
-- **#126 (polled module)** is Feliciano's, has an outstanding
- hold-merge punch list, and also bases on phase21. Merge order
- relative to this stack is decided between Justin and Feliciano; the
- polled module is a parallel surface, so PRs 2–3 should rebase
- trivially either way.
-- **#124 is force-pushed actively.** Our stack starts only after it
- merges into phase21, to avoid chasing a moving base.
-- **Future-size assertions can be brittle across rustc versions** and
- are host-arch proxies. Budgets use generous headroom (e.g. +25%)
- over the post-optimization baseline; the thumbv7em
- `print-type-sizes` harness is the authoritative number.
-- **The buffer pool's claim/release lifecycle** is new unsafe-adjacent
- surface (handing out `&'static mut [u8]`); the implementation plan
- includes loom-style or witness tests for double-claim and
- release-on-unbind.
-- The whole stack still sits on the unmerged #114 tower
- (134+ commits ahead of main); the eventual consolidation rebase is a
- known cost of the established workflow, accepted to keep reviewable
- PR boundaries.
diff --git a/docs/simple_someip/plans/2026-06-10-pr1-124-followups-design.md b/docs/simple_someip/plans/2026-06-10-pr1-124-followups-design.md
deleted file mode 100644
index cda70430..00000000
--- a/docs/simple_someip/plans/2026-06-10-pr1-124-followups-design.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# PR 1 — #124 follow-ups: design
-
-Second PR of the #125 / phase-22 close-out stack
-(`2026-06-09-phase22-125-memory-reduction-design.md`, "PR 1" section).
-Closes the four review findings recorded there against PR #124, plus
-one stale-doc item surfaced during PR 0.
-
-**Branch:** `feature/pr1_124_followups` off
-`feature/pr0_measurement_harness` (PR base = the PR 0 branch). PR 1
-must stack on PR 0: changing the callback shape touches `ServerDeps`
-initializers in test files PR 0 modified
-(`tests/bare_metal_e2e.rs`, `tests/bare_metal_server.rs`).
-
-**External gate:** Feliciano gets the callback-signature heads-up
-before this merges, so no further halo FFI builds on the bare `fn`
-shape (stack-plan gate 2 — user action, still open as of
-2026-06-10).
-
-## 1. `NonSdRequestCallback` gains a context argument (BREAKING)
-
-Decision (2026-06-10): **`ctx: usize`**, over an unsafe-Send
-`*mut c_void` newtype and over a generic observer type parameter.
-
-Revised 2026-06-11: a parallel branch (`feat/embassy-mem-channel-cap`)
-independently reshaped the callback to a library-parsed
-`(service_id, method_id, payload, e2e_status)` contract. The
-reconciled contract is the union of both — ctx + source + decoded
-fields — keeping parse/E2E in the audited crate (MISRA/ASIL) rather
-than in N C consumers. `e2e_status` is 0 (unchecked) on the server
-path today; `source` is future-proofing (unused by halo and dft).
-
-```rust
-pub type NonSdRequestCallback = fn(
- ctx: usize,
- source: core::net::SocketAddrV4,
- service_id: u16,
- method_id: u16,
- payload: &[u8],
- e2e_status: u8,
-);
-```
-
-- Storage everywhere becomes `Option<(NonSdRequestCallback, usize)>`
- — a plain tuple, no wrapper struct: the `Server` field,
- `ServerDeps.non_sd_observer`, and
- `ServerDeps::with_non_sd_observer`. `recv_loop` threads the pair
- down and invokes `cb(ctx, data, src)`.
-- Rationale (recorded on the type alias's doc comment): a stored
- `*mut c_void` makes `Server` `!Send` and breaks `Server::run`'s
- declared `+ Send` bound; `usize` is trivially `Send + Sync`,
- keeps the field `Copy`, and matches the `uintptr_t` the C caller
- holds anyway. halo passes `(dispatch, state_ptr as usize)`;
- Rust-native users pass `(f, 0)`.
-- **No `unsafe` enters this crate.** The library stores, copies, and
- passes back a plain integer through a safe `fn`-pointer call —
- `Server: Send` holds by construction, with no `unsafe impl` and no
- soundness contract for the library to document or uphold. The
- unsafe dereference (`ctx as *mut T`, then `unsafe { &*ptr }`)
- happens in the consumer's callback body — halo's FFI dispatch
- code, which is already unsafe territory and is the only party
- that can verify the pointee's lifetime and thread-safety. Known
- trade-off: `usize` carries no provenance, so the compiler can't
- stop a caller passing a wrong address — but the rejected
- `*mut c_void` newtype was equally untyped; only the
- generic-observer design would have fixed that, at the
- 8th-type-parameter cost.
-- Rejected alternatives, for the record: the unsafe-Send newtype
- moves an unverifiable soundness contract into this library; the
- generic observer adds an 8th type parameter that ripples through
- `ServerDeps`/`ServerHandles`/both cfg-switched alias families,
- and halo's FFI would still write its own unsafe-Send wrapper.
-- Breaking now is free: 0.8.0 is unpublished and halo is the only
- consumer. CHANGELOG gets a breaking-change entry with the
- before/after signature.
-
-## 2. Eager-`Ready` timing documented (no code change)
-
-Decision (2026-06-10): document, don't make lazy.
-`StaticSubscriptionHandle::subscribe`/`unsubscribe` execute the
-locked mutation when the future is *constructed* (inside
-`core::future::ready(...)`), unlike the `Box::pin(async)` impls,
-which are lazy. The only in-tree caller (`runtime.rs`) awaits
-immediately, so laziness would be ~80 lines of poll boilerplate for
-behavior no current caller observes.
-
-- Trait-level note on `SubscriptionHandle::subscribe`/`unsubscribe`:
- implementations with a fully synchronous critical section may
- perform the mutation at future construction; callers must not
- assume construction is side-effect-free.
-- Matching sentence on the `StaticSubscriptionHandle` impl block.
-- The phase-22 preflight patch's hand-written `StaticSubscribeFuture`
- (`.claude/phase22_item5_preflight.patch` in the main worktree) is
- permanently obsolete.
-
-## 3. `announce_only_future` rationale (doc-only)
-
-The method's doc already explains the shared-socket topology
-(supplementary Servers via `new_with_handles` announce on the shared
-SD socket; the primary owns all inbound loops). It gains the honest
-acknowledgment that this partially reintroduces the split-future
-shape phase 21 removed, and why that is acceptable here: an
-announce-only future never touches the recv path, so the
-single-run-future invariant that motivated phase 21b (no two futures
-racing on the same sockets/session counter) is preserved — the
-`started` latch still guards the full run-future.
-
-The originally-planned MSRV check is recorded as moot: the crate is
-edition 2024 (Rust ≥ 1.85); `use<>` precise capture needs only 1.82.
-
-## 4. Strengthen the non-SD-observer negative test
-
-`non_sd_observer_none_preserves_ignore_behavior`
-(`tests/bare_metal_server.rs`) cannot currently fail: `record_none`
-is never wired into the server, so no code path can populate
-`OBSERVED_NONE` regardless of any routing regression.
-
-Replace with negative tests that have a live witness — register the
-recording callback as a real observer, then assert it does NOT fire
-for:
-
-- (i) an SD unicast datagram (exercises the SD-vs-non-SD routing
- branch), and
-- (ii) a non-SD **multicast** datagram (exercises the
- unicast-vs-multicast branch — the mock needs to mark the datagram
- as arriving on the SD/multicast socket; mechanics resolved in the
- implementation plan).
-
-The `None` case shrinks to what it actually proves: the run loop
-processes a non-SD unicast datagram without panicking when no
-observer is registered. The positive test
-(`non_sd_observer_some_receives_unicast_method_request`) is updated
-for the new signature and asserts the ctx value round-trips.
-
-## 5. Stale `UDP_BUFFER_SIZE` rustdoc
-
-Verified false during PR 0 review (2026-06-10): `send_ack`
-(`src/server/runtime.rs`) builds into a stack
-`[0u8; crate::UDP_BUFFER_SIZE]`, and the `server,bare_metal` rlib
-audits to zero allocator symbols. The Vec-to-stack conversion was
-the phase-21 per-event-allocation cleanup (`7c58649`, PR #114
-stack), not #124 — the rustdoc claim was stale even before #124,
-which never touched those paths. The constant's rustdoc paragraph
-claiming announcement builders / `SubscribeAck`/`Nack` "still use
-heap `Vec` buffers — known gap" is rewritten: all outbound SD paths
-are stack-buffered and capped by `UDP_BUFFER_SIZE`.
-
-## Error handling
-
-No new fallible paths. The callback invocation remains
-fire-and-forget from `recv_loop` (a misbehaving callback is the
-consumer's responsibility — same contract as today, restated in the
-type-alias docs).
-
-## Verification
-
-fmt, clippy `--workspace --all-features` + `--no-default-features`
-(both `-D warnings -D clippy::pedantic`), full suite at
-`--test-threads=1`, doc tests, the three `-Zbuild-std=core` thumb
-builds, and the `nm` server audit — the latter two also enforced in
-CI since PR 0. Future-size witnesses from PR 0 must stay within
-budget (the tuple adds 2×usize to the run-future capture; budgets
-have 25% headroom).
diff --git a/docs/simple_someip/plans/2026-06-10-pr1-124-followups.md b/docs/simple_someip/plans/2026-06-10-pr1-124-followups.md
deleted file mode 100644
index 8a42ed2f..00000000
--- a/docs/simple_someip/plans/2026-06-10-pr1-124-followups.md
+++ /dev/null
@@ -1,869 +0,0 @@
-# PR 1 — #124 Follow-ups Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Land the four #124 review follow-ups — ctx-carrying `NonSdRequestCallback` (breaking), eager-`Ready` timing docs, `announce_only_future` rationale, live-witness negative tests — plus the stale `UDP_BUFFER_SIZE` rustdoc fix.
-
-**Architecture:** One breaking signature change (`fn(data, src)` → `fn(ctx: usize, data, src)`, stored as `Option<(NonSdRequestCallback, usize)>` on `ServerDeps`/`ServerHandles`/`Server` and threaded through `recv_loop`); a mock-transport split in `tests/bare_metal_server.rs` (per-socket pipes routed by `multicast_if_v4`, making `from_unicast` deterministic); the rest is documentation and CHANGELOG.
-
-**Tech Stack:** Rust (stable; nightly only for the final `-Zbuild-std=core` verification), cargo, GNU `nm`.
-
-**Spec:** `docs/simple_someip/plans/2026-06-10-pr1-124-followups-design.md`
-
-**Working tree:** the `simple_someip-pr0` worktree, branch `feature/pr1_124_followups` (created off `feature/pr0_measurement_harness` at the design commit). PR base = `feature/pr0_measurement_harness`.
-
----
-
-### Task 1: Verify preconditions
-
-**Files:** none (git only)
-
-- [ ] **Step 1: Confirm branch and clean tree**
-
-Run: `git branch --show-current && git status --short`
-Expected: `feature/pr1_124_followups`, no output from status (clean tree; the design doc commit `da3e02c` or later is HEAD).
-
-- [ ] **Step 2: Confirm the baseline is green**
-
-Run: `cargo test --features server-tokio,client-tokio,bare_metal --tests --lib -- --test-threads=1 2>&1 | grep -E "^test result" | grep -v "0 failed" || echo ALL_GREEN`
-Expected: `ALL_GREEN`
-
----
-
-### Task 2: ctx argument on `NonSdRequestCallback` (breaking)
-
-Red first: rewrite the test-side callbacks and the positive test to the NEW signature (compile failure is the failing test), then change the library, then green.
-
-**Files:**
-- Modify: `tests/bare_metal_server.rs:365-385` (statics + record fns), `:416-492` (positive test)
-- Modify: `src/server/mod.rs:645` (type alias), `:285-293` (ServerDeps field), `:409-417` (builder), `:519-521` (ServerHandles field), `:629-634` (Server field)
-- Modify: `src/server/runtime.rs:443`, `:588` (params), `:543-545` (invocation)
-
-- [ ] **Step 1: Update the test-side statics and record fns to the new shape**
-
-Replace the two statics and two fns at `tests/bare_metal_server.rs:367-379` with:
-
-```rust
-static OBSERVED_SOME: OnceLock, SocketAddrV4)>>> = OnceLock::new();
-
-fn record_some(ctx: usize, data: &[u8], source: SocketAddrV4) {
- let slot = OBSERVED_SOME.get_or_init(|| Mutex::new(None));
- *slot.lock().unwrap() = Some((ctx, data.to_vec(), source));
-}
-```
-
-(`OBSERVED_NONE` / `record_none` are deleted here; Task 4 replaces the test that used them. If the file temporarily fails to compile because the old negative test still references them, stub the references by deleting the body of `non_sd_observer_none_preserves_ignore_behavior` down to `let _ = ();` — Task 4 rewrites it entirely.)
-
-- [ ] **Step 2: Update the positive test to the new signature + ctx round-trip**
-
-In `non_sd_observer_some_receives_unicast_method_request`:
-
-```rust
- non_sd_observer: Some((record_some as NonSdRequestCallback, 0xC0FF_EE00)),
-```
-
-and replace the result-destructuring + asserts at the end with:
-
-```rust
- let (got_ctx, got_data, got_src) = OBSERVED_SOME
- .get()
- .unwrap()
- .lock()
- .unwrap()
- .clone()
- .expect("callback fired");
- assert_eq!(
- got_ctx, 0xC0FF_EE00,
- "callback must receive the registered ctx word verbatim"
- );
- assert_eq!(
- got_data, payload,
- "callback must receive the full raw datagram bytes"
- );
- assert_eq!(got_src, src, "callback must receive the original source");
-```
-
-- [ ] **Step 3: Verify it fails to compile (red)**
-
-Run: `cargo test --no-run --features server,bare_metal --test bare_metal_server 2>&1 | tail -5`
-Expected: FAIL — type mismatch (`Option` vs the tuple) — the library hasn't changed yet.
-
-- [ ] **Step 4: Change the type alias**
-
-`src/server/mod.rs:636-645` — replace the alias and its doc:
-
-```rust
-/// Callback invoked by the server's `recv_loop` for every non-SD
-/// unicast datagram received on the service's port (i.e. method
-/// requests / fire-and-forget calls to the offered services). The
-/// payload is the full raw datagram bytes; the caller is responsible
-/// for re-parsing the SOME/IP header (and applying any E2E check) on
-/// the consumer side.
-///
-/// `ctx` is an opaque caller-owned context word, registered alongside
-/// the callback as a `(NonSdRequestCallback, usize)` pair and passed
-/// back verbatim on every invocation. It is deliberately `usize`
-/// rather than `*mut c_void`: a stored raw pointer would make
-/// [`Server`] `!Send` and break [`Server::run`]'s declared `+ Send`
-/// bound, while `usize` is trivially `Send + Sync` and matches the
-/// `uintptr_t` an FFI caller holds anyway. No `unsafe` enters this
-/// crate — the cast back to a pointer (and its safety justification)
-/// lives in the consumer's callback body, the only place that knows
-/// the pointee's lifetime and thread-safety. Rust-native users that
-/// need no context pass `0`. `fn` pointers are
-/// `Copy + Send + Sync + 'static`, so the pair can be stored on the
-/// `Server` and captured by the run-future without adding a new
-/// generic.
-pub type NonSdRequestCallback = fn(ctx: usize, data: &[u8], source: core::net::SocketAddrV4);
-```
-
-- [ ] **Step 5: Change the three struct fields and the builder**
-
-`src/server/mod.rs:287-293` (`ServerDeps` field — replace doc + type):
-
-```rust
- /// Optional `(callback, ctx)` pair invoked from the server's receive
- /// loop for every non-SD **unicast** datagram (method requests /
- /// fire-and-forget calls to offered services). `None` reproduces the
- /// historical "non-SD ignored" behavior. The callback receives the
- /// opaque `ctx` word back verbatim, plus the full raw datagram bytes
- /// and the source `SocketAddrV4`; the consumer is responsible for
- /// re-parsing the SOME/IP header and any E2E check.
- pub non_sd_observer: Option<(NonSdRequestCallback, usize)>,
-```
-
-`src/server/mod.rs:409-417` (`ServerDeps::with_non_sd_observer`):
-
-```rust
- /// Register a `(callback, ctx)` pair invoked for every non-SD unicast
- /// datagram (method requests / fire-and-forget calls to offered
- /// services). The opaque `ctx` word is passed back verbatim on every
- /// invocation — FFI callers stash a pointer here as `usize`;
- /// pure-Rust callers that need no context pass `0`. Passing `None`
- /// (the default if unset) preserves the historical "ignore non-SD"
- /// behavior.
- #[must_use]
- pub fn with_non_sd_observer(
- mut self,
- observer: Option<(NonSdRequestCallback, usize)>,
- ) -> Self {
- self.non_sd_observer = observer;
- self
- }
-```
-
-`src/server/mod.rs:519-521` (`ServerHandles` field):
-
-```rust
- /// Optional `(callback, ctx)` pair for non-SD unicast datagrams
- /// (method requests). `None` reproduces the default "non-SD
- /// ignored" behavior.
- pub non_sd_observer: Option<(NonSdRequestCallback, usize)>,
-```
-
-`src/server/mod.rs:629-634` (`Server` field — keep the existing doc sentence about halo's HWP1 dispatch, change the type):
-
-```rust
- non_sd_observer: Option<(NonSdRequestCallback, usize)>,
-```
-
-All `non_sd_observer: None` initializers and the struct-update copies (`non_sd_observer: self.non_sd_observer`, `non_sd_observer: deps_non_sd_observer`, …) compile unchanged — do not touch them.
-
-- [ ] **Step 6: Thread the pair through `recv_loop`**
-
-`src/server/runtime.rs:443` and `:588` — both parameter declarations become:
-
-```rust
- non_sd_observer: Option<(super::NonSdRequestCallback, usize)>,
-```
-
-`src/server/runtime.rs:543-545` — the invocation becomes:
-
-```rust
- if let Some((cb, ctx)) = non_sd_observer {
- if let core::net::SocketAddr::V4(src_v4) = addr {
- cb(ctx, data, src_v4);
- }
-```
-
-- [ ] **Step 7: Verify green**
-
-Run: `cargo test --features server-tokio,client-tokio,bare_metal --tests --lib -- --test-threads=1 2>&1 | grep -E "^test result"`
-Expected: all `ok`, 0 failed (the gutted negative test passes vacuously until Task 4).
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add src/server/mod.rs src/server/runtime.rs tests/bare_metal_server.rs
-git commit -m "feat(server)!: NonSdRequestCallback gains an opaque ctx:usize argument
-
-Stored as (callback, ctx) on ServerDeps/ServerHandles/Server and
-passed back verbatim from recv_loop. usize over *mut c_void keeps
-Server: Send (run's declared + Send bound survives) with no unsafe
-in this crate; the pointer cast lives in the consumer's callback.
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 3: Per-socket mock pipes in `tests/bare_metal_server.rs`
-
-Both mock sockets currently pop the SAME `inbound` queue, so which select
-arm wins (and therefore `from_unicast`) depends on the alternating
-`select_biased!` bias — latent nondeterminism, and a blocker for Task 4's
-multicast test. Route by `SocketOptions`: the server binds its SD socket
-with `multicast_if_v4 = Some(..)` (`src/server/mod.rs:880`), the unicast
-socket with plain `SocketOptions::new()`.
-
-**Files:**
-- Modify: `tests/bare_metal_server.rs:53-77` (factory), all four test constructors
-
-- [ ] **Step 1: Split the factory**
-
-Replace `MockFactory` and its `bind`:
-
-```rust
-#[derive(Clone)]
-struct MockFactory {
- /// Handed to sockets bound WITHOUT multicast options — the
- /// server's unicast service socket.
- unicast_pipe: Arc,
- /// Handed to sockets bound WITH `multicast_if_v4` set — the
- /// server's SD socket. Per-socket queues make `recv_loop`'s
- /// `from_unicast` flag deterministic: with a single shared queue,
- /// whichever select arm polled first stole the datagram, so
- /// routing depended on the alternating `select_biased!` bias.
- sd_pipe: Arc,
- next_port: Arc>,
-}
-
-impl TransportFactory for MockFactory {
- type Socket = MockSocket;
- type BindFuture<'a> =
- core::pin::Pin> + Send + 'a>>;
- fn bind<'a>(&'a self, addr: SocketAddrV4, options: &'a SocketOptions) -> Self::BindFuture<'a> {
- let pipe = if options.multicast_if_v4.is_some() {
- Arc::clone(&self.sd_pipe)
- } else {
- Arc::clone(&self.unicast_pipe)
- };
- // Mock: assign port deterministically. If caller asked for 0,
- // hand out an incrementing fake ephemeral port.
- let port = if addr.port() == 0 {
- let mut p = self.next_port.lock().unwrap();
- let next = *p + 1;
- *p = next;
- 40000 + next
- } else {
- addr.port()
- };
- let local = SocketAddrV4::new(*addr.ip(), port);
- Box::pin(async move { Ok(MockSocket { pipe, local }) })
- }
-}
-```
-
-- [ ] **Step 2: Update every factory construction site**
-
-In all four tests (`server_constructible_without_server_tokio_feature`,
-`passive_server_constructible_without_server_tokio_feature`,
-`non_sd_observer_some_receives_unicast_method_request`, and the gutted
-negative test), replace:
-
-```rust
- let pipe = Arc::new(MockPipe::default());
- let factory = MockFactory {
- pipe: Arc::clone(&pipe),
- next_port: Arc::new(Mutex::new(0)),
- };
-```
-
-with:
-
-```rust
- let unicast_pipe = Arc::new(MockPipe::default());
- let sd_pipe = Arc::new(MockPipe::default());
- let factory = MockFactory {
- unicast_pipe: Arc::clone(&unicast_pipe),
- sd_pipe: Arc::clone(&sd_pipe),
- next_port: Arc::new(Mutex::new(0)),
- };
-```
-
-In the positive test, the datagram pushes change `pipe.` → `unicast_pipe.`
-(both the `inbound` push and the `inbound_waker` wake), and DELETE the
-now-false comment block above the push ("Queue a non-SD unicast … relying
-on the `select_biased!`'s prefer-unicast tick…") — replace it with:
-
-```rust
- // Queue a non-SD unicast method-request datagram on the unicast
- // socket's own pipe; per-socket pipes make `from_unicast = true`
- // deterministic.
-```
-
-- [ ] **Step 3: Verify green**
-
-Run: `cargo test --features server,bare_metal --test bare_metal_server -- --test-threads=1 2>&1 | grep -E "^test result"`
-Expected: `ok`, 0 failed.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add tests/bare_metal_server.rs
-git commit -m "test(server): per-socket mock pipes routed by multicast_if_v4
-
-Removes the shared-queue nondeterminism (from_unicast depended on
-select_biased bias order) and enables deterministic SD-socket
-injection for the negative observer tests.
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 4: Live-witness negative observer tests
-
-The old `non_sd_observer_none_preserves_ignore_behavior` could not fail:
-its witness callback was never registered, so no routing regression could
-populate `OBSERVED_NONE`. Replace it with two tests whose observer IS
-registered and must NOT fire, plus a slim no-panic `None` case.
-
-**Files:**
-- Modify: `tests/bare_metal_server.rs` (new helper + statics, replace the negative test)
-
-- [ ] **Step 1: Add the SD datagram builder next to `build_method_request`**
-
-```rust
-/// Build a minimal, well-formed SOME/IP-SD datagram: SD message id
-/// (0xFFFF / 0x8100), then an SD payload with flags + reserved and
-/// ZERO entries / options. Routing-wise a legitimate (if vacuous) SD
-/// message — `recv_loop` must hand it to SD handling, never to the
-/// non-SD observer, regardless of which socket it arrived on.
-fn build_sd_message() -> Vec {
- let mut buf = Vec::with_capacity(28);
- buf.extend_from_slice(&0xFFFFu16.to_be_bytes()); // message_id (high): SD service
- buf.extend_from_slice(&0x8100u16.to_be_bytes()); // message_id (low): SD method
- buf.extend_from_slice(&20u32.to_be_bytes()); // length = header(8) + sd payload(12)
- buf.extend_from_slice(&0u32.to_be_bytes()); // request_id
- buf.push(1); // protocol_version
- buf.push(1); // interface_version
- buf.push(2); // message_type = Notification (0x02)
- buf.push(0); // return_code = OK
- buf.push(0x80); // SD flags: reboot
- buf.extend_from_slice(&[0, 0, 0]); // reserved
- buf.extend_from_slice(&0u32.to_be_bytes()); // entries array length = 0
- buf.extend_from_slice(&0u32.to_be_bytes()); // options array length = 0
- buf
-}
-```
-
-- [ ] **Step 2: Add per-test witness statics + record fns (next to `OBSERVED_SOME`)**
-
-Separate statics per test — they share the process under parallel `cargo test`.
-
-```rust
-static OBSERVED_SD_UNICAST: OnceLock, SocketAddrV4)>>> =
- OnceLock::new();
-static OBSERVED_MULTICAST: OnceLock, SocketAddrV4)>>> =
- OnceLock::new();
-
-fn record_sd_unicast(ctx: usize, data: &[u8], source: SocketAddrV4) {
- let slot = OBSERVED_SD_UNICAST.get_or_init(|| Mutex::new(None));
- *slot.lock().unwrap() = Some((ctx, data.to_vec(), source));
-}
-
-fn record_multicast(ctx: usize, data: &[u8], source: SocketAddrV4) {
- let slot = OBSERVED_MULTICAST.get_or_init(|| Mutex::new(None));
- *slot.lock().unwrap() = Some((ctx, data.to_vec(), source));
-}
-```
-
-- [ ] **Step 3: Replace the gutted negative test with three tests**
-
-```rust
-/// A registered observer must NOT fire for an SD message arriving on
-/// the unicast socket — SD-formatted unicast traffic (e.g. unicast
-/// FindService) routes to SD handling. Unlike the pre-PR-1 negative
-/// test, the witness callback IS registered, so a routing regression
-/// (SD datagrams leaking to the observer) trips the assertion.
-#[tokio::test]
-async fn non_sd_observer_ignores_sd_message_on_unicast_socket() {
- let unicast_pipe = Arc::new(MockPipe::default());
- let sd_pipe = Arc::new(MockPipe::default());
- let factory = MockFactory {
- unicast_pipe: Arc::clone(&unicast_pipe),
- sd_pipe: Arc::clone(&sd_pipe),
- next_port: Arc::new(Mutex::new(0)),
- };
-
- let e2e_handle: Arc> = Arc::new(Mutex::new(E2ERegistry::new()));
- let config = ServerConfig::new(0x1234, 1)
- .with_interface(Ipv4Addr::LOCALHOST)
- .with_local_port(30702);
-
- let deps: ServerDeps>, MockSubscriptions> =
- ServerDeps {
- factory,
- timer: MockTimer,
- e2e_registry: e2e_handle,
- subscriptions: MockSubscriptions::default(),
- non_sd_observer: Some((record_sd_unicast as NonSdRequestCallback, 7)),
- };
-
- let (_server, _handles, run): (
- Server>, MockSubscriptions>,
- _,
- _,
- ) = Server::new_with_deps(deps, config, false)
- .await
- .expect("Server::new_with_deps must succeed");
- let handle = tokio::spawn(run);
-
- let src = SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 102), 40002);
- unicast_pipe
- .inbound
- .lock()
- .unwrap()
- .push_back((build_sd_message(), src));
- if let Some(w) = unicast_pipe.inbound_waker.lock().unwrap().take() {
- w.wake();
- }
-
- // No positive completion signal exists for "was ignored" — give
- // the run-future a generous processing window, then assert.
- for _ in 0..50 {
- tokio::task::yield_now().await;
- }
- tokio::time::sleep(Duration::from_millis(10)).await;
-
- let observed = OBSERVED_SD_UNICAST.get().and_then(|m| m.lock().unwrap().clone());
- assert!(
- observed.is_none(),
- "observer must NOT fire for SD messages; got {observed:?}"
- );
- handle.abort();
- let _ = handle.await;
-}
-
-/// A registered observer must NOT fire for a non-SD datagram arriving
-/// on the SD/multicast socket — the observer contract is unicast-only
-/// (`from_unicast == true`).
-#[tokio::test]
-async fn non_sd_observer_ignores_non_sd_on_multicast_socket() {
- let unicast_pipe = Arc::new(MockPipe::default());
- let sd_pipe = Arc::new(MockPipe::default());
- let factory = MockFactory {
- unicast_pipe: Arc::clone(&unicast_pipe),
- sd_pipe: Arc::clone(&sd_pipe),
- next_port: Arc::new(Mutex::new(0)),
- };
-
- let e2e_handle: Arc> = Arc::new(Mutex::new(E2ERegistry::new()));
- let config = ServerConfig::new(0x1234, 1)
- .with_interface(Ipv4Addr::LOCALHOST)
- .with_local_port(30703);
-
- let deps: ServerDeps>, MockSubscriptions> =
- ServerDeps {
- factory,
- timer: MockTimer,
- e2e_registry: e2e_handle,
- subscriptions: MockSubscriptions::default(),
- non_sd_observer: Some((record_multicast as NonSdRequestCallback, 9)),
- };
-
- let (_server, _handles, run): (
- Server>, MockSubscriptions>,
- _,
- _,
- ) = Server::new_with_deps(deps, config, false)
- .await
- .expect("Server::new_with_deps must succeed");
- let handle = tokio::spawn(run);
-
- let src = SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 103), 40003);
- sd_pipe
- .inbound
- .lock()
- .unwrap()
- .push_back((build_method_request(0x1234, 0x0001), src));
- if let Some(w) = sd_pipe.inbound_waker.lock().unwrap().take() {
- w.wake();
- }
-
- for _ in 0..50 {
- tokio::task::yield_now().await;
- }
- tokio::time::sleep(Duration::from_millis(10)).await;
-
- let observed = OBSERVED_MULTICAST.get().and_then(|m| m.lock().unwrap().clone());
- assert!(
- observed.is_none(),
- "observer must NOT fire for non-unicast datagrams; got {observed:?}"
- );
- handle.abort();
- let _ = handle.await;
-}
-
-/// With `non_sd_observer: None`, a non-SD unicast datagram is processed
-/// without panicking (historical "ignore" behavior). This is all the
-/// `None` case can actually prove — there is no callback to witness.
-#[tokio::test]
-async fn non_sd_observer_none_preserves_ignore_behavior() {
- let unicast_pipe = Arc::new(MockPipe::default());
- let sd_pipe = Arc::new(MockPipe::default());
- let factory = MockFactory {
- unicast_pipe: Arc::clone(&unicast_pipe),
- sd_pipe: Arc::clone(&sd_pipe),
- next_port: Arc::new(Mutex::new(0)),
- };
-
- let e2e_handle: Arc> = Arc::new(Mutex::new(E2ERegistry::new()));
- let config = ServerConfig::new(0x1234, 1)
- .with_interface(Ipv4Addr::LOCALHOST)
- .with_local_port(30701);
-
- let deps: ServerDeps>, MockSubscriptions> =
- ServerDeps {
- factory,
- timer: MockTimer,
- e2e_registry: e2e_handle,
- subscriptions: MockSubscriptions::default(),
- non_sd_observer: None,
- };
-
- let (_server, _handles, run): (
- Server>, MockSubscriptions>,
- _,
- _,
- ) = Server::new_with_deps(deps, config, false)
- .await
- .expect("Server::new_with_deps must succeed");
- let handle = tokio::spawn(run);
-
- let src = SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 101), 40001);
- unicast_pipe
- .inbound
- .lock()
- .unwrap()
- .push_back((build_method_request(0x1234, 0x0001), src));
- if let Some(w) = unicast_pipe.inbound_waker.lock().unwrap().take() {
- w.wake();
- }
-
- for _ in 0..50 {
- tokio::task::yield_now().await;
- }
- tokio::time::sleep(Duration::from_millis(10)).await;
-
- assert!(
- !handle.is_finished(),
- "run-future must keep running (no panic / no error) after \
- ignoring a non-SD datagram with no observer registered"
- );
- handle.abort();
- let _ = handle.await;
-}
-```
-
-- [ ] **Step 4: Run the new tests — verify they pass, then verify they CAN fail**
-
-Run: `cargo test --features server,bare_metal --test bare_metal_server -- --test-threads=1 2>&1 | grep -E "^test result"`
-Expected: `ok`, 0 failed.
-
-Sanity-check the witness is live: temporarily change `else if from_unicast`
-at `src/server/runtime.rs` to `else if true`, rerun
-`cargo test --features server,bare_metal --test bare_metal_server non_sd_observer_ignores_non_sd_on_multicast -- --test-threads=1`,
-expect FAIL (observer fired); **revert the temporary change** and rerun to
-green before committing.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add tests/bare_metal_server.rs
-git commit -m "test(server): live-witness negative tests for the non-SD observer
-
-The old None-case test could not fail (its witness was never
-registered). Replace with: registered observer must not fire for SD
-messages on the unicast socket nor for non-SD datagrams on the SD
-socket; the None case shrinks to its real guarantee (no panic).
-Refutation-checked by inverting the from_unicast branch.
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 5: Eager-`Ready` timing docs (spec item 2, doc-only)
-
-**Files:**
-- Modify: `src/server/subscription_manager.rs:321-341` (trait method docs), `:495-502` (impl comment)
-
-- [ ] **Step 1: Trait method notes**
-
-On `SubscriptionHandle::subscribe` (after the "Idempotent…" paragraph, before `fn subscribe`):
-
-```rust
- /// Timing note: implementations whose critical section is fully
- /// synchronous (e.g. `StaticSubscriptionHandle`) may perform the
- /// mutation when the future is *constructed*, deferring only the
- /// result delivery to the poll. Callers must not assume that
- /// constructing the returned future is free of side effects.
-```
-
-On `unsubscribe` (after "Remove a subscriber from an event group."):
-
-```rust
- /// Same construction-time-mutation caveat as [`Self::subscribe`].
-```
-
-- [ ] **Step 2: Impl-side sentence**
-
-In the comment block above `type SubscribeFuture` in
-`impl SubscriptionHandle for StaticSubscriptionHandle`
-(`src/server/subscription_manager.rs:496-502`), append after
-"…satisfying any `Send`-checked run path.":
-
-```rust
- // Consequence (documented on the trait): the mutation runs
- // eagerly at future construction; only the result is delivered
- // through the poll. The in-tree caller awaits immediately, so
- // the difference from the lazy boxed impls is unobservable
- // there.
-```
-
-- [ ] **Step 3: Verify docs build + commit**
-
-Run: `cargo doc --no-deps --features server,bare_metal 2>&1 | tail -2` — expect `Finished`.
-
-```bash
-git add src/server/subscription_manager.rs
-git commit -m "docs(server): document eager-at-construction timing of Ready-based subscribe
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 6: `announce_only_future` rationale (spec item 3, doc-only)
-
-**Files:**
-- Modify: `src/server/mod.rs:1286-1296` (doc comment)
-
-- [ ] **Step 1: Append to the doc comment**
-
-After "…competing for inbound datagrams." and before "The returned future
-loops forever…", insert:
-
-```rust
- /// Design note: this partially reintroduces the split-future shape
- /// phase 21 removed — deliberately. An announce-only future never
- /// touches the receive path, so the invariant that motivated the
- /// phase-21 combined run-future (no two futures racing the same
- /// sockets and SD session counter) is preserved: the [`Self::run`]
- /// path is still guarded by the first-poll `started` latch, and
- /// supplementary announce loops only ever *send* on the shared SD
- /// socket.
- ///
-```
-
-- [ ] **Step 2: Verify + commit**
-
-Run: `cargo doc --no-deps --features server,bare_metal 2>&1 | tail -2` — expect `Finished`.
-
-```bash
-git add src/server/mod.rs
-git commit -m "docs(server): record why announce_only_future may split the run shape
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 7: `UDP_BUFFER_SIZE` rustdoc trues-up (spec item 5)
-
-**Files:**
-- Modify: `src/lib.rs:145-150`
-
-- [ ] **Step 1: Confirm the claim is stale before editing**
-
-Run: `grep -rn "alloc::vec\|Vec::with_capacity\|vec!" src/server/runtime.rs src/protocol/sd/ --include="*.rs" | grep -v test`
-Expected: no production-path `std`/`alloc` `Vec` hits (heapless only). `send_ack`/`send_nack` build into `[0u8; crate::UDP_BUFFER_SIZE]` (`src/server/runtime.rs`, inside `send_ack`); the `server,bare_metal` rlib `nm`-audits to zero allocator symbols (CI since PR 0). If a real heap `Vec` shows up in an outbound SD path, STOP — the doc is not stale and the spec is wrong; report back.
-
-- [ ] **Step 2: Rewrite the stale sentence**
-
-Replace (in the `UDP_BUFFER_SIZE` doc):
-
-```text
-Paths that return early before
-attempting serialization (e.g. `publish_event` when there are no
-subscribers) are not affected. Other outbound SD paths (announcement
-builders, `SubscribeAck` / `SubscribeNack`) currently still use
-heap `Vec` buffers and are not capped by this constant — that is a
-known gap, planned alongside the bare-metal `no_alloc` refactor.
-```
-
-with:
-
-```text
-Paths that return early before
-attempting serialization (e.g. `publish_event` when there are no
-subscribers) are not affected. The remaining outbound SD paths
-(`OfferService` announcements, `SubscribeAck` / `SubscribeNack`)
-serialize into stack buffers of this same size — PR #124's no-alloc
-server work removed the former heap `Vec` buffers, so every outbound
-path is capped by this constant.
-```
-
-- [ ] **Step 3: Verify + commit**
-
-Run: `cargo doc --no-deps 2>&1 | tail -2` — expect `Finished`.
-
-```bash
-git add src/lib.rs
-git commit -m "docs: UDP_BUFFER_SIZE rustdoc — outbound SD paths are stack-buffered since #124
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 8: CHANGELOG
-
-**Files:**
-- Modify: `CHANGELOG.md` (0.8.0 section — add a `#### Breaking` block after the existing GAT one, matching its style)
-
-- [ ] **Step 1: Add the entry**
-
-````markdown
-#### Breaking — `NonSdRequestCallback` gains an opaque `ctx: usize` first argument
-
-```rust
-// before
-pub type NonSdRequestCallback = fn(data: &[u8], source: SocketAddrV4);
-// after
-pub type NonSdRequestCallback = fn(ctx: usize, data: &[u8], source: SocketAddrV4);
-```
-
-The observer is now registered as a `(callback, ctx)` pair
-(`ServerDeps::non_sd_observer: Option<(NonSdRequestCallback, usize)>`),
-and `ctx` is passed back verbatim on every invocation. FFI consumers
-stash their state pointer as `usize`; pure-Rust callers pass `0`.
-`usize` (rather than a stored `*mut c_void`) keeps `Server: Send` —
-and therefore `Server::run`'s declared `+ Send` bound — with no
-`unsafe` in this crate; the pointer cast lives in the consumer's
-callback body.
-
-##### Migration
-
-`non_sd_observer: Some(my_cb)` → `non_sd_observer: Some((my_cb, 0))`,
-and add the leading `ctx: usize` parameter to the callback.
-````
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add CHANGELOG.md
-git commit -m "docs: changelog for the ctx-carrying NonSdRequestCallback break
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 9: Spec correction, full verification, push, PR
-
-**Files:**
-- Modify: `docs/simple_someip/plans/2026-06-10-pr1-124-followups-design.md` (one word)
-
-- [ ] **Step 1: Fix the builder owner in the design doc**
-
-The spec says `ServerConfig::with_non_sd_observer`; the builder lives on
-**ServerDeps**. Replace that one occurrence with
-`ServerDeps::with_non_sd_observer` and commit:
-
-```bash
-git add docs/simple_someip/plans/2026-06-10-pr1-124-followups-design.md
-git commit -m "docs: with_non_sd_observer lives on ServerDeps, not ServerConfig
-
-Co-Authored-By: Claude Fable 5 "
-```
-
-- [ ] **Step 2: Full local verification (mirrors CI)**
-
-```bash
-cargo fmt --check
-cargo clippy --workspace --all-features -- -D warnings -D clippy::pedantic
-cargo clippy --no-default-features -- -D warnings -D clippy::pedantic
-cargo test --features server-tokio,client-tokio,bare_metal --tests --lib -- --test-threads=1
-cargo test --doc
-cargo +nightly build --no-default-features --features client,bare_metal -Zbuild-std=core --target thumbv7em-none-eabihf
-cargo +nightly build --no-default-features --features server,bare_metal -Zbuild-std=core --target thumbv7em-none-eabihf
-cargo +nightly build --no-default-features --features client,server,bare_metal -Zbuild-std=core --target thumbv7em-none-eabihf
-cargo clean -p simple-someip --target thumbv7em-none-eabihf
-cargo build --target thumbv7em-none-eabihf --no-default-features --features server,bare_metal
-nm -A target/thumbv7em-none-eabihf/debug/libsimple_someip.rlib | grep -c -E '__rust_alloc|__rg_alloc'
-```
-
-Expected: fmt/clippy clean; tests green; doc tests green; three `Finished`;
-`nm` count `0`. The PR 0 future-size witnesses run inside the test step —
-the `(fn, usize)` capture adds 8 bytes to the server run-future, far
-inside the 25% budget headroom; if a witness trips, STOP and investigate
-rather than raising a budget.
-
-- [ ] **Step 3: Push and open the PR (stacked on PR 0)**
-
-```bash
-git push -u origin feature/pr1_124_followups
-gh pr create --base feature/pr0_measurement_harness \
- --title "PR 1: #124 follow-ups — ctx-carrying NonSdRequestCallback + doc trues-up (#125 stack)" \
- --body "$(cat <<'EOF'
-Second PR of the #125 / phase-22 stack
-(docs/simple_someip/plans/2026-06-10-pr1-124-followups-design.md).
-Stacked on PR 0 (#127); retargets when that merges.
-
-- **BREAKING:** `NonSdRequestCallback` gains an opaque `ctx: usize`
- first argument, registered as a `(callback, ctx)` pair. Keeps
- `Server: Send` with zero `unsafe` in-crate; FFI casts its pointer
- in the callback body. Migration in CHANGELOG.
-- Negative observer tests now have a live witness (the old None-case
- test could not fail); mock transport gets per-socket pipes so
- `from_unicast` is deterministic.
-- Doc trues-up: eager-at-construction timing of the `Ready`-based
- subscribe/unsubscribe; `announce_only_future` split-shape rationale;
- `UDP_BUFFER_SIZE` outbound-SD claim (stack-buffered since #124).
-
-**Merge gate:** Feliciano gets the callback-signature heads-up first
-so no further halo FFI builds on the bare `fn` shape.
-
-🤖 Generated with [Claude Code](https://claude.com/claude-code)
-EOF
-)"
-```
-
----
-
-## Self-review notes (already applied)
-
-- Spec coverage: item 1 → Task 2; item 2 → Task 5; item 3 → Task 6;
- item 4 → Tasks 3+4 (the mock split is the enabling mechanic the spec
- deferred to this plan); item 5 → Task 7; CHANGELOG → Task 8; the
- spec's own ServerConfig/ServerDeps naming slip → Task 9.
-- The `None` initializers needing no edit (Task 2 Step 5) was verified
- against all 20 `non_sd_observer:` sites — only `Some(...)` sites and
- type declarations change; `examples/*` and `tests/bare_metal_e2e.rs`
- all pass `None` and compile unchanged.
-- Task 4's refutation step (invert `from_unicast`, watch the test fail,
- revert) guards against rebuilding another vacuous test.
-
----
-
-## Recorded deviation (2026-06-11, post-execution)
-
-Task 2's callback shape was superseded after execution by the
-cross-branch contract reconciliation: the final signature is the
-union `fn(ctx, source, service_id, method_id, payload, e2e_status)`
-(library parses; e2e_status 0 = unchecked today). See the design
-doc's §1 revision note. Applied as a follow-up commit rather than
-rewriting this plan's task history.
diff --git a/docs/simple_someip/plans/2026-06-11-128-embassy-union-rebase.md b/docs/simple_someip/plans/2026-06-11-128-embassy-union-rebase.md
deleted file mode 100644
index 3bd6c2b0..00000000
--- a/docs/simple_someip/plans/2026-06-11-128-embassy-union-rebase.md
+++ /dev/null
@@ -1,360 +0,0 @@
-# PR #128 Rebase — Union Callback Adoption Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Rebase `feat/embassy-mem-channel-cap` (PR #128, Feliciano's draft) onto the merged #124→#127→#129 spine, dropping its three #124-duplicate commits and adopting the union `NonSdRequestCallback` contract for the runtime's `DispatchFn`, then capture the fresh size baseline that becomes PR 2's "before".
-
-**Architecture:** A 9-commit rebase with a small, fully-enumerated conflict surface (dry-run executed 2026-06-11 — see inventory below), followed by a mechanical union-adoption pass on the new `bare_metal_runtime` files (2 compile errors, both at `runtime.rs:345`), a `DISPATCH_CTX` parallel static, and ctx/source threading through `event_rx_dispatch_future`.
-
-**Tech Stack:** git rebase, Rust (nightly for the runtime's `#![feature]` and `-Zbuild-std`), cargo, GNU `nm`.
-
-**Ownership note:** `feat/embassy-mem-channel-cap` is Feliciano's draft PR. This plan produces a **preview branch**; force-pushing his branch happens only with his explicit ack (Task 8).
-
----
-
-## Dry-run inventory (executed 2026-06-11, preview preserved)
-
-A complete dry run exists: branch `preview/embassy_union_rebase` (tip `35ba14f`) in worktree `/tmp/embassy_rebase_preview`, rebased onto the PR 1 tip `8e41b0e`. Tasks 1–2 below are **already done on that branch** — an executor can resume from it (start at Task 3) or replay from scratch using the recipes. Per-commit results:
-
-| #128 commit | Result on rebase |
-|---|---|
-| `0901274`, `45a4630`, `1a4ca83` | **Dropped by construction** (rewritten duplicates of #124's `be292bb`/`3dafd3e`/`64fcc08`; patch-ids drifted so git will NOT auto-skip them — rebase from `1a4ca83`, not from the branch base) |
-| `16e5b27` pre-bound sockets | clean |
-| `1124531` host rx notify | clean |
-| `7623829` payload/config sizes | clean |
-| `63e549f` CLIENT_SOCKET_CHANNEL_CAP | 1 trivial conflict: `src/client/socket_manager.rs` import adjacency — keep BOTH lines |
-| `0f48c16` ARENA cap cuts | clean |
-| `d56a691` co-offered Subscribe | 1 conflict: `src/server/runtime.rs` — new `accept_subscribe` fn inserted above `handle_sd_message`; take embassy's insertion AND the chain's backticked doc line (`` `FindService` ``) |
-| `77cf725` SD codec + helpers | 5 hunks in `src/server/mod.rs` / `src/server/runtime.rs` / `tests/bare_metal_server.rs` — ALL are union-vs-4-param of the same content; **keep HEAD (the union side) in every hunk**. The commit's new files (`src/sd_codec.rs`, helper fns) apply clean |
-| `3f7d7d1` run_someip | clean |
-| `223bf40` reusable runtime | clean (new files) — but **semantically un-adopted**: leaves exactly 2 compile errors at `src/bare_metal_runtime/runtime.rs:345` (E0308 `Some(fn)` vs `Option<(fn, usize)>`, E0605 4-param→6-param fn cast). Tasks 3–5 fix this |
-
----
-
-### Task 1: Preconditions and base selection
-
-**Files:** none (git only)
-
-- [ ] **Step 1: Pick the rebase target**
-
-The real target is `feature/phase21_api_symmetry` AFTER the spine (#124 → #127 → #129) merges. Until then, the PR 1 tip is content-identical: `origin/feature/pr1_124_followups` (`8e41b0e`). The preserved preview was built against the PR 1 tip. If the spine has merged since, replay Task 2 against the merged phase21 instead of resuming the preview — the inventory above still applies unless #129 was amended in review (check: `git log 8e41b0e..origin/feature/phase21_api_symmetry --oneline -- src/server/` — if #129 landed with changes beyond `8e41b0e`, re-verify the `77cf725` resolutions).
-
-- [ ] **Step 2: Resume or replay?**
-
-Run: `git -C /tmp/embassy_rebase_preview log --oneline -1 2>/dev/null`
-If it prints `35ba14f feat(bare-metal): reusable runtime …`, resume from the preview (skip Task 2). Otherwise replay Task 2.
-
----
-
-### Task 2: The rebase (replay recipe — skip if resuming the preview)
-
-**Files:** conflict resolutions only, per the inventory.
-
-- [ ] **Step 1: Create the worktree and rebase from above the duplicates**
-
-```bash
-git worktree add /tmp/embassy_rebase_preview -b preview/embassy_union_rebase origin/feat/embassy-mem-channel-cap
-cd /tmp/embassy_rebase_preview
-git rebase --onto 1a4ca83
-```
-
-`` = `origin/feature/pr1_124_followups` (pre-spine-merge) or `origin/feature/phase21_api_symmetry` (post-merge). Rebasing from `1a4ca83` drops the three duplicates by construction.
-
-- [ ] **Step 2: Resolve `63e549f` (socket_manager.rs)**
-
-One hunk: HEAD's `use crate::log::{…}` vs embassy's `use super::CLIENT_SOCKET_CHANNEL_CAP;` at the same insertion point. Keep both (CLIENT_SOCKET_CHANNEL_CAP line first, then the log line). `git add` + `git rebase --continue`.
-
-- [ ] **Step 3: Resolve `d56a691` (runtime.rs)**
-
-One hunk: embassy inserts `async fn accept_subscribe(…)` (a ~95-line function) above `handle_sd_message`; HEAD's side of the hunk is only the doc line `/// Handle a Service Discovery message (Subscribe / \`FindService\` etc.).`. Resolution: take the entire embassy insertion, and replace its trailing un-backticked `FindService` doc line with the backticked HEAD version. `git add` + continue.
-
-- [ ] **Step 4: Resolve `77cf725` (3 files, 5 hunks)**
-
-Every hunk is the union contract (HEAD) vs the 4-param contract (embassy) of the SAME semantic content — the union strictly supersedes (it has everything plus `ctx` + `source`). Keep the HEAD side of **every** hunk; do NOT use whole-file `checkout --ours` (it would discard the commit's cleanly-merged additions elsewhere in those files). `git add -u` + continue.
-
-- [ ] **Step 5: Confirm completion**
-
-`3f7d7d1` and `223bf40` apply clean. Expected: `Successfully rebased`. Then `cargo +nightly check --no-default-features --features bare-metal-runtime,client 2>&1 | grep -c "^error"` → exactly 2 (both at `runtime.rs:345`) — that's the Task 3–5 worklist, not a failure.
-
----
-
-### Task 3: Union adoption — `DispatchFn`, `DISPATCH_CTX`, trampoline
-
-**Files:**
-- Modify: `src/bare_metal_runtime/runtime.rs` (~line 63 alias; ~line 174 statics; ~line 263 trampoline; ~line 345 registration; ~line 441 init store; the init config struct — locate fields with `grep -n "pub dispatch\|dispatch:" src/bare_metal_runtime/runtime.rs`)
-
-- [ ] **Step 1: Reshape the alias** (~line 63)
-
-```rust
-/// Platform dispatch sink for inbound messages (decoded by the runtime).
-/// Same shape as [`crate::server::NonSdRequestCallback`] — the union
-/// contract — so a platform can use one handler for both the server's
-/// non-SD observer and the runtime's notification RX path. `ctx` is the
-/// opaque word registered at [`init`]; `source` is the sender;
-/// `e2e_status` is real on the RX path (Profile-5 check) and `0`
-/// (unchecked) on the server-request path.
-pub type DispatchFn = fn(
- ctx: usize,
- source: core::net::SocketAddrV4,
- service_id: u16,
- method_id: u16,
- payload: &[u8],
- e2e_status: u8,
-);
-```
-
-- [ ] **Step 2: Add the parallel ctx static** (next to `static DISPATCH`, ~line 174)
-
-```rust
-static DISPATCH: AtomicUsize = AtomicUsize::new(0); // DispatchFn as usize
-static DISPATCH_CTX: AtomicUsize = AtomicUsize::new(0); // opaque ctx word for DISPATCH
-```
-
-- [ ] **Step 3: Register ctx at init**
-
-The init config struct (the one whose fields feed `SEND_FN`/`NOW_FN`/`DISPATCH` stores at ~line 439-441) gains a field:
-
-```rust
- /// Opaque context word passed back verbatim as the first argument of
- /// every `dispatch` invocation (FFI: stash a pointer as `usize`).
- pub dispatch_ctx: usize,
-```
-
-and beside `DISPATCH.store(...)` (~line 441):
-
-```rust
- DISPATCH_CTX.store(config.dispatch_ctx, Ordering::Release);
-```
-
-**Flag for Feliciano:** if that struct is `#[repr(C)]` consumed from C, this is a C-side ABI addition — field at the END of the struct, and the C header updates with it.
-
-- [ ] **Step 4: Reshape the trampoline** (~line 263)
-
-```rust
-/// Forwards a parsed inbound message to the platform dispatch callback.
-/// The `_ctx` received from the caller is ignored: the runtime's real
-/// ctx lives in [`DISPATCH_CTX`] (registered at [`init`], possibly
-/// re-registered later), so loading it here keeps late re-registration
-/// coherent — callers register/pass `0`.
-fn dispatch(
- _ctx: usize,
- source: core::net::SocketAddrV4,
- service_id: u16,
- method_id: u16,
- payload: &[u8],
- e2e_status: u8,
-) {
- let raw = DISPATCH.load(Ordering::Acquire);
- if raw == 0 {
- return;
- }
- // SAFETY: stored from a valid DispatchFn in `init`.
- let f: DispatchFn = unsafe { core::mem::transmute::(raw) };
- f(
- DISPATCH_CTX.load(Ordering::Acquire),
- source,
- service_id,
- method_id,
- payload,
- e2e_status,
- );
-}
-```
-
-- [ ] **Step 5: Fix the registration** (~line 345)
-
-```rust
- non_sd_observer: Some((dispatch as crate::server::NonSdRequestCallback, 0)),
-```
-
-(`0` because the trampoline injects `DISPATCH_CTX` itself — see Step 4 doc.)
-
-- [ ] **Step 6: Verify the two errors are gone**
-
-Run: `cargo +nightly check --no-default-features --features bare-metal-runtime,client 2>&1 | grep -E "^error" | head`
-Expected: errors at `runtime.rs:345` gone; remaining errors (if any) are in `bare_metal_tasks.rs` — Task 4's worklist.
-
----
-
-### Task 4: Thread ctx + source through `event_rx_dispatch_future`
-
-**Files:**
-- Modify: `src/bare_metal_tasks.rs` (~lines 95-125, plus its callers — locate with `grep -n "event_rx_dispatch_future" src/`)
-
-- [ ] **Step 1: Reshape the helper**
-
-The fn's `dispatch` parameter is currently an inline 4-param fn type. It becomes the union shape plus a pass-through `ctx`, and the receive captures the datagram's source (`ReceivedDatagram.source` is already there — only `bytes_received` was being kept):
-
-```rust
-pub async fn event_rx_dispatch_future<'a, S, R>(
- rx_socket: &'a S,
- e2e: &'a R,
- e2e_enabled: bool,
- dispatch: crate::bare_metal_runtime::DispatchFn,
- ctx: usize,
- buf: &'a mut [u8],
-) where
- S: TransportSocket,
- R: E2ERegistryHandle,
-{
- loop {
- let (n, source) = match rx_socket.recv_from(&mut *buf).await {
- Ok(d) => (d.bytes_received, d.source),
- Err(_) => continue,
- };
- let Some(parsed) = parse_someip_datagram(&buf[..n]) else {
- continue;
- };
- let (status, body) = if e2e_enabled {
- check_parsed_e2e(e2e, &parsed)
- } else {
- (E2ECheckStatus::Unchecked, parsed.payload)
- };
- dispatch(
- ctx,
- source,
- parsed.service_id,
- parsed.method_id,
- body,
- e2e_status_code(status),
- );
- }
-}
-```
-
-NOTE on the `dispatch` param type: if `bare_metal_tasks` must stay decoupled from the `bare-metal-runtime` feature (check the cfg on `bare_metal_runtime`'s module declaration in lib.rs), keep an inline fn type with the same six params instead of naming `DispatchFn`. External (non-runtime) callers pass their real callback + ctx and get verbatim forwarding; the runtime passes its trampoline + `0`.
-
-- [ ] **Step 2: Update the callers**
-
-`run_someip` (in `bare_metal_tasks.rs`) and any direct caller pass the extra `ctx` argument — the runtime's composition passes `0` (trampoline injects). Locate: `grep -n "event_rx_dispatch_future(" src/`.
-
-- [ ] **Step 3: Sweep for leftover 4-param shapes**
-
-Run: `grep -rn "fn(service_id: u16, method_id: u16" src/`
-Expected: zero hits (every dispatch-shaped type is now the union).
-
-- [ ] **Step 4: Full check**
-
-Run: `cargo +nightly check --no-default-features --features bare-metal-runtime,client 2>&1 | tail -2` → `Finished`.
-
-- [ ] **Step 5: Commit** (one commit on the preview branch for Tasks 3+4)
-
-```bash
-git add src/bare_metal_runtime/runtime.rs src/bare_metal_tasks.rs
-git commit -m "feat(bare-metal): DispatchFn adopts the union callback contract
-
-Same six-param shape as NonSdRequestCallback (decision 2026-06-11):
-ctx + source + decoded fields + e2e_status. DISPATCH_CTX parallel
-static carries the opaque word; the dispatch trampoline injects it so
-late re-registration stays coherent; event_rx_dispatch_future threads
-ctx/source through (e2e_status stays REAL on this path).
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 5: CHANGELOG + sd_codec visibility note
-
-**Files:**
-- Modify: `CHANGELOG.md`
-
-- [ ] **Step 1: Behavior notes under the 0.8.0 section**
-
-Append to the existing `#### Breaking — NonSdRequestCallback…` block's vicinity (match file style):
-
-- `PENDING_RESPONSES_CAP` 64→8 is a **global** bound (also tokio): more than 8 outstanding request-response pairs now returns `Err(Error::Capacity(…))`. Sized for the embedded target; raise the const if a host consumer genuinely needs more in flight. (`REQUEST_QUEUE_CAP` 32→4 is NOT consumer-visible: the feeding control channel was always depth 4 on both paths — verified `BoundedSender` + tokio `channel(N)`.)
-- The bare-metal runtime's `DispatchFn` now matches `NonSdRequestCallback` (union shape); the init config gains `dispatch_ctx`.
-
-- [ ] **Step 2: sd_codec visibility — decision recorded, not changed**
-
-`sd_codec::parse_someip_datagram` stays at its current visibility (the runtime's RX path uses it; halo's FFI may too). Narrowing to `pub(crate)` is Feliciano's call on his own PR — leave a PR-comment question, don't change it here.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add CHANGELOG.md
-git commit -m "docs: changelog for ARENA cap bounds + DispatchFn union shape
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 6: Full verification
-
-- [ ] **Step 1: fmt the conflict resolutions**
-
-Run: `cargo fmt` then `git diff --stat` — the Task 2 resolutions (esp. `accept_subscribe`) may need reflow; if fmt changed files, amend them into the rebase HEAD: `git add -u && git commit --amend --no-edit` is WRONG here (HEAD is the Task 5 commit) — instead commit fmt separately: `git commit -m "style: rustfmt over rebase resolutions"`.
-
-- [ ] **Step 2: The matrix**
-
-```bash
-cargo fmt --check
-cargo clippy --workspace --all-features -- -D warnings -D clippy::pedantic
-cargo clippy --no-default-features -- -D warnings -D clippy::pedantic
-cargo test --features server-tokio,client-tokio,bare_metal --tests --lib -- --test-threads=1
-cargo test --doc
-cargo check -p simple-someip-embassy-net --tests
-cargo +nightly build --no-default-features --features client,bare_metal -Zbuild-std=core --target thumbv7em-none-eabihf
-cargo +nightly build --no-default-features --features server,bare_metal -Zbuild-std=core --target thumbv7em-none-eabihf
-cargo +nightly build --no-default-features --features client,server,bare_metal -Zbuild-std=core --target thumbv7em-none-eabihf
-cargo +nightly build --no-default-features --features bare-metal-runtime,client -Zbuild-std=core --target thumbv7em-none-eabihf
-cargo clean -p simple-someip --target thumbv7em-none-eabihf
-cargo build --target thumbv7em-none-eabihf --no-default-features --features server,bare_metal
-nm -A target/thumbv7em-none-eabihf/debug/libsimple_someip.rlib | grep -c -E '__rust_alloc|__rg_alloc'
-```
-
-Expected: all clean; nm prints `0`. The fourth build-std line is NEW (the runtime feature under halo's constraint) — if it fails on `embassy-executor` deps, record the failure verbatim; it's a finding about #128, not about this rebase. The future-size witnesses run inside the test step and must PASS (the cap cuts shrink futures; budgets are upper bounds). If the `#128` clippy surface has pre-existing pedantic warnings the chain's gate now catches (the chain enforces `--workspace --all-features`), fix mechanically and commit as `style:`.
-
-- [ ] **Step 3: Probe-mirror check (`7623829` touched payload/option sizes)**
-
-`tools/size_probe`'s `ProbePayload` mirrors `TestPayload` (`src/protocol/sd/test_support.rs`) field-for-field. `7623829` changed `heapless_payload.rs` / `sd/options.rs` / `static_channels` — verify `TestPayload`/`TestSdHeader` themselves are untouched (`git diff 1a4ca83..HEAD -- src/protocol/sd/test_support.rs` → empty means the mirror holds). `MAX_CONFIGURATION_STRING_LENGTH` changes WILL shift captured layouts — that's expected and handled by Step 4's re-capture, not an error.
-
-- [ ] **Step 4: Fresh baseline — this is PR 2's "before"**
-
-```bash
-tools/capture_type_sizes.sh
-```
-
-Copy the new numbers into a new committed baseline `docs/simple_someip/plans/baselines/post-128-size-baseline.md` (same format as `pr0-size-baseline.md`, with a header noting: captured on the #128-rebased tree; supersedes pr0 baseline as PR 2's "before"; the deltas vs pr0 quantify #128's cap cuts — record them, they're the first real measured win of the stack). Also re-run the host witnesses with `--nocapture` and record the `FUTURE_SIZE` lines.
-
-```bash
-git add docs/simple_someip/plans/baselines/post-128-size-baseline.md
-git commit -m "docs: post-#128 size baseline (PR 2's before; quantifies the cap cuts)
-
-Co-Authored-By: Claude Fable 5 "
-```
-
----
-
-### Task 7: Witness-budget tightening decision (optional, record either way)
-
-The PR 0 witness budgets are `pr0-baseline × 1.25`. After #128's cuts the real sizes drop well below those budgets, leaving slack that could mask a future regression up to the OLD budget. Either tighten the budget consts (`src/client/mod.rs`, `tests/bare_metal_e2e.rs`) to `post-128-baseline × 1.25` in this branch, or record in the new baseline doc that tightening lands with PR 2. **Default: tighten now** — it's two consts per file and the witnesses exist to be tight.
-
----
-
-### Task 8: Handoff (Feliciano coordination — DO NOT force-push his branch unilaterally)
-
-- [ ] **Step 1: Push the preview**
-
-```bash
-git push -u origin preview/embassy_union_rebase
-```
-
-- [ ] **Step 2: PR comment on #128**
-
-Summarize: rebase preview ready; 3 duplicate commits dropped; his 9 commits survive with authorship intact (rebase preserves author); union contract adopted for `DispatchFn` + `DISPATCH_CTX` + init-config `dispatch_ctx` field (C-side ABI addition flagged); the conflict inventory + this plan's path; ask him to either `git reset --hard origin/preview/embassy_union_rebase && git push --force-with-lease` on his branch, or cherry-pick at his leisure. Include the open question: `sd_codec` visibility (keep `pub` for halo FFI, or narrow?).
-
-- [ ] **Step 3: Sequencing reminder**
-
-This branch can only MERGE after the spine (#124→#127→#129) lands in phase21 — it contains the spine. If #129 gets amended in review, re-run Task 1 Step 1's check and rebase the preview again (cheap: the inventory holds).
-
----
-
-## Self-review notes (already applied)
-
-- The dry run IS the spec-coverage check: every #128 commit is accounted for in the inventory table; the 2 compile errors are closed by Tasks 3–4; sweep step (Task 4 Step 3) catches any 4-param stragglers.
-- `e2e_status` semantics differ by path and both docs say so: REAL on the RX/notification path (`check_parsed_e2e`), `0` on the server-request path — the union docs on both aliases carry the distinction.
-- The trampoline-injects-ctx design (register `(dispatch, 0)`) was chosen over registering the real ctx because `DISPATCH`/`DISPATCH_CTX` support late re-registration; the server's stored copy would go stale. Documented on the trampoline.
-- `event_rx_dispatch_future` gets ctx as a pass-through parameter (not a static read) because it's a public spawnable helper — non-runtime callers need verbatim forwarding.
diff --git a/docs/simple_someip/plans/2026-06-17-pr126-polled-port-handoff.md b/docs/simple_someip/plans/2026-06-17-pr126-polled-port-handoff.md
deleted file mode 100644
index 0c90dd05..00000000
--- a/docs/simple_someip/plans/2026-06-17-pr126-polled-port-handoff.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# Handoff: port #126 (polled bare-metal) onto the #125 stack
-
-**For:** Feliciano (owner of the polled module)
-**Branch:** `feat/polled-port-onto-125` — branched off the #133 tip (`82be01d`), the top of the completed #125 stack (`#131 → #127 → #129 → #132 → #133`). This is your port target; it builds clean.
-**Why a port, not a rebase:** `#126`'s `polled.rs` is built on your *own* divergent refactor of the alloc-free server, the `E2ERegistry`, and a new mutex — and the #125 stack reworked those same areas differently. A `git rebase` of `#126` onto #133 hits 15 conflict hunks across `server/mod.rs`/`runtime.rs`/`subscription_manager.rs` and replays commits that duplicate (and contradict) the stack. So the clean path is: branch off #133, bring `polled.rs` over, and adapt it to the stack's APIs.
-
-## Decision already made (Justin, 2026-06-17): take the union callback
-
-`NonSdRequestCallback` is **already the agreed union** on the stack — no change needed there. At `src/server/mod.rs:666`:
-```rust
-pub type NonSdRequestCallback = fn(
- ctx: usize, source: core::net::SocketAddrV4,
- service_id: u16, method_id: u16, payload: &[u8], e2e_status: u8,
-);
-```
-Library-side header parse happens at the call site (`runtime.rs:619`, `view.header()`) — the consumer never hand-rolls parsing (MISRA/ASIL). `ctx: usize` (not `*mut c_void`) keeps `Server: Send`; `source` keeps reply-routing; `e2e_status` is live on the client path. halo uses neither `ctx` nor `source`, but they stay for other consumers. **Your `#126` commit `4c2531d "remove non-SD observer callback"` is therefore dropped — we keep the observer.** Apply this same shape to the runtime `DispatchFn` when you bring it over.
-
-## Your `#126` commits — keep / drop / adapt
-
-| commit | what | action |
-|---|---|---|
-| `4faac4a` make ...alloc-free, add `NonSdRequestCallback` | server/mod, runtime, subscription_manager | **DROP** — superseded by `#131` (stack's alloc-free server) |
-| `4c2531d` remove non-SD observer | server/mod (−28), runtime (−13) | **DROP** — superseded by the union decision |
-| `929962a` const-generic `E2ERegistry` | e2e/registry, subscription_manager (+164), transport, event_publisher | **DON'T adopt into the stack** — see "E2E" below; adapt polled instead |
-| `372c7d4` `SingleContextRawMutex` | new file, subscription_manager, transport | **your call** — bring the primitive if polled needs it (see "mutex") |
-| `25ba82b` sync SOME/IP helpers | new `src/polled.rs` (+285), header.rs | **KEEP** — port |
-| `3b8e483` polled integration | `polled.rs` (+464) | **KEEP** — port |
-| `e7c956c` multi-offer builders | `polled.rs` (+114) | **KEEP** — port |
-
-Bring the polled sources over with:
-```bash
-git checkout feat/polled-port-onto-125
-git checkout origin/feat/polled-bared-metal -- src/polled.rs
-# (and src/single_context_mutex.rs if you decide to keep the mutex)
-# then wire `mod polled;` into src/lib.rs and adapt — see below.
-```
-
-## Target API the stack provides (what to adapt `polled.rs` against)
-
-- **`NonSdRequestCallback`** — the union above (`src/server/mod.rs:666`); registered via `ServerDeps`/`ServerStorage.non_sd_observer: Option<(NonSdRequestCallback, usize)>` and invoked at `runtime.rs:619`.
-- **`E2ERegistry`** (`src/e2e/registry.rs`) is a **plain, non-const-generic** struct: `register(key, profile) -> Result<(), E2ERegistryFull>`, `contains_key(&key) -> bool`, `check(...)`, `protect(...)`. The handle trait `E2ERegistryHandle` and `StaticE2EHandle` live in `src/transport.rs` as the stack left them (PR 2/PR 3). **Your `929962a` changed these to a const-generic (`E2E_CAP`) shape** — `polled.rs` (`check_parsed_e2e`, the `E2E_CAP` params) and its `use crate::transport::E2ERegistryHandle` / `use crate::StaticE2EHandle` depend on that. Adapt polled's E2E usage to the stack's non-const-generic handle surface.
-- **Server send/run** (PR 3 — caller-buffer model): `Server::run_with_buffers(unicast_buf, sd_buf, recv_send_buf, announce_send_buf: &mut [u8])`; new public `Server::announce_only_with_buffer(&mut [u8])`; `EventPublisher::publish_event_with_buffers(.., msg_buf, protected_buf)` / `publish_raw_event_with_buffers(.., buf)`. The SD send helpers in `runtime.rs`/`sd_state.rs` now take caller scratch and bound on `buf.len()`. If polled re-implements SD datagram building (`build_multi_offer_service_datagram`), reconcile against these.
-- **`subscription_manager`** caps: `EVENT_GROUPS_CAP = 32`, `SUBSCRIBERS_PER_GROUP = 16`; backed by `FnvIndexMap`. `929962a` reshaped this (+164) — adapt polled's `` usage to the stack's.
-
-## Adaptation list (the divergent-API touch points in `polled.rs`)
-
-1. **E2E:** `use crate::transport::E2ERegistryHandle`, `use crate::StaticE2EHandle`, `use crate::E2ECheckStatus`, and `check_parsed_e2e` (`polled.rs:341`) — reconcile with the stack's non-const-generic `E2ERegistry`/handle traits. **Do NOT pull `929962a` into the stack** to satisfy this (see rationale below); adapt polled.
-2. **Mutex:** any `SingleContextRawMutex` use — decide whether to bring `372c7d4`'s primitive (it's small and arguably generally useful) or use the stack's existing mutex abstraction.
-3. **Server/observer:** polled's references to the removed observer / your alloc-free server APIs — retarget to the stack's union `NonSdRequestCallback` + `#131`/PR 3 server surface.
-4. **SD builders:** `build_multi_offer_service_datagram` / `build_multi_stop_offer_service_datagram` (`polled.rs:145/158`) — confirm they encode against the same SD wire format the stack's `sd_state`/`runtime` helpers use (wire format is unchanged across the #125 stack).
-
-## Why the const-generic `E2ERegistry` (`929962a`) is NOT being adopted into the stack
-
-Adopting it would re-open the audited E2E code that PR 2/PR 3 just reworked and reviewed (the E2E-OOB fixes + `buf.len()` guards), force a public `E2ERegistry` API migration on other consumers (e.g. dft, which uses the E2E/`e2e_status` surface), and add per-instantiation monomorphization/flash cost the flash-constrained TC4/halo target doesn't want — all to host one module. If the const-generic registry is worth it on its own merits, it should be its own PR with its own review + dft-impact call, not a polled dependency. So: `polled.rs` (a consumer) adapts to the crate's reviewed E2E surface.
-
-## Suggested order
-
-1. `git checkout origin/feat/polled-bared-metal -- src/polled.rs` onto this branch; wire `mod polled;` into `lib.rs`.
-2. Compile under `--no-default-features --features `; fix the E2E-handle + mutex + observer references against the target APIs above.
-3. Re-apply the union `NonSdRequestCallback` shape to the runtime `DispatchFn`.
-4. Run `cargo build --target thumbv7em-none-eabihf ...` for the polled feature combos + the no-alloc witness.
-5. Clear the open `#126` review punch list (mutex feature-unification footgun, missing tests/CI, Ack-on-failure) as part of this.
-6. Open the PR based on `feature/pr3_125_server_buffers` (keep it stacked; never merge-down).
-
-`#128` (embassy-mem-channel-cap) is still a draft and stacks *after* this once it lands.
-
-Backups of the original tips: `backup/feat-polled-bared-metal-20260617-prestack2`, `backup/feat-embassy-mem-channel-cap-20260617-prestack2`.
diff --git a/docs/simple_someip/plans/2026-06-17-pr2-125-client-async-state-reduction.md b/docs/simple_someip/plans/2026-06-17-pr2-125-client-async-state-reduction.md
deleted file mode 100644
index c7fc5d25..00000000
--- a/docs/simple_someip/plans/2026-06-17-pr2-125-client-async-state-reduction.md
+++ /dev/null
@@ -1,767 +0,0 @@
-# PR 2 — #125 Client Async-State Reduction Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Move the client's per-socket `[u8; UDP_BUFFER_SIZE]` buffers out of the spawned socket-loop futures and into caller-sized pooled storage, and flatten the control-message handler, so the client's Embassy-arena (TaskStorage) footprint drops and becomes consumer-sized — closing the client half of issue #125.
-
-**Architecture:** Three moves. (1) Add a `BufferPool` static-storage primitive and a `BufferProvider` trait that mirror the existing channel-pool machinery (`OneshotPool`/`MpscPool` + `ChannelFactory`); a claim returns a RAII `BufferLease` that derefs to `&'static mut [u8]` and returns its slot on drop. (2) Thread a `BufferProvider` through `ClientDeps` → `BindDispatch` → `SocketManager::bind_*`; the socket loop receives its buffer by slice instead of owning a stack array, so the buffer lives in consumer `.bss` (or the tokio heap pool), not in the future. (3) Flatten `handle_control_message` into a synchronous decode/decide returning a small action value, with awaits hoisted to shallow helpers — kept only where the PR-0 witnesses show it moves the number.
-
-**Tech Stack:** Rust (edition 2024, crate stays stable-buildable), `heapless`, `embassy-sync` (bare-metal channel backend), `critical-section` (no_std slot synchronization), `tokio` (std path only), cargo, `cargo-nextest`.
-
-**Spec:** `docs/simple_someip/plans/2026-06-09-phase22-125-memory-reduction-design.md` (PR 2 section) and its rev-2 caller-sized-buffers decision.
-
-## Global Constraints
-
-- **Crate stays stable-buildable.** No nightly-only features in `simple-someip` itself. (CI may use nightly only for `-Zprint-type-sizes` / `-Zbuild-std` measurement jobs.)
-- **Wire format untouched.** No change to any byte emitted or parsed.
-- **Public tokio/std API unchanged.** Callers using the tokio-defaulted `ClientDeps` constructor see no signature change; the buffer pool is provisioned internally (8 × `UDP_BUFFER_SIZE`).
-- **Only these behavioral changes are allowed** (all from the design doc's PR-2 section; everything else is behavior-preserving):
- - (a) An inbound datagram larger than the claimed receive buffer is **dropped with a log**, not truncated/panicked.
- - (b) The oversize-send rejection compares against the **claimed buffer length** (`buf.len()`), not the `UDP_BUFFER_SIZE` constant.
-- **Existing suite stays green on every commit** (~543 tests as of #114 plus #124's additions; the `client,bare_metal` no-alloc witness and the embassy-net loopback live-wire test included).
-- **Every memory change is validated against PR-0's future-size witnesses** (`tests/bare_metal_e2e.rs`). A change that does not move a witness number is dropped, not merged on faith.
-- **No `&'static mut` aliasing.** A buffer slot is handed out to exactly one lease at a time; the pool enforces this and is covered by a witness test.
-- **Exact constants (copy verbatim):** `UDP_BUFFER_SIZE = 1500` (`src/lib.rs:158`); `UNICAST_SOCKETS_CAP = 8` (`src/client/inner.rs:40`).
-
----
-
-## File Structure
-
-| File | Action | Responsibility |
-|---|---|---|
-| `src/static_channels/buffer_pool.rs` | Create | `BufferPool` static primitive + `BufferLease` RAII handle (claim/release, no aliasing). |
-| `src/static_channels/mod.rs` | Modify | `mod buffer_pool; pub use buffer_pool::{BufferPool, BufferLease};` |
-| `src/transport.rs` | Modify | `BufferProvider` trait (`claim(&self) -> Option`), next to the `*Pooled` traits. |
-| `src/client/socket_manager.rs` | Modify | `socket_loop_future` takes the buffer by lease; oversize-send check keys off `buf.len()`; inbound-oversize drop+log; `bind_*` claim a buffer before spawn. |
-| `src/client/bind_dispatch.rs` | Modify | Thread the `BufferProvider` from `SpawnerDispatch` into the `SocketManager::bind_*` calls. |
-| `src/client/inner.rs` | Modify | Hold the provider; flatten `handle_control_message` into a sync decide + hoisted awaits. |
-| `src/client/mod.rs` | Modify | Add `buffer_provider` field/generic to `ClientDeps`; rewrite the `:1-30` memory-footprint doc. |
-| `src/tokio_transport.rs` | Modify | Heap-backed `BufferProvider` (leak a `[u8; UDP_BUFFER_SIZE] × 8` store) wired into the tokio-defaulted `ClientDeps` constructor. |
-| `tests/buffer_pool.rs` | Create | Unit + witness tests for claim-to-exhaustion, release-on-drop, no double-claim. |
-| `tests/bare_metal_e2e.rs` | Modify | Extend/retighten the future-size witnesses after extraction. |
-
----
-
-### Task 1: `BufferPool` static primitive + `BufferLease`
-
-**Files:**
-- Create: `src/static_channels/buffer_pool.rs`
-- Modify: `src/static_channels/mod.rs`
-- Test: `tests/buffer_pool.rs`
-
-**Interfaces:**
-- Produces:
- - `pub struct BufferPool` with `pub const fn new() -> Self` and `pub fn claim(&'static self) -> Option`.
- - `pub struct BufferLease { /* private */ }` implementing `Deref`, `DerefMut`, `Drop` (returns the slot), and `Send`.
-
-- [ ] **Step 1: Write the failing test**
-
-```rust
-// tests/buffer_pool.rs
-use simple_someip::static_channels::BufferPool;
-
-static POOL: BufferPool<2, 4> = BufferPool::new();
-
-#[test]
-fn claim_returns_distinct_zeroed_slices_until_exhausted() {
- let mut a = POOL.claim().expect("slot 0");
- let b = POOL.claim().expect("slot 1");
- assert_eq!(a.len(), 4);
- assert_eq!(&*b, &[0u8; 4]); // freshly handed-out slot is zeroed
- a[0] = 0xAB; // writable
- assert_eq!(a[0], 0xAB);
- assert!(POOL.claim().is_none(), "pool of 2 must refuse a 3rd claim");
-}
-
-#[test]
-fn dropping_a_lease_returns_its_slot() {
- let a = POOL.claim().expect("slot");
- drop(a);
- assert!(POOL.claim().is_some(), "slot must be reusable after the lease drops");
-}
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `cargo test --test buffer_pool`
-Expected: FAIL — `BufferPool` is not found / unresolved import.
-
-- [ ] **Step 3: Write the implementation**
-
-```rust
-// src/static_channels/buffer_pool.rs
-//! Fixed-capacity pool of `&'static mut [u8]` buffers with claim/release
-//! semantics, mirroring the channel pools in this module. A `BufferPool`
-//! is declared as a `static` by the consumer; each `claim()` hands out one
-//! slot as a [`BufferLease`] that returns the slot to the pool on drop.
-//!
-//! Synchronization uses `critical-section` so the same code is valid on the
-//! bare-metal (single-core, no atomics-guarantee) target and on std.
-
-use core::cell::UnsafeCell;
-use core::ops::{Deref, DerefMut};
-
-/// Backing storage for a pool: `SLOTS` independent `LEN`-byte buffers plus a
-/// claimed-flag per slot.
-pub struct BufferPool {
- // `UnsafeCell` because `claim()` hands out `&'static mut` into this store;
- // the `claimed` flags guarantee at most one live `&mut` per slot.
- store: UnsafeCell<[[u8; LEN]; SLOTS]>,
- claimed: UnsafeCell<[bool; SLOTS]>,
-}
-
-// SAFETY: all access to `store`/`claimed` is funneled through a
-// `critical_section::with`, which provides mutual exclusion on the targets
-// we support; a slot is only aliased once (its `claimed` flag gates it).
-unsafe impl Sync for BufferPool {}
-
-impl BufferPool {
- #[must_use]
- pub const fn new() -> Self {
- Self {
- store: UnsafeCell::new([[0u8; LEN]; SLOTS]),
- claimed: UnsafeCell::new([false; SLOTS]),
- }
- }
-
- /// Claim a free slot, or `None` if all `SLOTS` are in use. The returned
- /// buffer is zeroed before hand-out so a reused slot never leaks the
- /// previous tenant's bytes.
- pub fn claim(&'static self) -> Option {
- critical_section::with(|_| {
- // SAFETY: inside the critical section we hold exclusive access to
- // both arrays; we take a raw pointer and only form one `&mut` for
- // the chosen, not-yet-claimed slot.
- let claimed = unsafe { &mut *self.claimed.get() };
- let idx = claimed.iter().position(|&c| !c)?;
- claimed[idx] = true;
- let store = unsafe { &mut *self.store.get() };
- let slot: &'static mut [u8; LEN] = unsafe { &mut *(&mut store[idx] as *mut [u8; LEN]) };
- slot.fill(0);
- Some(BufferLease {
- buf: slot.as_mut_slice(),
- claimed_flag: claimed.as_mut_ptr(),
- idx,
- })
- })
- }
-}
-
-impl Default for BufferPool {
- fn default() -> Self {
- Self::new()
- }
-}
-
-/// RAII handle to one claimed buffer. Derefs to the `&'static mut [u8]`;
-/// returns the slot to its pool on drop.
-pub struct BufferLease {
- buf: &'static mut [u8],
- claimed_flag: *mut bool,
- idx: usize,
-}
-
-// SAFETY: `BufferLease` owns exclusive access to its slot (gated by the
-// pool's `claimed` flag) and the backing store is `'static`.
-unsafe impl Send for BufferLease {}
-
-impl Deref for BufferLease {
- type Target = [u8];
- fn deref(&self) -> &[u8] {
- self.buf
- }
-}
-
-impl DerefMut for BufferLease {
- fn deref_mut(&mut self) -> &mut [u8] {
- self.buf
- }
-}
-
-impl Drop for BufferLease {
- fn drop(&mut self) {
- critical_section::with(|_| {
- // SAFETY: `claimed_flag` points into the owning pool's `'static`
- // `claimed` array; only this lease writes `idx`'s flag.
- unsafe {
- *self.claimed_flag.add(self.idx) = false;
- }
- });
- }
-}
-```
-
-```rust
-// src/static_channels/mod.rs — add near the other `mod`/`pub use` lines
-mod buffer_pool;
-pub use buffer_pool::{BufferLease, BufferPool};
-```
-
-- [ ] **Step 4: Run test to verify it passes**
-
-Run: `cargo test --test buffer_pool`
-Expected: PASS (both tests).
-
-- [ ] **Step 5: Confirm no_std-clean and lint-clean**
-
-Run: `cargo clippy -p simple-someip --no-default-features --features client,bare_metal -- -D warnings -D clippy::pedantic`
-Expected: no warnings. (`critical-section` is already a transitive dep via `embassy-sync` per `Cargo.toml`; if the bare-metal build cannot find it, add `critical-section = { version = "1", optional = true }` and include it in the `bare_metal` feature.)
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add src/static_channels/buffer_pool.rs src/static_channels/mod.rs tests/buffer_pool.rs
-git commit -m "feat(static_channels): BufferPool + BufferLease claim/release primitive (#125)"
-```
-
----
-
-### Task 2: `BufferProvider` trait + static & tokio impls
-
-**Files:**
-- Modify: `src/transport.rs` (next to `OneshotPooled`/`BoundedPooled`, ~`:1342`)
-- Modify: `src/tokio_transport.rs` (after the `TokioChannels` `*Pooled` impls, ~`:550`)
-- Test: `tests/buffer_pool.rs`
-
-**Interfaces:**
-- Consumes: `BufferLease`, `BufferPool` (Task 1).
-- Produces:
- - `pub trait BufferProvider: Clone + Send + Sync + 'static { fn claim(&self) -> Option; }`
- - `pub struct StaticBufferProvider(pub &'static BufferPool);` impl `BufferProvider`.
- - `pub struct TokioBufferProvider;` impl `BufferProvider` (heap-backed, `UDP_BUFFER_SIZE`-sized).
-
-- [ ] **Step 1: Write the failing test**
-
-```rust
-// tests/buffer_pool.rs (append)
-use simple_someip::static_channels::{BufferPool, BufferLease};
-use simple_someip::transport::{BufferProvider, StaticBufferProvider};
-
-static PROV_POOL: BufferPool<2, 8> = BufferPool::new();
-
-#[test]
-fn static_provider_claims_through_a_shared_pool() {
- let prov = StaticBufferProvider(&PROV_POOL);
- let _a = prov.claim().expect("first");
- let _b = prov.claim().expect("second");
- assert!(prov.claim().is_none(), "provider exposes the pool's capacity");
-}
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `cargo test --test buffer_pool static_provider_claims_through_a_shared_pool`
-Expected: FAIL — `BufferProvider` / `StaticBufferProvider` unresolved.
-
-- [ ] **Step 3: Write the trait and static impl**
-
-```rust
-// src/transport.rs — near the *Pooled traits
-use crate::static_channels::{BufferLease, BufferPool};
-
-/// Source of `&'static mut [u8]` receive/scratch buffers for the client's
-/// socket loops. Mirrors [`ChannelFactory`]'s role for channels: the
-/// bare-metal path is backed by a consumer-declared `static BufferPool`;
-/// the tokio path is heap-backed and provisioned internally.
-pub trait BufferProvider: Clone + Send + Sync + 'static {
- /// Claim one buffer, or `None` when the pool is exhausted.
- fn claim(&self) -> Option;
-}
-
-/// `BufferProvider` backed by a `'static` [`BufferPool`] (bare-metal path).
-#[derive(Clone, Copy, Debug)]
-pub struct StaticBufferProvider(
- pub &'static BufferPool,
-);
-
-impl BufferProvider
- for StaticBufferProvider
-{
- fn claim(&self) -> Option {
- self.0.claim()
- }
-}
-```
-
-```rust
-// src/tokio_transport.rs — heap-backed provider
-use crate::static_channels::{BufferLease, BufferPool};
-use crate::transport::BufferProvider;
-use crate::UDP_BUFFER_SIZE;
-
-/// Tokio-path buffer provider: a single `Arc`-backed `BufferPool` sized at
-/// `UNICAST_SOCKETS_CAP + 1 (discovery) + 1 (release-lag slack)` ×
-/// `UDP_BUFFER_SIZE`. `Arc`-backed, NOT leaked — the pool is freed when the
-/// last provider/lease drops; the API hides it entirely from callers.
-/// (Rev: the original sketch used `Box::leak`; the merged implementation is
-/// `Arc`-backed so dynamically-created clients don't leak a pool each.)
-#[derive(Clone, Debug)]
-pub struct TokioBufferProvider(alloc::sync::Arc>);
-
-impl TokioBufferProvider {
- #[must_use]
- pub fn new() -> Self {
- Self(alloc::sync::Arc::new(BufferPool::new()))
- }
-}
-
-impl Default for TokioBufferProvider {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl BufferProvider for TokioBufferProvider {
- fn claim(&self) -> Option {
- self.0.claim()
- }
-}
-```
-
-- [ ] **Step 4: Run test to verify it passes**
-
-Run: `cargo test --test buffer_pool static_provider_claims_through_a_shared_pool`
-Expected: PASS.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add src/transport.rs src/tokio_transport.rs tests/buffer_pool.rs
-git commit -m "feat(transport): BufferProvider trait + static/tokio impls (#125)"
-```
-
----
-
-### Task 3: `socket_loop_future` consumes the buffer; oversize behaviors
-
-**Files:**
-- Modify: `src/client/socket_manager.rs:551` (signature), `:569` (drop local `buf`), `:447-458` (oversize-send check), the receive path (inbound-oversize drop+log)
-- Test: `tests/bare_metal_e2e.rs` (a focused inbound-oversize test)
-
-**Interfaces:**
-- Consumes: `BufferLease` (Task 1).
-- Produces: `socket_loop_future(socket, rx_tx, tx_rx, e2e_registry, buf: BufferLease)` — the buffer is now an explicit parameter.
-
-- [ ] **Step 1: Write the failing test** (inbound datagram larger than the claimed buffer is dropped with a log, loop survives)
-
-```rust
-// tests/bare_metal_e2e.rs (new test; reuse the file's existing harness types)
-#[tokio::test]
-async fn inbound_datagram_larger_than_claimed_buffer_is_dropped_not_fatal() {
- // Claim a deliberately tiny 8-byte buffer for the loop, deliver a 64-byte
- // datagram, then deliver a valid small one. The oversized datagram must be
- // dropped (no panic, loop still running) and the valid one delivered.
- let outcome = run_socket_loop_with_buffer_len(8, &[
- Datagram::raw(vec![0xFF; 64]), // oversized -> dropped
- Datagram::valid_small(), // must still arrive
- ])
- .await;
- assert_eq!(outcome.delivered.len(), 1, "only the in-budget datagram is delivered");
- assert!(outcome.loop_alive, "loop must survive an oversized datagram");
-}
-```
-
-*(If `run_socket_loop_with_buffer_len` / `Datagram` helpers don't exist, add a thin harness in this test module that builds a `SocketManager` over a mock `TransportSocket` whose `recv` yields the scripted datagrams and a `BufferPool<1, 8>` for the lease. Model it on the existing `future_size_witness_bare_metal_channels` setup at `tests/bare_metal_e2e.rs:600`.)*
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `cargo test --features client,server,bare_metal --test bare_metal_e2e inbound_datagram_larger`
-Expected: FAIL — signature mismatch (`socket_loop_future` takes no buffer) / helper missing.
-
-- [ ] **Step 3: Change the signature and drop the local array**
-
-```rust
-// src/client/socket_manager.rs:551 — add the buffer parameter
-#[allow(clippy::too_many_lines)]
-async fn socket_loop_future(
- socket: T,
- rx_tx: C::BoundedSender, Error>, 16>,
- mut tx_rx: C::BoundedReceiver, 16>,
- e2e_registry: R,
- mut buf: crate::static_channels::BufferLease, // ← was `let mut buf = [0u8; UDP_BUFFER_SIZE];`
-) where
- T: TransportSocket + 'static,
- R: E2ERegistryHandle,
-{
- const MAX_CONSECUTIVE_RECV_ERRORS: u32 = 16;
- let mut consecutive_recv_errors: u32 = 0;
- // (delete the old `let mut buf = [0u8; UDP_BUFFER_SIZE];` at :569)
-```
-
-- [ ] **Step 4: Inbound-oversize drop+log in the receive path**
-
-In the receive arm (where `socket.recv(&mut buf)` is awaited), the transport already truncates to `buf.len()`; add the guard that a datagram reported larger than `buf.len()` is dropped with a log rather than parsed from a truncated buffer:
-
-```rust
-// receive path, after a successful recv reporting `n` bytes:
-let n = match socket.recv(&mut buf).await {
- Ok(n) => n,
- Err(e) => { /* existing consecutive-error handling, unchanged */ }
-};
-if n > buf.len() {
- crate::log::warn!(
- "inbound datagram ({n} B) exceeds claimed buffer ({} B); dropping",
- buf.len()
- );
- continue;
-}
-let datagram = &buf[..n];
-// ... existing parse/forward of `datagram`, unchanged
-```
-
-- [ ] **Step 5: Oversize-send check keys off `buf.len()`**
-
-```rust
-// src/client/socket_manager.rs:447 — was `if required > UDP_BUFFER_SIZE {`
-if required > buf.len() {
- warn!(
- "outgoing message size {required} exceeds claimed buffer ({}); rejecting with Capacity(\"udp_buffer\")",
- buf.len()
- );
- return Err(Error::Capacity("udp_buffer"));
-}
-```
-
-For the E2E `protected` scratch at `:632` (`let mut protected = [0u8; UDP_BUFFER_SIZE];`): leave it for **Task 5** (measurement-gated). For now, keep it as a local so this task stays focused on the receive buffer + the two oversize behaviors.
-
-- [ ] **Step 6: Run the focused test + the full client/server suite**
-
-Run: `cargo test --features client,server,bare_metal --test bare_metal_e2e inbound_datagram_larger`
-Expected: PASS.
-Run: `cargo nextest run --no-default-features --features client-tokio,server-tokio`
-Expected: all existing client/server tests still green (behavior-preserving except the two allowed changes).
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add src/client/socket_manager.rs tests/bare_metal_e2e.rs
-git commit -m "feat(client): socket loop receives buffer by lease; oversize drop/reject on buf.len() (#125)"
-```
-
----
-
-### Task 4: Thread `BufferProvider` through deps → bind → spawn
-
-**Files:**
-- Modify: `src/client/mod.rs:278-296` (`ClientDeps` gains a `buffer_provider` + generic `BP`), and the tokio-defaulted constructor
-- Modify: `src/client/bind_dispatch.rs:34-117` (`BindDispatch` + `SpawnerDispatch` carry/forward the provider)
-- Modify: `src/client/socket_manager.rs:248-249,355-397` (claim a buffer before spawn; pass it into `socket_loop_future`)
-- Modify: `src/client/inner.rs` (store the provider; nothing extra at eviction — release is RAII on loop exit)
-- Test: `tests/bare_metal_e2e.rs` (bind-to-capacity claims/releases)
-
-**Interfaces:**
-- Consumes: `BufferProvider` (Task 2), `socket_loop_future(.., buf)` (Task 3).
-- Produces: `ClientDeps` with field `pub buffer_provider: BP`.
-
-- [ ] **Step 1: Write the failing test** (binding N unicast sockets claims N buffers; closing a socket releases its buffer)
-
-```rust
-// tests/bare_metal_e2e.rs
-#[tokio::test]
-async fn each_bound_socket_claims_one_buffer_and_releases_on_close() {
- // Pool with exactly 2 slots; provider shared into ClientDeps.
- static POOL: simple_someip::static_channels::BufferPool<2, 1500> =
- simple_someip::static_channels::BufferPool::new();
- let provider = simple_someip::transport::StaticBufferProvider(&POOL);
-
- let client = build_test_client_with_buffer_provider(provider).await;
- client.bind_unicast_for_test(40000).await.expect("1st bind claims slot 0");
- client.bind_unicast_for_test(40001).await.expect("2nd bind claims slot 1");
- // 3rd bind must fail: pool exhausted.
- let third = client.bind_unicast_for_test(40002).await;
- assert!(matches!(third, Err(Error::Capacity("udp_buffer"))));
-
- client.close_unicast_for_test(40000).await; // releases slot 0
- client.bind_unicast_for_test(40002).await.expect("slot freed -> bind succeeds");
-}
-```
-
-*(`build_test_client_with_buffer_provider` / `bind_unicast_for_test` / `close_unicast_for_test` are thin test shims over `new_with_deps` + the existing control-message API; build them in the test module mirroring `tests/bare_metal_client.rs:257`.)*
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `cargo test --features client,server,bare_metal --test bare_metal_e2e each_bound_socket_claims`
-Expected: FAIL — `ClientDeps` has no `buffer_provider`.
-
-- [ ] **Step 3: Add the provider to `ClientDeps`**
-
-```rust
-// src/client/mod.rs:278 — add generic BP and field
-pub struct ClientDeps
-where
- F: TransportFactory,
- Tm: Timer,
- R: E2ERegistryHandle,
- I: InterfaceHandle,
- BP: crate::transport::BufferProvider,
-{
- pub factory: F,
- pub timer: Tm,
- pub e2e_registry: R,
- pub interface: I,
- pub spawner: Sp,
- /// Source of `&'static mut [u8]` socket-loop buffers (caller-sized on
- /// bare-metal; internally heap-provisioned on the tokio path).
- pub buffer_provider: BP,
-}
-```
-
-Update `Client::new_with_deps` and the `Inner` constructor to store `buffer_provider` (alongside `dispatch`/`spawner`). The tokio-defaulted constructor (the phase-21 `ClientDeps`/`Deps` convenience builder) sets `buffer_provider: TokioBufferProvider::new()` so tokio callers are unaffected.
-
-- [ ] **Step 4: Forward the provider through `bind_dispatch` and claim at bind**
-
-In `SpawnerDispatch` add a `buffer_provider: BP` field; in its `BindDispatch::bind_unicast`/`bind_discovery` impls (`src/client/bind_dispatch.rs:87-117`), claim a buffer and pass it to the `SocketManager::bind_*` calls. In `SocketManager::bind_with_transport` / `bind_discovery_seeded_with_transport` (`socket_manager.rs:355-397` / `:248`), accept the lease and forward it into the spawned loop:
-
-```rust
-// src/client/bind_dispatch.rs — bind_unicast impl
-fn bind_unicast(&self, port: u16, e2e_registry: R) -> impl Future