diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index c02afcc..2bdd28f 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Spec-conformant TUNNELING_REQUEST retransmission — `TunnelConfig::ack_retransmits` (036 W4, default `1`).** When a tracked outbound telegram's ACK does not arrive within `ack_timeout_ms`, the engine now retransmits the byte-identical frame (same sequence counter, buffered in the pending-ACK slot per KNXnet/IP 3.8.4) and, when the repeat also goes unanswered, reports `Action::AckTimeout` **and tears the connection down** — so subsequent commands queue for the re-handshake instead of being sent into a dead tunnel. Hardware-bench evidence motivating this: ten button-press writes issued during a link outage's heartbeat-detection window (up to ~65 s) were silently lost with only warnings; with retransmission the loss window shrinks to ~2× `ack_timeout_ms`. `ack_retransmits: 0` restores the previous expire-and-warn behavior (no retransmit, no disconnect, no frame buffering — though the 16-slot frame capacity, ~4.5 KiB, is statically reserved either way on `heapless`). The retransmit delay is `ack_timeout_ms` (default 3 s, the constant both pre-engine implementations used); set it to `1_000` for strict spec timing. Covered by engine unit tests and a fake-gateway test that drops the first ACK and asserts the identical repeat. + ### Fixed - **Heartbeat-response liveness — a dead send path or expired gateway channel now reconnects (review follow-up to #135).** The engine tracks each CONNECTIONSTATE_REQUEST and drops the connection when the gateway's CONNECTIONSTATE_RESPONSE doesn't arrive within the new `TunnelConfig::heartbeat_response_timeout_ms` (default 10 s, the KNX spec timeout) or reports a non-zero status (e.g. the gateway expired the channel during an outage). This restores the old tokio client's recovery from silently-failing sends — the recv path of an unconnected UDP socket never errors, so without it a route flap left the tunnel `Connected` forever with a stale channel id — and adds genuine liveness detection on both runtimes. diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index aaff6a7..193bd60 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -419,6 +419,89 @@ mod tests { frame } + /// TUNNELING_ACK from the gateway: header + connection header. + fn gateway_ack(channel_id: u8, seq: u8) -> Vec { + vec![ + 0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, // header, total len 10 + 0x04, channel_id, seq, 0x00, // connection header, status OK + ] + } + + /// W4: the gateway drops the first ACK; the client retransmits the + /// byte-identical TUNNELING_REQUEST (same sequence counter, KNXnet/IP + /// 3.8.4) after the ACK timeout, and the tunnel survives once the repeat + /// is ACKed. Real-time test: waits out the 3 s default ACK timeout. + #[tokio::test] + async fn dropped_ack_triggers_identical_retransmit() { + let gateway = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let gateway_port = gateway.local_addr().unwrap().port(); + + let (command_tx, mut telegram_rx, connection_future) = + KnxConnectorImpl::build_internal(&format!("knx://127.0.0.1:{}", gateway_port), 8) + .await + .unwrap(); + let task = tokio::spawn(connection_future); + + let mut buf = [0u8; 1024]; + let (len, client_addr) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no CONNECT_REQUEST") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0205); + gateway + .send_to(&connect_response(7, 0), client_addr) + .await + .unwrap(); + + // Outbound write; deliberately do NOT ACK the first request. + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + command_tx + .send(GroupWrite { + group_addr: "1/0/8".parse().unwrap(), + data, + }) + .await + .unwrap(); + let (len, _) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_REQUEST") + .unwrap(); + let first = buf[..len].to_vec(); + assert_eq!(service_type_of(&first), 0x0420); + + // The retransmit arrives after the ACK timeout, byte-identical. + let (len, _) = timeout(Duration::from_secs(8), gateway.recv_from(&mut buf)) + .await + .expect("no retransmit after dropped ACK") + .unwrap(); + assert_eq!(&buf[..len], &first[..]); + + // ACK the repeat: the tunnel stays up — an inbound telegram still + // round-trips on the same channel (a disconnect would have produced + // a CONNECT_REQUEST here instead of an ACK). + gateway + .send_to(&gateway_ack(7, 0), client_addr) + .await + .unwrap(); + gateway + .send_to(&inbound_group_write(7, 42), client_addr) + .await + .unwrap(); + let (len, _) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_ACK for inbound telegram") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0421); + let (topic, _) = timeout(RECV_TIMEOUT, telegram_rx.recv()) + .await + .expect("no telegram routed") + .unwrap(); + assert_eq!(topic, "1/0/7"); + + task.abort(); + } + /// Full roundtrip against a scripted fake gateway on localhost UDP: /// handshake, inbound telegram → `KnxSource` channel, outbound command → /// TUNNELING_REQUEST on the wire (then ACKed). diff --git a/aimdb-knx-connector/src/tunnel.rs b/aimdb-knx-connector/src/tunnel.rs index d4115b1..00d8ffe 100644 --- a/aimdb-knx-connector/src/tunnel.rs +++ b/aimdb-knx-connector/src/tunnel.rs @@ -89,9 +89,12 @@ pub enum Action { /// The engine has entered backoff and will emit the next CONNECT_REQUEST /// as a `Send` action once the backoff deadline passes. ResetSocket, - /// An outbound telegram was never acknowledged within the ACK timeout. - /// Log-only: the engine keeps the connection (matching both previous - /// implementations, which expired and warned without retransmitting). + /// An outbound telegram was never acknowledged: every send attempt + /// (1 + [`TunnelConfig::ack_retransmits`]) expired unanswered. Log-only + /// for the transport. With retransmits enabled the engine also tears the + /// connection down afterwards (KNXnet/IP 3.8.4); with + /// `ack_retransmits == 0` it keeps the connection, matching both + /// pre-retransmit implementations. AckTimeout { seq: u8 }, } @@ -124,10 +127,20 @@ pub struct TunnelConfig { pub local_endpoint: LocalEndpoint, /// CONNECT_RESPONSE wait before giving up and backing off. pub connect_timeout_ms: Millis, - /// Pending ACK lifetime before it is reported via [`Action::AckTimeout`]. + /// Pending ACK lifetime before a send attempt is considered unanswered. pub ack_timeout_ms: Millis, /// Cadence of the pending-ACK expiry sweep. pub ack_sweep_ms: Millis, + /// TUNNELING_REQUEST retransmissions after an ACK timeout before the + /// telegram is reported lost via [`Action::AckTimeout`]. + /// + /// KNXnet/IP 3.8.4 repeats the identical frame once and tears the + /// connection down when the repeat also goes unanswered — `1` (the + /// default) is that behavior, with the retransmit delay being + /// [`ack_timeout_ms`](Self::ack_timeout_ms) (set that to `1_000` for + /// strict spec timing). `0` restores the pre-retransmit behavior: expire + /// and warn only, no disconnect, and no frame bytes are buffered. + pub ack_retransmits: u8, /// CONNECTIONSTATE_REQUEST keepalive cadence. pub heartbeat_ms: Millis, /// CONNECTIONSTATE_RESPONSE wait before the connection is considered @@ -147,6 +160,7 @@ impl Default for TunnelConfig { connect_timeout_ms: 5_000, ack_timeout_ms: 3_000, ack_sweep_ms: 500, + ack_retransmits: 1, heartbeat_ms: 55_000, heartbeat_response_timeout_ms: 10_000, reconnect_backoff_ms: 5_000, @@ -154,9 +168,22 @@ impl Default for TunnelConfig { } } -/// Pending outbound ACKs tracked per connection (seq → sent-at). +/// Pending outbound ACKs tracked per connection (seq → [`PendingAck`]). const PENDING_ACK_CAPACITY: usize = 16; +/// A tracked outbound TUNNELING_REQUEST awaiting its TUNNELING_ACK. +#[derive(Debug)] +struct PendingAck { + sent_at: Millis, + /// Send attempts still allowed after the next expiry. + retries_left: u8, + /// The sent frame, buffered for byte-identical retransmission (KNXnet/IP + /// 3.8.4 repeats the same frame, same sequence counter). Left empty when + /// `ack_retransmits == 0`: the slot capacity is statically reserved + /// either way (heapless), but no bytes are copied. + frame: Frame, +} + /// Per-connection state; only exists while the handshake has succeeded, so /// "connected" needs no separate flag and the keepalive cannot fire while /// disconnected. @@ -164,7 +191,7 @@ const PENDING_ACK_CAPACITY: usize = 16; struct ChannelState { channel_id: u8, outbound_seq: u8, - pending_acks: heapless::FnvIndexMap, + pending_acks: heapless::FnvIndexMap, next_heartbeat: Millis, /// Set when a CONNECTIONSTATE_REQUEST goes out; cleared by the gateway's /// OK response. A pending entry older than @@ -335,15 +362,25 @@ impl TunnelEngine { let seq = state.next_outbound_seq(); let cemi = build_group_write_cemi(cmd.group_addr, &cmd.data); let frame = build_tunneling_request(state.channel_id, seq, &cemi); - if state.pending_acks.insert(seq, now).is_err() { + let pending = PendingAck { + sent_at: now, + retries_left: self.cfg.ack_retransmits, + frame: if self.cfg.ack_retransmits > 0 { + frame.clone() + } else { + Frame::new() + }, + }; + if let Err((_, pending)) = state.pending_acks.insert(seq, pending) { // Map full (burst deeper than PENDING_ACK_CAPACITY): evict the // oldest entry and report it now, so no unacknowledged telegram - // is ever silently untracked. + // is ever silently untracked. Eviction is overflow, not confirmed + // loss, so it never tears the connection down. if let Some((&oldest, _)) = state.pending_acks.iter().next() { state.pending_acks.remove(&oldest); self.actions.push_back(Action::AckTimeout { seq: oldest }); } - let _ = state.pending_acks.insert(seq, now); + let _ = state.pending_acks.insert(seq, pending); } self.actions.push_back(Action::Send { frame, @@ -378,6 +415,9 @@ impl TunnelEngine { /// Fire any deadlines that have passed. Call at the top of every loop /// iteration, before draining actions. pub fn poll(&mut self, now: Millis) { + // Set inside the `Connected` arm (where `self.phase` is borrowed) and + // applied after the match — same pattern as `handle_datagram`. + let mut drop_connection = false; match &mut self.phase { Phase::Backoff { until } => { if now >= *until { @@ -419,19 +459,44 @@ impl TunnelEngine { } if now >= state.next_ack_sweep { let mut expired: heapless::Vec = heapless::Vec::new(); - for (&seq, &sent_at) in state.pending_acks.iter() { - if now.saturating_sub(sent_at) > self.cfg.ack_timeout_ms { + for (&seq, pending) in state.pending_acks.iter() { + if now.saturating_sub(pending.sent_at) > self.cfg.ack_timeout_ms { let _ = expired.push(seq); } } for seq in &expired { - state.pending_acks.remove(seq); - self.actions.push_back(Action::AckTimeout { seq: *seq }); + let Some(pending) = state.pending_acks.get_mut(seq) else { + continue; + }; + if pending.retries_left > 0 { + // KNXnet/IP 3.8.4: repeat the identical frame + // (same sequence counter) and re-arm the timeout. + pending.retries_left -= 1; + pending.sent_at = now; + self.actions.push_back(Action::Send { + frame: pending.frame.clone(), + await_ack: Some(*seq), + }); + } else { + state.pending_acks.remove(seq); + self.actions.push_back(Action::AckTimeout { seq: *seq }); + // Spec: tear the connection down once the repeat + // also went unanswered, so queued commands stop + // being sent into a dead tunnel. + // `ack_retransmits == 0` keeps the legacy + // warn-and-continue behavior. + if self.cfg.ack_retransmits > 0 { + drop_connection = true; + } + } } state.next_ack_sweep = now + self.cfg.ack_sweep_ms; } } } + if drop_connection { + self.handle_socket_error(now); + } } /// Earliest deadline the transport must wake the engine for. @@ -711,16 +776,27 @@ fn parse_telegram(cemi_data: &[u8]) -> Option<(GroupAddress, Vec)> { mod tests { use super::*; + /// Legacy-mode config: no retransmits, expire-and-warn only. Most tests + /// pin this pre-retransmit contract; the retransmit tests use + /// [`RETRANSMIT_CFG`]. const CFG: TunnelConfig = TunnelConfig { local_endpoint: LocalEndpoint::Nat, connect_timeout_ms: 5_000, ack_timeout_ms: 3_000, ack_sweep_ms: 500, + ack_retransmits: 0, heartbeat_ms: 55_000, heartbeat_response_timeout_ms: 10_000, reconnect_backoff_ms: 5_000, }; + /// [`CFG`] with the spec-conformant retransmit knob (the shipping + /// default) enabled. + const RETRANSMIT_CFG: TunnelConfig = TunnelConfig { + ack_retransmits: 1, + ..CFG + }; + fn drain(engine: &mut TunnelEngine) -> Vec { let mut actions = Vec::new(); while let Some(a) = engine.next_action() { @@ -757,7 +833,12 @@ mod tests { /// Drive a fresh engine to Connected; returns it with actions drained. fn connected_engine(now: Millis) -> TunnelEngine { - let mut engine = TunnelEngine::new(CFG.clone(), now); + connected_engine_with(CFG.clone(), now) + } + + /// [`connected_engine`] with an explicit config. + fn connected_engine_with(cfg: TunnelConfig, now: Millis) -> TunnelEngine { + let mut engine = TunnelEngine::new(cfg, now); engine.poll(now); let actions = drain(&mut engine); assert_eq!(actions.len(), 1); @@ -1024,10 +1105,88 @@ mod tests { engine.poll(1_000); assert!(drain(&mut engine).is_empty()); - // First sweep past sent_at + ack_timeout expires it. + // First sweep past sent_at + ack_timeout expires it. Legacy mode + // (ack_retransmits == 0): warn only, the connection is kept. engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms); let actions = drain(&mut engine); assert_eq!(actions, vec![Action::AckTimeout { seq: 0 }]); + assert!(engine.is_connected()); + } + + #[test] + fn ack_timeout_retransmits_identical_frame_then_disconnects() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine_with(RETRANSMIT_CFG.clone(), 0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 10, + ); + let actions = drain(&mut engine); + let [Action::Send { + frame: original, + await_ack: Some(0), + }] = &actions[..] + else { + panic!("expected tracked TUNNELING_REQUEST send, got {actions:?}"); + }; + let original = original.clone(); + + // First expiry: the identical frame goes out again (same sequence + // counter, KNXnet/IP 3.8.4), no AckTimeout, connection kept. + engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms); + let actions = drain(&mut engine); + assert_eq!( + actions, + vec![Action::Send { + frame: original, + await_ack: Some(0), + }] + ); + assert!(engine.is_connected()); + + // Second expiry: reported lost, and the connection is torn down so + // subsequent commands queue instead of vanishing into a dead tunnel. + let t = 10 + 2 * (CFG.ack_timeout_ms + CFG.ack_sweep_ms); + engine.poll(t); + let actions = drain(&mut engine); + assert_eq!( + actions, + vec![Action::AckTimeout { seq: 0 }, Action::ResetSocket] + ); + assert!(!engine.is_connected()); + assert_eq!(engine.next_deadline(), t + CFG.reconnect_backoff_ms); + } + + #[test] + fn retransmitted_request_acked_keeps_connection() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine_with(RETRANSMIT_CFG.clone(), 0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 10, + ); + drain(&mut engine); + + // Let the first send expire and the retransmit go out… + engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms); + assert_eq!(drain(&mut engine).len(), 1); + + // …then the (late) ACK lands: pending cleared, no AckTimeout at any + // later sweep, connection kept. + engine.handle_datagram(&gateway_ack(7, 0), 4_000); + engine.poll(20_000); + assert!(drain(&mut engine).is_empty()); + assert!(engine.is_connected()); } #[test] diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md index 8191bf4..bf071d1 100644 --- a/docs/design/036-followup-refactoring.md +++ b/docs/design/036-followup-refactoring.md @@ -72,6 +72,10 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Size:** S (one bench session). No issue needed if run as part of #140; otherwise file as a validation task. +**Prep done 2026-06-12:** the demo firmware builds clean (`cargo build -p embassy-knx-connector-demo --target thumbv8m.main-none-eabihf`); flash from the host via the demo's `flash.sh` (probe-rs, STM32H563ZITx), defmt over RTT; set `KNX_GATEWAY_IP` in the demo's `main.rs` first. Since #140 merged, 035's "run the matrix twice" guidance is moot — one run is the bar. (The session actually ran on the 036 stack-tip build, which is fine — the stack's core changes ride under the connector and got hardware exposure for free.) + +**Bench findings so far (2026-06-12, partial):** baseline boot → DHCP → tunnel connect → inbound telegrams ✅; outbound button-press round trip with zero AckTimeouts on a healthy link ✅ (scenario 1 preview); reconnect after a gateway outage via the heartbeat path ✅ (link-bounce variant of scenario 3). The scenario-2 variant (writes during the undetected-outage window) showed **ten writes silently lost** — warn-only AckTimeouts, no retransmit, no re-queue — which fired W4's trigger; see W4. **Re-run on the #144 build (same day):** a press during the outage produced exactly one AckTimeout ~7 s after the press (silent retransmit in between), disconnect, and reconnect attempts paced one CONNECT_REQUEST per backoff cycle (scenario 5's criterion, observed from defmt alone) — W4's detection + bounded-loss half validated on hardware. **Remaining, to run before the next release that ships the KNX connector (decision 2026-06-12):** the 30-min soak (1), scenario 2's queue-flush half (press *after* the AckTimeout warning, expect flush on re-handshake), stale-channel NACK (4), backoff pacing under Wireshark (5), inbound flood (7), and the switch-isolated scenario-3 variant for a clean ≤65 s detection number. + ### W4 — KNX ACK-retransmit knob in `TunnelConfig` (from the #135 review) **Current state (verified).** When a `TunnelingRequest` is not ACKed within the timeout, the engine expires the pending slot and emits [`Action::AckTimeout`](../../aimdb-knx-connector/src/tunnel.rs#L94) (shims log a warning) — no retransmit, no disconnect. The KNXnet/IP tunneling spec (3.8.4) says: retransmit once after 1 s, then tear the connection down on the second miss. @@ -82,6 +86,8 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Size:** S–M. File after #140 merges (touches `tunnel.rs` on the #140 baseline). +**Status: implemented in PR [#144](https://github.com/aimdb-dev/aimdb/pull/144)** (stacked on #143). The item was first deferred pending W3 data (decision 2026-06-12, same day), and the trigger fired within hours: the W3 bench's scenario-2 variant showed ten button-press writes issued during a link outage's heartbeat-detection window (~65 s) silently lost with warn-only AckTimeouts. Design as pre-decided: option **(a)** — the pending-ACK slot buffers the sent 278-byte frame for byte-identical retransmit (`GroupWrite.data` already carries a full 254-byte APDU, so semantic-content storage (b) would save only ~350 B total at `PENDING_ACK_CAPACITY` 16; the RAM argument evaporated). `ack_retransmits` default `1` = retransmit once then disconnect (spec 3.8.4); `0` = the legacy expire-and-warn, pinned by tests. Retransmit delay is `ack_timeout_ms` (3 s, the pre-engine constant) rather than the spec's hardcoded 1 s — strict timing is one knob away. Hardware validation: re-run scenario 2 on the #144 build (expect reconnect within ~6 s and queued writes flushing after re-handshake). + ### W5 — `StringKey::intern`: dedup interner + loud contract (034 §3.10) **Current state (verified).** [`StringKey::intern`](../../aimdb-core/src/record_id.rs#L284) still `Box::leak`s every call ([record_id.rs:297](../../aimdb-core/src/record_id.rs#L297)); re-interning the same key leaks again; guarded only by a debug-build counter (cap 1000). @@ -90,13 +96,13 @@ The registry keeps storing `Box`; consumers upcast to the capabil Explicitly rejected: a non-`Copy` `Arc` key variant — it forks `RecordKey` into two shapes, which is the 034 §3.1 mistake again. -**Size:** S. Independent of everything else; opportunistic. +**Size:** S. Independent of everything else; opportunistic. **Status:** implemented in PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) (with W6, stacked on #142). As specified: dedup table behind the std/spin mutex pattern from typed_record, contract documented on `intern`, debug tripwire now counts distinct keys. ### W6 — `host_test_stubs!` macro for the defmt logger duplication (035 §2.4) **Current state (verified).** The no-op `#[defmt::global_logger]`/panic-handler/time-driver block exists in three places: [session_smoke.rs](../../aimdb-embassy-adapter/tests/session_smoke.rs), [buffer.rs](../../aimdb-embassy-adapter/src/buffer.rs) (test module), [embassy_smoke.rs](../../aimdb-serial-connector/tests/embassy_smoke.rs) — the third copy that 035 named as the trigger already exists. -**Approach.** As specified in 035 §2.4: `#[macro_export] #[doc(hidden)] macro_rules! host_test_stubs` in `aimdb-embassy-adapter`, expanded once per test binary; delete the three copies and the serial-connector's standalone `defmt` dev-dependency if nothing else needs it. **Size:** S. Do it the next time any of those test files is touched, or fold into the W1/W2 series. +**Approach.** As specified in 035 §2.4: `#[macro_export] #[doc(hidden)] macro_rules! host_test_stubs` in `aimdb-embassy-adapter`, expanded once per test binary; delete the three copies and the serial-connector's standalone `defmt` dev-dependency if nothing else needs it. **Size:** S. Do it the next time any of those test files is touched, or fold into the W1/W2 series. **Status:** implemented in PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) (with W5, stacked on #142). The time driver adopts the `wake_by_ref` superset variant from buffer.rs; the serial-connector `defmt`/`embassy-time-driver` dev-deps stay because the macro expansion references them at the invocation site — the "if nothing else needs it" condition does not hold. ### W7 — `aimdb-data-contracts` trait audit (034 §3.8, last unhandled row) @@ -104,6 +110,8 @@ Explicitly rejected: a non-`Copy` `Arc` key variant — it forks `RecordKey **Approach.** One-time audit: for each trait, list in-tree implementors and external consumers (aimdb-pro included). Traits with zero non-demo implementors get a deprecation note or deletion in the next breaking window — per 034 root-cause 7, speculative surface is the habit to break, not an emergency. **Size:** S (audit) + S (deletions). Output is a short table appended to this doc or the issue. +**Status: skipped entirely (decision 2026-06-12).** No audit will be run; the crate keeps its current surface. Re-open only if a data-contracts trait actively blocks other work. + --- ## 3. Assessment-only items (decision docs, not code) @@ -142,11 +150,11 @@ Both protocols now ride the session engine (the hard part), but two subscribe/wr |---|---|---| | W1 data-plane de-`Any` | PR [#141](https://github.com/aimdb-dev/aimdb/pull/141) | done — no separate issue, direct PR | | W2 `AnyRecord` split | PR [#142](https://github.com/aimdb-dev/aimdb/pull/142) | done — stacked on #141, no separate issue | -| W3 hardware matrix | — | none if run with #140; else a validation task | -| W4 ACK-retransmit knob | — | on #140 merge | -| W5 `StringKey` interner | — | opportunistic; file if not done by next release | -| W6 `host_test_stubs!` | — | opportunistic | -| W7 data-contracts audit | — | opportunistic | +| W3 hardware matrix | — | prep done 2026-06-12 (firmware build verified); bench session pending — the gate for closing 035 | +| W4 ACK-retransmit knob | PR [#144](https://github.com/aimdb-dev/aimdb/pull/144) | done — trigger fired same day via W3 scenario-2 evidence; stacked on #143 | +| W5 `StringKey` interner | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W6, stacked on #142 | +| W6 `host_test_stubs!` | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W5, stacked on #142 | +| W7 data-contracts audit | — | skipped entirely (decision 2026-06-12) | | A1 / A2 | — | on trigger only | Update this table with issue numbers as they are filed; when every row is filed or closed, this doc's status moves to Final and the live list is the issue tracker again.