# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.9.0-rc.46] - 2026-07-07

### Changed

- **iroh 1.0 stable** ([#276](https://github.com/defenseunicorns/peat-mesh/pull/276)).
  `iroh` `=1.0.0-rc.1` → `1.0.2`, `iroh-blobs` `=0.102.0` → `0.103.0`,
  `iroh-mdns-address-lookup` `=0.3.0` → `0.4.0`. Zero API breakage; exact
  pins dropped in favour of normal SemVer ranges now that iroh is post-1.0.

## [0.9.0-rc.45] - 2026-07-05

### Added

- **`NetworkedIrohBlobStore::fetch_blob_from_peer`** ([#274](https://github.com/defenseunicorns/peat-mesh/pull/274), peat#1013 expanded scope).
  Pull-only, targeted fetch of a blob from exactly one specified peer, over
  the existing content-addressed store — bypassing the automatic
  candidate-peer list and `PeerHealthIndex` readiness/cooldown filter, with
  no fallback to any other known peer on failure. For callers that already
  know out-of-band which peer holds a blob and want a direct, single-target
  attempt independent of document sync. The target peer must already be
  registered via `add_peer`, `add_peer_from_hex`, or `add_peer_from_hex_id`.
  The per-peer download-attempt logic (stream open, stall watchdog, success
  bookkeeping) is now shared with `fetch_blob`'s multi-candidate loop via a
  new private `attempt_download_from_peer` helper — no behavior change to
  `fetch_blob`.

## [0.9.0-rc.44] - 2026-07-04

### Added

- **mDNS-discovered peer dial** ([#268](https://github.com/defenseunicorns/peat-mesh/pull/268)).
  Peers discovered via `_peat._udp` mDNS browse are now automatically dialed
  into the formation. `PeerInfo::from_mdns_discovered` bridges raw mDNS records
  (hex-encoded `EndpointId` + socket addresses) into the `PeerInfo` the dial
  path consumes. `respond_to_formation_auth` provides the post-handshake dial
  entry point so the calling layer can wire mDNS discovery directly into
  formation join. This closes the Android "discovered-but-never-dialed" gap
  where mDNS browse surfaced peers but the transport layer had no path to
  initiate a connection to them.

- **`add_peer_from_hex_id`** ([#270](https://github.com/defenseunicorns/peat-mesh/pull/270)).
  Register a blob-distribution peer by hex-encoded `EndpointId`, complementing
  the existing `NodeId`-based registration path.

### Fixed

- **mDNS browse supervisor** ([#268](https://github.com/defenseunicorns/peat-mesh/pull/268)).
  The browse daemon is now supervised and respawned on panic instead of
  silently dying, and loopback/link-local addresses are filtered from
  dialable peer sets to prevent self-dial loops.

### Changed

- **QA review workflow hardening** ([#269](https://github.com/defenseunicorns/peat-mesh/pull/269),
  [#271](https://github.com/defenseunicorns/peat-mesh/pull/271),
  [#272](https://github.com/defenseunicorns/peat-mesh/pull/272)).
  Re-review on PR title/description edits; fork-origin guard removed;
  empty `CLAUDE_CODE_OAUTH_TOKEN` unset for fork PRs.

## [0.9.0-rc.43] - 2026-06-19

### Added

- **Provider gossip — `peat/blob-announce/1` ALPN** (defenseunicorns/peat-node#170).
  "Who holds blob X" now propagates across the mesh into the `BlobPeerIndex`
  that `fetch_blob` consults, and a node announces after each successful fetch,
  so a blob converges along connected chains. A dedicated ALPN (not a new
  `SyncMessageType`) keeps it compat-safe and isolated from the sync path;
  re-broadcast is bounded to newly-learned holdings (TTL) so the epidemic
  terminates. Holder claims are trust-gated by the delivering peer to prevent
  provider/address poisoning.

- **Distribution / file-transfer implementation relocated into peat-mesh**
  (`storage::file_distribution`, `storage::model_distribution`). This is the
  transport-specific implementation (`IrohFileDistribution`, the receive
  watcher, blob-fetch orchestration, and the ADR-071 interest-driven
  convergence seam — `NeedEvaluator`, `CollectionSubscriptionNeed`,
  `collection` on the distribution document) that previously lived in
  peat-protocol. peat-mesh is the canonical iroh consumer (ADR-060 §5), so the
  impl belongs here; peat-protocol will re-export it (defenseunicorns/peat#992).
  Additive — no existing peat-mesh behavior changes.

### Fixed

- **Inbound-accepted peers are now registered into `known_peers`.** Previously
  `known_peers` was populated only by *outbound* dials (discovery /
  `connect_peer`); a peer that dialed us was authenticated and synced but never
  recorded. Because `known_peers` drives both distribution targeting
  (`resolve_targets`) and blob-provider lookup (`fetch_blob`), a one-directional
  peering left attachment delivery broken in the un-dialed direction — the
  distribution *document* replicated over CRDT gossip but the file was never
  delivered ("synced but nothing written"). `SyncProtocolHandler::accept` now
  registers the peer into the blob store's `known_peers` after a successful
  formation handshake, so a single dial suffices between adjacent peers.
  Regression test: `inbound_dialed_peer_is_registered_in_known_peers`
  (`tests/two_backend_persistent_sync_e2e.rs`). Resolves the adjacent-peer half
  of [peat-node#170](https://github.com/defenseunicorns/peat-node/issues/170).

## [0.9.0-rc.42] - 2026-06-17

**Runtime n0-relay toggle for iroh endpoints ([#259](https://github.com/defenseunicorns/peat-mesh/pull/259)).**

### Added

- **Runtime `enable_n0_relay` selection for iroh endpoints.** Whether an iroh
  endpoint uses the n0-hosted relay is now a runtime argument rather than a
  compile-time-only choice. `relay_policy_builder(enable_n0_relay)` builds the
  endpoint with no n0 relay when `false` (the default — tactical/air-gapped
  posture) and opts into the n0-hosted relay when `true`. Covered by a new
  `relay_n0_sync_e2e` test. Additive; the default preserves existing behavior.

## [0.9.0-rc.41] - 2026-06-16

**Fix: break the stall-reset cycle that held 10 MB blobs at ~180 s on unshaped links ([#257](https://github.com/defenseunicorns/peat-mesh/pull/257)).**

### Fixed

- **`fetch_blob` now skips within-cooldown unhealthy peers instead of trying them last.** The prior "demote-not-skip" policy (peat-mesh #137) sorted stalled peers to the back of the attempt list but still tried them every sweep tick. With `peer_health_cooldown = 30 s` and `download_stall_timeout = 30 s`, a transiently overloaded peer stalled → reset its own cooldown → was tried again on the next tick, producing a 6 × 30 s = 180 s cycle observed in the v0.4.1 convergence sweep. The new `PeerHealthIndex::ready_peers` filter partitions the candidate list: within-cooldown Unhealthy peers are excluded entirely; `fetch_blob` returns a fast transient `Err` ("cooling off") when no ready peers remain, so the receive watcher retries in ~1 s instead of stalling. Single-source robustness is preserved: once the 30 s window expires the peer reverts to Neutral and gets a fresh first-class attempt. Expected improvement: transient-overload worst-case ~180 s → ~31 s (1 stall + 1 s sweep tick).

## [0.9.0-rc.39] - 2026-06-14

**Deterministic iroh endpoint identity for Kubernetes peer discovery ([peat-node#63](https://github.com/defenseunicorns/peat-node/issues/63)).**

### Added

- **`AutomergeBackendConfig.iroh_secret_key: Option<[u8; 32]>`** — when `Some`, the iroh endpoint is bound with a fixed `SecretKey` derived from these bytes (`iroh::SecretKey::from_bytes`), giving this node a stable, reproducible `EndpointId` across restarts. Required for Kubernetes-based peer discovery in peat-node: all formation members derive each pod's iroh identity from `HKDF-SHA256(ikm=sharedKey, info="iroh:"+podName)` without out-of-band exchange. `None` (default) preserves the existing ephemeral-key behavior. **Additive field**: struct-literal callers already using `..Default::default()` require no change.

## [0.9.0-rc.38] - 2026-06-10

**Circuit-breaker isolation, FullHistory sync performance, and operator lifecycle config.**

### Added

- **`AutomergeBackendConfig` gains `ttl_config: Option<TtlConfig>` and `gc_config: Option<GcConfig>` ([peat-node#136](https://github.com/defenseunicorns/peat-node/issues/136)).** Optional background tasks for short-lived document eviction (TTL) and tombstone/implicit-TTL garbage collection. `None` (default) for both skips the respective background tasks — safe for test consumers and environments that manage TTL/GC externally. **Breaking for struct-literal callers**: direct `AutomergeBackendConfig { … }` construction must now add `..Default::default()` as the final field. The struct is now `#[non_exhaustive]` to guard future additive fields against the same breakage pattern.

### Fixed

- **`SyncError::Document` no longer affects the peer circuit breaker ([#246](https://github.com/defenseunicorns/peat-mesh/issues/246)).** Document errors (absent doc, corrupt bytes, deserialise failure) are local conditions that say nothing about peer health. Previously, `handle_error` routed all errors — including `Document` — through `record_failure`, so a burst of document-level errors could open the circuit breaker and block subsequent syncs to a healthy peer. Fixed via `SyncError::affects_peer_circuit_breaker()` predicate + `record_local_error` path (advances diagnostic counters only, not `consecutive_failures`, `retry_attempt`, or `consecutive_successes`).
- **FullHistory `prepare_doc_for_sync` no longer calls `doc.save()` ([#236](https://github.com/defenseunicorns/peat-mesh/issues/236)).** `doc.save()` is O(edit-history depth); for FullHistory docs the snapshot bytes were only used for bandwidth-permit sizing, but FullHistory sync uses `initiate_delta_sync` on the live doc object and never reads the snapshot. `PreparedDoc.doc_bytes` is now `Option<Arc<Vec<u8>>>` — `None` for FullHistory (skips the O(N) save), `Some` for LatestOnly/WindowedHistory (unchanged). Bandwidth permit uses `FULL_HISTORY_BANDWIDTH_FLOOR` (64 KiB) rather than 0 to preserve QoS back-pressure.

## [0.9.0-rc.37] - 2026-06-08

**Determinism fix: a burst of deletes no longer silently drops the next write to a peer ([#243](https://github.com/defenseunicorns/peat-mesh/issues/243)).** A bulk delete could open a peer's circuit breaker and block all subsequent syncs to it, so a committed write would never cross that hop — in the C2-failover relay topology (the peer is the sole relay), it reached no downstream node. A cross-hop-liveness violation: the write succeeded locally but vanished, with no error surfaced to the caller.

### Fixed

- **Absent document tripped the peer circuit breaker — `automerge_sync.rs`.** `initiate_sync_inner` turned a deleted/absent document (`store.get` → `None`) into `Err` via `.context("Document not found for sync")`, which surfaces as `SyncError::Document` (Fatal / non-retryable) and increments the per-peer circuit breaker. A burst of ≥ `failure_threshold` (default 5) deletes then opened the breaker and blocked **all** subsequent syncs to that peer until `open_timeout` (default 5 s) elapsed — silently dropping unrelated, healthy writes. (Threshold and recovery matched the observed ≥5-deletes trigger and <5 s self-heal exactly.) Fix: an absent document is a clean skip (`Ok`) — there is nothing to send, and the deletion itself propagates via the tombstone sync path; a local document-state condition must never trip a peer-health breaker. Mirrors the existing send-side tombstone guard. Regression test `absent_document_sync_does_not_trip_circuit_breaker` (6 absent-doc syncs above the threshold must not open the breaker); lab-validated on the 7-node failover lab (post-burst write→peer: TIMEOUT → 0.01 s; breaker opens: 6+ → 0).

## [0.9.0-rc.36] - 2026-06-07

**Determinism fix: stop the CRDT gossip-amplification storm + sync-channel idle-timeout wedge ([#239](https://github.com/defenseunicorns/peat-mesh/issues/239)).** Under sustained writes the mesh could wedge and stop converging (timeouts; multi-second dead time) — a violation of the "every transaction deterministic, ≤ a couple seconds" SLA.

### Fixed

- **Gossip-amplification storm (dominant) — `automerge_sync.rs`.** On sync convergence the receive path (Issue #435) reset the peer `SyncState` to a fresh state keeping only `shared_heads`, discarding the negotiated quiescence (`their_heads`/`last_sent_heads`/`have_responded`). A converged doc then re-emitted a sync message on the next `generate_sync_message`; with the transitive gossip pusher (peat#891) that no-op send re-fired `gossip_tx` on every peer → re-broadcast → an **unbounded gossip-amplification storm** (~285 initiate/s on one unchanged doc) that starved new writes after a couple transactions. Fix: retain the converged (quiescent) `SyncState` and prune only the field that actually accumulates over a document's edit history — `sent_hashes` (`prune_converged_sync_state`). Quiescence is governed by the heads fields, not `sent_hashes`, so this bounds memory (the real #435 concern) without re-negotiating. On a 7-node failover lab this collapsed per-converged-doc traffic from ~735 (≤~5,700 worst) to ~10–12 delta-sends, and restored deterministic convergence (direct + failover-relay + 80 sustained writes + idle: zero timeouts).
- **Sync-channel receive-idle timeout — `sync_channel.rs`.** The receiver wrapped the wait for the next frame in a 30 s idle timeout, tearing down healthy idle channels whose receiver was then never re-armed → delivery wedged after any quiet period. Removed it; the loop blocks for the next frame and QUIC keepalive (1 s) / `max_idle_timeout` (5 s) handles genuine connection death (surfacing as a stream error → reconnect).

Both fixes ship with deterministic guard tests (`converged_resync_is_quiescent_after_issue_435_reset`, `receive_loop_delivers_frame_after_idle_beyond_old_recv_timeout`). The bandwidth-cycled convergence sweep is tracked separately ([#240](https://github.com/defenseunicorns/peat-mesh/issues/240) — bandwidth-bound transfers + a realistic per-cell tolerance), independent of this fix.

## [0.9.0-rc.35] - 2026-06-06

**Node vocabulary, Phase 3 — `HierarchyLevel::Platform` → `HierarchyLevel::Node` ([ADR-068](https://github.com/defenseunicorns/peat/blob/main/docs/adr/068-node-base-unit-vocabulary.md), epic [#968](https://github.com/defenseunicorns/peat-mesh/issues/968)).** Converges peat-mesh on **Node** as the single name for the base mesh participant. Wire-breaking for the human-readable `GeographicBeacon.hierarchy_level` encoding (`"Platform"` → `"Node"`); numeric discriminant (`Node = 0`) preserved. Unblocks the peat-side rename (peat#969), whose `peat-transport` TAK bridge already references `peat_mesh::HierarchyLevel::Node`.

### Changed

- **BREAKING (wire): `HierarchyLevel::Platform` renamed to `HierarchyLevel::Node` ([ADR-068](https://github.com/defenseunicorns/peat/blob/main/docs/adr/068-node-base-unit-vocabulary.md), epic [#968](https://github.com/defenseunicorns/peat-mesh/issues/968)).** Phase 3 of the peat-wide convergence on **Node** as the single name for the base mesh participant (eliminating the overloaded "Platform"). The base hierarchy tier is now `Node`; the ladder reads `Node → Cell → Cohort → Federation → Coalition`. Because `HierarchyLevel` derives `Serialize`/`Deserialize` by variant name, this changes the on-the-wire `GeographicBeacon.hierarchy_level` encoding from `"Platform"` to `"Node"` — a beacon emitted by a pre-rename peer no longer deserializes its base tier on a post-rename peer (and vice versa). Pre-1.0 clean break (no compatibility shim), consistent with [ADR-066](https://github.com/defenseunicorns/peat/blob/main/docs/adr/066-abstract-command-hierarchy.md)'s own wire break. The numeric discriminant (`Node = 0`) is unchanged. The `"platforms"` document-collection name and the `platform_type`/`node_type` schema field are a separate cross-transport wire flip handled in lockstep with peat-node (epic #968 Phase 4) and are **not** changed here.

## [0.9.0-rc.34] - 2026-06-06

**CRDT convergence under failover: QUIC keepalive on the sync endpoint + configurable blob-stall timeout.** Two fixes plus CI hardening. The headline is [#226](https://github.com/defenseunicorns/peat-mesh/issues/226) — the `AutomergeBackend::with_iroh` sync/blob endpoint was missing the tactical QUIC keepalive every `IrohTransport` endpoint already applies, so a half-open connection under dual-C2 failover stalled convergence to *reachable* peers in multiples of the 30 s app-level read timeout (peat-sim 7n-dual-c2: ~30/60/90 s outliers collapsed to a ~5–7 s worst case). Ships alongside the [#227](https://github.com/defenseunicorns/peat-mesh/pull/227) configurable stall-timeout knob (consumed by peat-node) and CI serialization for the #222 P2P flake ([#229](https://github.com/defenseunicorns/peat-mesh/pull/229)).

### Fixed

- **`fetch_blob` peer-health demotion is now `Stalled`-only ([#227](https://github.com/defenseunicorns/peat-mesh/pull/227), follows [#137](https://github.com/defenseunicorns/peat-mesh/issues/137)).** A peer was demoted on both `Stalled` and `Errored` fetch outcomes, but iroh-blobs surfaces "peer unreachable" and "peer reachable but doesn't have the blob yet" identically as `Error("Unable to download …")`. Demoting on `Errored` punished a healthy relay still staging the content (e.g. the surviving C2 in a dual-C2 failover) and, with a low stall timeout, drove a demote/oscillate thrash. Now only `Stalled` (no response within the threshold) and stream-open failure demote a peer; an errored attempt is treated as a transient content-availability condition.
- **CRDT sync now detects half-open/dead connections in ~5s instead of ~30s ([#226](https://github.com/defenseunicorns/peat-mesh/issues/226)).** `AutomergeBackend::with_iroh` — the endpoint backing the persistent CRDT sync channels and the blob store on every `peat-node` / `peat-protocol` consumer — was built *without* the tactical QUIC keepalive + idle-timeout (1s keepalive / 5s `max_idle_timeout`, Issue #315) that every `IrohTransport` endpoint already applies. With no transport-level liveness, a half-open connection (a peer that became unreachable, or a transient path disruption at a topology failover) was detected only by the 30s app-level `SyncChannel::RECV_TIMEOUT`. Because the on-change sync loop pushes a local write by enqueuing onto each peer's persistent channel, the write then sat undelivered on the half-open stream until that 30s timeout forced a reconnect and re-sync — so convergence to *reachable* peers stalled in multiples of 30s under dual-C2 failover (observed end-to-end in the peat-sim 7n-dual-c2 experiment: ~30/60/90s outliers on otherwise sub-100ms document sync, blob-independent and size-independent). Applying the same `create_tactical_transport_config()` closes the gap; the 1s keepalive additionally keeps a healthy-but-idle channel verified so it is never mistaken for stale. This same `create_tactical_transport_config()` is also applied to `NetworkedIrohBlobStore::build_endpoint_with_hooks` (the `peat-mesh-node` binary's endpoint), so the keepalive guarantee is uniform across every peat-mesh-built iroh endpoint.

### Added

- **`AutomergeBackendConfig::download_stall_timeout: Option<Duration>` ([#227](https://github.com/defenseunicorns/peat-mesh/pull/227)).** Threads a configurable per-attempt blob-download stall threshold through to the blob store (was the `IrohConfig` default only). `None` preserves the 30 s default. Lets a consumer (e.g. peat-node) shrink the first-fetch dead-peer wait for redundant-peer deployments. Additive-but-source-breaking for `AutomergeBackendConfig` struct-literal callers (like the earlier `cipher` field).

## [0.9.0-rc.33] - 2026-06-04

**Fix: peer-health-aware blob-fetch ordering ([#220](https://github.com/defenseunicorns/peat-mesh/pull/220), closes [#137](https://github.com/defenseunicorns/peat-mesh/issues/137)).** Single-fix release plus a developer-tooling toolchain pin.

### Fixed

- **`fetch_blob` no longer pays the full `download_stall_timeout` on every fetch when a preferred peer is unreachable ([#220](https://github.com/defenseunicorns/peat-mesh/pull/220), closes [#137](https://github.com/defenseunicorns/peat-mesh/issues/137)).** A new internal `PeerHealthIndex` records the most-recent per-peer fetch outcome and `fetch_blob` stably reorders its peer list by recent health — recently-successful peers first, untried/cooled-down peers next, recently-stalled/errored peers last (deprioritize, not skip). In dual-C2 deployments where one C2 is down, the dead peer is demoted behind the live one after the first observed stall, removing the ~30s per-blob penalty on subsequent fetches. Builds on the #122 stall watchdog.

### Added

- **`IrohConfig::peer_health_cooldown` (default 120s).** How long a recently stalled/errored peer stays deprioritized in the fetch ordering before reverting to neutral priority. New field on the `pub struct IrohConfig`; existing `..Default::default()` construction is unaffected. The low-level `from_endpoint_with_protocols_with_timeouts` constructor gains a matching trailing `peer_health_cooldown` argument (the default-using `from_config` / `from_endpoint_with_protocols` constructors are unchanged for callers).

### Changed

- **Pinned `rust-toolchain.toml` to the `stable` channel** (with `rustfmt` + `clippy` components) so bare `cargo` — and therefore the `.githooks/pre-commit` gate — resolves to a toolchain new enough for the iroh 1.0-rc / iroh-blobs tree (rustc >= 1.91). Matches CI's `dtolnay/rust-toolchain@stable`; developer-environment only, no effect on the published crate.

## [0.9.0-rc.32] - 2026-06-02

**Fix: tombstone resurrection race in CRDT sync ([#218](https://github.com/defenseunicorns/peat-mesh/pull/218), closes [#217](https://github.com/defenseunicorns/peat-mesh/issues/217)).** Single-fix release.

### Fixed

- **`initiate_sync` no longer re-sends tombstoned documents as live snapshots.** The send path (`initiate_sync_inner`) had no tombstone guard, while the receive path (`receive_sync_message`, `apply_state_snapshot`) both reject sync for tombstoned docs. Because `store.get` still returns the pre-delete bytes after a tombstone (separate metadata), a reconnect's "sync existing documents" pass re-sent a deleted doc as a `StateSnapshot`, racing the tombstone and resurrecting it on the peer when the snapshot applied before the tombstone was recorded. Adds the symmetric send-side `has_tombstone` guard. Surfaced as the flaky downstream peat-node `quickstart-compose` delete gate. Regression test (`initiate_sync_skips_tombstoned_document`) uses an on-disk store and pins that a tombstoned doc is never sent.

## [0.9.0-rc.31] - 2026-06-02

**Abstract hierarchy vocabulary (ADR-066 Phase 3) + Coalition tier — a wire-format-breaking rename release — plus collection-name alignment, a CRDT/transport dependency refresh, and persisted-sync-state hardening.** Six landed PRs ([#208](https://github.com/defenseunicorns/peat-mesh/pull/208), [#209](https://github.com/defenseunicorns/peat-mesh/pull/209), [#210](https://github.com/defenseunicorns/peat-mesh/pull/210), [#211](https://github.com/defenseunicorns/peat-mesh/pull/211), [#212](https://github.com/defenseunicorns/peat-mesh/pull/212), [#213](https://github.com/defenseunicorns/peat-mesh/pull/213)) advancing the workspace-wide [peat#904](https://github.com/defenseunicorns/peat/issues/904) rename epic and refreshing the dependency floor.

### Changed — BREAKING

- **Hierarchy vocabulary renamed `Squad`/`Platoon`/`Company` → `Cell`/`Cohort`/`Federation`, plus a new top `Coalition` tier (ADR-066 Phase 3, [#208](https://github.com/defenseunicorns/peat-mesh/pull/208)).** The model is now four fixed tiers above the platform: `Platform → Cell → Cohort → Federation → Coalition`. `HierarchyLevel` gains `Coalition` (= 4); strategies, routing, partition, and QoS are extended for the new tier. **Wire-format break on `HierarchyLevel` Serialize/Deserialize** — peers must run matching major-rename builds. 22 files renamed across hierarchy/routing/topology/beacon/qos; an end-to-end Federation→Coalition glue test wires `PeerSelector → SelectedPeer → TopologyState → SelectiveRouter` through the new tier. Tier 5+ is deferred to a future parametric N-tier ADR.
- **Collection names adopt the canonical hyphenated peat-schema convention ([#209](https://github.com/defenseunicorns/peat-mesh/pull/209)).** `SyncMode`, `QoSClass`, `DeletionPolicy`, `PropagationDirection`, `ResurrectionPolicy`, `SyncDirection`, and `TtlConfig` dispatch tables now use `node-states`, `cell-summaries`, `cohort-summaries`, `federation-summaries`, `coalition-summaries`, `contact-reports`, `audit-logs`, `track-history`, `capability-history`, `node-positions`. The legacy underscored names (and the pre-ADR-066 `squad_summaries`/`platoon_summaries`/`company_summaries`) are still accepted as transitional backward-compat shims — `SyncMode::default_for_collection` emits a once-per-name deprecation warning naming the canonical replacement. These shims will be removed in a future release after operator migration.

### Fixed

- **`SyncStatePersistence` tolerates undecodable persisted sync state instead of erroring ([#211](https://github.com/defenseunicorns/peat-mesh/pull/211)).** A persisted `automerge::sync::State` blob that no longer decodes (e.g. after an automerge version upgrade changed the encoding) previously failed the load — and in `load_all` (full recovery on restart), a single undecodable entry aborted recovery of **every** peer/doc sync state. Decode failures now degrade gracefully: `load_sync_state` returns `Ok(None)` (the peer re-negotiates sync from scratch — no data loss), and `load_all`/`load_all_for_peer` skip the bad entry and keep the good ones. redb I/O errors remain fatal.

### Dependencies

- **automerge `0.7` → `0.9`, iroh `=1.0.0-rc.0` → `=1.0.0-rc.1` (with `iroh-blobs` `0.101`→`0.102` and `iroh-mdns-address-lookup` `0.2`→`0.3` in lockstep), plus `rustls`/`lru` patch bumps ([#210](https://github.com/defenseunicorns/peat-mesh/pull/210)).** No on-disk Automerge document-format change across 0.8/0.9 (in-place store upgrade is safe). FIPS posture preserved: `aws-lc-rs` remains the runtime QUIC crypto provider; `ring` stays a compile-time transitive only. Mixed-version wire compat (rc.30 ↔ rc.31) verified via a local two-process harness — iroh QUIC handshake + automerge sync converge both directions.

### Testing / CI

- **mDNS rendezvous contract pinned for `iroh-mdns-address-lookup` 0.3 ([#212](https://github.com/defenseunicorns/peat-mesh/pull/212)).** New `#[ignore]`-gated integration test dials a peer by `EndpointId` only, exercising the real `_peat._udp.local` advertise/browse/resolve + QUIC handshake end-to-end.
- **`P2P Functional Tests` CI job on the self-hosted multicast runner ([#213](https://github.com/defenseunicorns/peat-mesh/pull/213)).** Runs the networking-dependent `#[ignore]`'d functional tests (`cargo test --features automerge-backend --tests -- --ignored`) that the GH-hosted job can't (no multicast). Auto-discovering and airgap-safe (external-infra tests stay feature-gated out). Gates releases via the `release.yml` → `ci.yml` `workflow_call`.

## [0.9.0-rc.30] - 2026-05-31

**Airgap-safe peer dial + accept-side initial-sync push — unblocks ephemeral consumers (peat-cli) joining established sidecar meshes.** Single landed PR ([peat-mesh#206](https://github.com/defenseunicorns/peat-mesh/pull/206)) closes [peat-mesh#205](https://github.com/defenseunicorns/peat-mesh/issues/205). Two fixes ship together because they share the same surface (the `MeshSyncTransport` connect path + `SyncProtocolHandler::accept`) and were uncovered as adjacent bugs while debugging peat-node PR #114's QUICKSTART CI: a fresh-empty-store CLI dialing an established sidecar would (a) consult iroh's `address_lookup` chain even with the address in hand, then (b) authenticate cleanly but never observe the sidecar's existing docs. End-to-end validated against peat-node `kp/quickstart-docs` quickstart-compose + cross-cluster Tests 1–6.

### Added

- **`MeshSyncTransport::connect_and_authenticate_with_addr(EndpointAddr)`** ([peat-mesh#206](https://github.com/defenseunicorns/peat-mesh/pull/206), closes [peat-mesh#205](https://github.com/defenseunicorns/peat-mesh/issues/205)). Surgical-caller variant alongside the existing `EndpointId`-only `connect_and_authenticate`. When the caller already has a populated `EndpointAddr` (peat-cli's `join::connect_peer`, peat-node sidecars' `dial_and_attach`, any out-of-band address-exchange path), this passes the address straight to `endpoint.connect(addr, ALPN)` so iroh sees `ip_addresses=[Ip(…)]` instead of `[]` — no `address_lookup` chain consultation, no DNS attempt, no chain-dispatch race. Guards against the obvious misuse (empty `addrs` set) with a loud `anyhow::bail!` at the call site rather than silently re-introducing the bug. Substantial doc-comments on both methods spell out when to prefer which variant.

### Fixed

- **`SyncProtocolHandler::accept` now spawns initial-sync push on every inbound connection.** Pre-fix, only the **dial** side called `sync_all_documents_with_peer` (via `peat-node::start_sync` and `peat-cli::join::connect_peer`). The **accept** side registered the connection and spawned the sync-receiver loop but never proactively pushed its own docs. That worked fine for sidecar-pair bootstrap because the dialer typically had docs too; it broke the moment an empty-store ephemeral consumer (peat-cli) dialed an established sidecar — the dialer's push iterated zero local docs, the acceptor never reciprocated, and the dialer's store stayed empty even though the connection was authenticated and idle. Accept side now symmetric with dial: spawns a one-shot `sync_all_documents_with_peer(peer)` after `start_sync_connection`, on a tokio task so iroh's `ProtocolHandler::accept` contract is preserved. Latent pre-existing bug surfaced by peat-node PR #114; not specific to the airgapped dial fix above.

- **`DataSyncBackend::connect_to_peer` honors the `addresses` it already accepts.** Pre-fix the implementation had `let _ = addresses;` and silently dropped the address hints consumers passed through this trait method — exactly the information that makes the airgap-safe path possible. Non-empty `addresses` now routes through `connect_and_authenticate_with_addr` (the new method above), preserving the legacy bare-id `connect_and_authenticate` path for the "caller only has the id, rely on registered lookup source" case. Consumer-surface half of the [peat-mesh#205](https://github.com/defenseunicorns/peat-mesh/issues/205) fix.

### Added — regression tests

- **`tests/connect_with_addr_airgapped.rs`** (5 in-tree pins, all run on every CI build):
  - **`old_method_requires_chain_new_method_bypasses_it`** — side-by-side proof on a single dialer endpoint with an empty `address_lookup` chain: the old `EndpointId`-only method fails (`No addressing information available`); the new `EndpointAddr` method succeeds. The chain-dependency is isolated as the only variable.
  - **`connect_with_addr_rejects_empty_addrs`** — pins the misuse-rejection guard: `EndpointAddr::new(id)` (sentinel-empty addrs) hits a loud `anyhow::bail!` instead of silently falling back to the chain.
  - **`connect_with_addr_empty_guard_fails_before_any_io`** — timing pin (`elapsed < 50 ms`) that catches a future refactor that moves the guard after `endpoint.connect(...).await`.
  - **`sync_round_trip_via_with_addr_adapter_path`** — full Automerge sync round-trip through `DataSyncBackend::connect_to_peer`'s new with-addr branch with no `MemoryLookup` population. Proves the adapter actually establishes a sync-capable connection, not just a connect+auth handshake.
  - **`accept_side_pushes_existing_docs_to_fresh_dialer`** — pins the accept-side spawn introduced above: backend B upserts a doc *before* A connects, A connects fresh-empty via `connect_and_authenticate_with_addr` + `start_sync_connection`, A's `document_store` observes B's doc within 10 s. Pre-fix (verified by reverting the spawn while keeping the test) the test times out at the 10 s deadline. If the accept-side spawn ever regresses, this test catches it deterministically — A has zero local docs to push, so the accept-side path is the only way the doc can reach A.

### Refs

- peat-node PR #114 QUICKSTART CI on `kp/quickstart-docs`: all four gates (CI, Quickstart compose, cross-cluster sync, QA Review) confirmed green against rev `cfa9023` and forward. Receipt thread on [peat-mesh#205](https://github.com/defenseunicorns/peat-mesh/issues/205).

## [0.9.0-rc.29] - 2026-05-30

**`subscribe_to_observer_changes` now fires on tombstone-driven deletes — closes the documented "fires for ALL document changes" contract on the CDC channel.** Single landed PR ([peat-mesh#203](https://github.com/defenseunicorns/peat-mesh/pull/203)) closes [peat-mesh#202](https://github.com/defenseunicorns/peat-mesh/issues/202). Pre-rc.29 `AutomergeStore::delete` silently removed from disk + cache without firing any of the three broadcast channels, so a CDC consumer subscribed to the documented channel saw inserts and updates from any peer but never saw deletes from any peer. Blocked the operator-visible `peat observe`-with-`peat delete` cross-CLI flow (peat-node ADR-001 Open Question §7).

### Fixed

- **`AutomergeStore::delete` fires the same broadcast pipeline as `put`** ([peat-mesh#203](https://github.com/defenseunicorns/peat-mesh/pull/203), closes [peat-mesh#202](https://github.com/defenseunicorns/peat-mesh/issues/202)). `delete(&self, key)` now routes through a new `delete_inner(key, notify, origin)` private path that fires `observer_tx` (CDC) + `change_tx` (local sync-out trigger) + `gossip_tx` (origin-tagged), mirroring the existing `put` / `put_with_origin` channel-gating matrix exactly. The contract repair is observable: a CDC consumer subscribed to `subscribe_to_observer_changes` now sees `Ok(None)` from `store.get(key)` on the event when the delete originated from any source (local or remote-via-tombstone), making the "Some → insert/update, None → delete" detection pattern (peat-cli's `cli/observe.rs:94-102` shape) work end-to-end.

### Added

- **`AutomergeStore::delete_with_origin(&self, key, origin: ChangeOrigin)`** ([peat-mesh#203](https://github.com/defenseunicorns/peat-mesh/pull/203)). Mirrors `put_with_origin`: `ChangeOrigin::Local` fires `change_tx` (sync-out trigger); `ChangeOrigin::Remote(peer)` suppresses it per the peat-mesh#115 ping-pong invariant and tags `gossip_tx` with the peer attribution for transitive-gossip drivers to skip the source. `observer_tx` always fires.

### Changed

- **`AutomergeSyncCoordinator::apply_tombstone`** now routes the post-tombstone document removal through `delete_with_origin(.., Remote(peer))` instead of the silent `delete()` call. Same observable behaviour for `change_tx` (suppressed per peat-mesh#115); new observable behaviour for `observer_tx` + `gossip_tx(Remote(peer))` — CDC consumers see remote-driven deletes; transitive-gossip pushers see the peer attribution.
- **`AutomergeStore::subscribe_to_observer_changes` doc-comment** updated to reflect the post-#202 contract: explicitly lists tombstone-driven deletes among the events the channel fires, and adds the subscriber-side detection pattern (`store.get` returning `Ok(Some(_))` → insert/update; `Ok(None)` → delete) inline. The contrast with `subscribe_to_changes` shifts from "local puts vs. all changes" to "local writes (puts and deletes) vs. all writes."
- **`AutomergeStore::ttl_manager` TTL eviction sites** now carry inline comments naming peat-mesh#202: each `store.delete` call in the synchronous eviction path and the background cleanup task wakes `change_tx` per the rc.29 contract, so a TTL eviction wakes the sync-coordinator's local-only outbound pusher as a side-effect (the document is gone by the time the pusher runs — wakeup, not propagation). Comments give future readers an in-place diagnostic hook if they trace a steady-state wakeup rate back to TTL expiry cadence.

### Added — regression tests

- **`tests/observer_fires_on_delete.rs`** ([peat-mesh#203](https://github.com/defenseunicorns/peat-mesh/pull/203)): 4 integration pins through the public surface — `delete_fires_observer_tx`, `delete_fires_local_change_observer_and_gossip_channels`, `delete_with_remote_origin_suppresses_change_tx_and_tags_gossip`, `delete_of_missing_key_still_fires_observer`.
- **`src/storage/automerge_sync.rs::tests::persistent_stream_wire_format_peat_mesh_175::tombstone_receive_fires_observer_tx`**: dispatch-tier in-tree pin. Seeds a doc on the receiver's store, drives a real Tombstone v2 frame through `handle_incoming_sync_stream`, asserts (a) the doc is removed AND (b) the observer channel wakes with the doc key. Catches a future regression where `apply_tombstone` reverts to the silent `delete()` call or bypasses the observer broadcast some other way.

## [0.9.0-rc.28] - 2026-05-29

**`peat-cli` round-trip-edit + tombstone-authorship unblocker — Automerge delta API, node-local Lamport clock, cross-restart persistence, and sync-receive wire-up.** Four landed PRs ([peat-mesh#193](https://github.com/defenseunicorns/peat-mesh/pull/193), [peat-mesh#194](https://github.com/defenseunicorns/peat-mesh/pull/194), [peat-mesh#197](https://github.com/defenseunicorns/peat-mesh/pull/197), [peat-mesh#198](https://github.com/defenseunicorns/peat-mesh/pull/198)) close [peat-mesh#187](https://github.com/defenseunicorns/peat-mesh/issues/187), [peat-mesh#192](https://github.com/defenseunicorns/peat-mesh/issues/192), [peat-mesh#195](https://github.com/defenseunicorns/peat-mesh/issues/195), and [peat-mesh#196](https://github.com/defenseunicorns/peat-mesh/issues/196). Together they replace the `peat-cli` wall-clock Lamport proxy with a peat-mesh-managed clock, surface the Automerge delta primitive `peat-cli` needs for `peat update --from <PATH>`, persist the clock across restarts, and wire the receive-side rule so cross-node Lamport semantics hold without consumer-side threading.

### Added — Automerge delta API ([peat-mesh#193](https://github.com/defenseunicorns/peat-mesh/pull/193), closes [peat-mesh#187](https://github.com/defenseunicorns/peat-mesh/issues/187))

- **`AutomergeStore::diff(current, proposed) -> AutomergeDelta`** — associated function (no store state). Wraps `Automerge::get_changes_added`; returns the changes in `proposed` not in `current` as an opaque delta.
- **`AutomergeStore::apply_delta(&self, key, &AutomergeDelta)`** + **`apply_delta_with_origin(&self, key, &AutomergeDelta, ChangeOrigin)`** — read-modify-write under the per-key striped lock, routes the resulting state through `put_inner` so observers, the sync coordinator's `change_tx`, and the origin-tagged `gossip_tx` all fire identically to a `put`. The `Remote(peer)` origin variant suppresses `change_tx` per the peat-mesh#115 ping-pong invariant.
- **`AutomergeDelta::to_bytes` / `from_bytes`** — `u32`-LE-prefixed framing per change, so the delta crosses a process boundary safely. Truncation surfaces as a named-diagnostic `Err`.
- **8 behavioural pins** in `tests/automerge_delta_api.rs`: diff/apply roundtrip preserves operation history (ADR-021 invariant), empty-delta no-op, missing-key diagnostic, Local-origin observer fan-out, Remote-origin `change_tx` suppression, bytes round-trip, truncation rejection.

Unblocks peat-node ADR-001 Phase 4b — `peat update --from <PATH>` round-trip-edit flow.

### Added — node-local Lamport clock ([peat-mesh#194](https://github.com/defenseunicorns/peat-mesh/pull/194), closes [peat-mesh#192](https://github.com/defenseunicorns/peat-mesh/issues/192))

- **`AutomergeBackend::next_lamport() -> u64`** — read-and-advance the node-local Lamport clock. Strictly monotonic across concurrent callers (`fetch_add(Relaxed).wrapping_add(1)`).
- **`AutomergeBackend::current_lamport() -> u64`** — non-advancing read for diagnostics.
- **`AutomergeBackend::observe_lamport(observed: u64)`** — Lamport receive-side rule `L := max(L, observed) + 1`, implemented via CAS retry loop. Capped at `u64::MAX - 1` so a hostile or buggy peer's `observed = u64::MAX` cannot saturate the atomic and wrap `next_lamport` to `0`.
- Seeded at construction from `SystemTime::now()` nanos with a `1u64` floor.
- **6 behavioural pins** in `tests/lamport_clock_source.rs`: monotonicity, non-advancing read, observe-jumps-clock, observe-never-regresses, uniqueness across 8 × 1024 concurrent calls, seed-above-zero.

Replaces the `peat-cli delete` wall-clock proxy at the right surface — strict node-local monotonicity under concurrent writes, regardless of NTP drift.

### Added — Lamport persistence across restart ([peat-mesh#197](https://github.com/defenseunicorns/peat-mesh/pull/197), closes [peat-mesh#195](https://github.com/defenseunicorns/peat-mesh/issues/195))

- **`AutomergeStore::read_lamport_highwater() -> Result<Option<u64>>`** + **`write_lamport_highwater(u64) -> Result<()>`** — new `metadata` redb table; the write is **monotonic in-transaction** (reads existing, skips write when `existing >= value`) so the periodic flush task and an explicit flush cannot interleave to regress the on-disk high-water. Corrupt entries (wrong length) log a warning and treat as `None`, allowing the next write to self-heal.
- **`AutomergeBackend::flush_lamport_highwater() -> Result<()>`** — synchronous explicit flush. Called from `DataSyncBackend::shutdown` so the on-disk value reflects every issued value at clean exit (zero worst-case loss); also available for test rigs and explicit shutdown hooks.
- **Background flush task** — 1-second cadence, skips when the atomic hasn't changed since the last flush. `last_flushed` seeded from `persisted_highwater` at startup so an idle backend doesn't re-write the same value redundantly. Holds `Weak<AutomergeStore>` across the sleep — task exits when the backend drops.
- **Seed formula** — `max(persisted_highwater, SystemTime nanos, 1)`. Resists wall-clock regression on tactical hardware (battery-less RTC, NTP slew, manual time-set, boot-at-epoch) — the resumed clock stays above any value the prior run could have issued, even when the wall clock has gone backwards.
- **`AutomergeBackend::shutdown_and_release(&self) -> Result<()>`** — async teardown that awaits the iroh Router's shutdown future, so production callers (hot-restart, daemon-rotate, mobile pause/resume) and integration tests can deterministically drop the backend and immediately reopen the same `data_dir` in the same process. Idempotent via an `AtomicBool` CAS guard against signal-handling races. Bounded by `NetworkedIrohBlobStore::shutdown_timeout` (5s default).
- **4 new pins** in `tests/lamport_clock_source.rs`: cross-restart persistence (build → shutdown → rebuild → assert resumed seed > prior high-water), monotonic-write race regression, sequential idempotency of `shutdown_and_release`, concurrent-CAS-race safety under `multi_thread` runtime. Plus an in-module unit pin for corrupt-entry recovery.

### Added — `observe_lamport` sync-receive wire-up ([peat-mesh#198](https://github.com/defenseunicorns/peat-mesh/pull/198), closes [peat-mesh#196](https://github.com/defenseunicorns/peat-mesh/issues/196))

- **`AutomergeSyncCoordinator::set_lamport_clock(Arc<AtomicU64>)`** — opt-in setter, called from `AutomergeBackend::with_iroh` so the coordinator's tombstone-receive path advances the clock automatically. Standalone coordinator consumers (peat-mesh-node binary, unit tests) skip the call; the receive-side wire-up no-ops and preserves existing behaviour.
- **`handle_incoming_tombstone`** observes `tombstone.lamport` before applying. **`handle_incoming_tombstone_batch`** observes the **max** Lamport across the batch in a single CAS (idempotent, equivalent to per-entry observes but cheaper by N-1 attempts).
- **Shared `observe_atomic` helper** at `pub(crate)` visibility — both `AutomergeBackend::observe_lamport` (consumer-facing API) and the coordinator's receive-side dispatch share one CAS retry loop, one memory ordering choice (`AcqRel` / `Acquire`), and one `u64::MAX - 1` cap.
- **3 in-tree dispatch-tier pins** in `src/storage/automerge_sync.rs::tests`: single-tombstone receive advances wired clock past inbound Lamport; batch receive picks max Lamport across entries; no-op when no clock wired (opt-in contract). Plus a `u64::MAX` cap regression test through the full `handle_incoming_sync_stream` path.

Completes the cross-node half of Lamport partial-order semantics: a locally-authored operation that follows an inbound tombstone receive sorts strictly after the observed remote Lamport in the causal order Lamport's partial order captures.

### Out of scope (acknowledged follow-up)

- **Lookahead persistence for unclean-shutdown bounds.** rc.28 ships persistence with a 1-second periodic flush cadence + clean-shutdown flush; worst-case loss on an unclean crash is up to one periodic interval of issued values. Tightening that (smaller interval at the cost of redb write churn) is a trade-off better made after observing real workloads.
- **Backend-level abort of the Lamport flush task on `shutdown_and_release`.** The current shape relies on the existing `Weak<AutomergeStore>`-upgrade-fails-on-drop pattern (consistent with `observer_task`). The post-shutdown periodic-tick window is harmless because the storage-layer write is monotonic. Carried forward.

## [0.9.0-rc.27] - 2026-05-28

**`DocumentStore::get` perf override on both backends + UAT scope clarification — peat-mesh#175 closure follow-throughs.** rc.26's release notes named the `AutomergeBackend::get` O(N) trait-default trap as out-of-scope follow-up; rc.27 closes that gap and the analogous one on `InMemoryBackend`, plus lands the doc-comment scope distinction on the in-CI UAT that the peat-sim 7n-dual-c2 lab re-validation made explicit.

### Performance

- **`AutomergeBackend::get` keyed-lookup override** ([peat-mesh#189](https://github.com/defenseunicorns/peat-mesh/pull/189), closes [peat-mesh#186](https://github.com/defenseunicorns/peat-mesh/issues/186)). The trait-default `DocumentStore::get(collection, id)` routes through `query(Query::Eq{id})`, which in `AutomergeBackend::query` does a `scan_prefix(collection_prefix)` + Automerge-deserialize of every entry in the collection and filters in Rust. Single-doc `get` was O(N); per-id loops O(N²). The override does one `self.store.get(&Self::key_for(collection, doc_id))` + one Automerge parse — O(1) w.r.t. collection size, using the same key shape `upsert_with_origin` already builds. Deletion semantics intentionally match the trait-default (soft-deleted docs return `None`; `IncludeDeleted` callers continue to route through `query()` directly — `get` has no `Query` parameter). Regression test pins (a) hit, (b) miss, (c) collection isolation, (d) soft-delete filter equivalence.
- **`InMemoryBackend::get` keyed-lookup override** ([peat-mesh#190](https://github.com/defenseunicorns/peat-mesh/pull/190)). Same trap on the in-memory backend — trait-default `get` iterated `col.values()` and filtered in Rust. Override is two HashMap point lookups: `collections.get(collection)?.get(doc_id)`. Same deletion-semantics contract as the AutomergeBackend override. Regression test mirrors #189's 4-case shape plus an extra miss sub-case (`get` against an unknown collection short-circuits before the per-doc lookup; both branches pinned).

### Documentation

- **In-CI UAT scope clarification** ([peat-mesh#188](https://github.com/defenseunicorns/peat-mesh/pull/188)). Adds a "Scope — what this UAT validates and what it does NOT" subsection to `tests/persistent_sync_delivery_under_constrained_bandwidth.rs`'s file header. Makes explicit that the UAT covers the *symmetric two-peer / single-relay* case and that the *multi-emitter / oversubscribed-link* case lives in `peat-sim/experiments/7n-dual-c2/sweep-telemetry-rate.sh`. A regression here means the persistent-stream architecture broke; a regression in the lab without a regression here means something changed in the workload or shaping, not in the sync coordinator. Resolves the ambiguity peat-mesh#175's closing characterisation and peat-mesh#184 QA round-1 had previously flagged.

### Out of scope (acknowledged follow-up)

- **Workload-characterisation gap** ([peat-mesh#175](https://github.com/defenseunicorns/peat-mesh/issues/175) closed at the realistic-workload bar; the 5-platform concurrent oversubscribed-link corner is still bandwidth-bound, not coordinator-bound — that gap is documented in peat-mesh#175's closing characterisation and `peat-sim/experiments/7n-dual-c2/REPORT.md` §3.5.3).

## [0.9.0-rc.26] - 2026-05-28

**Behavioural UAT for peat-mesh#175 — delivery-ratio thresholds under constrained bandwidth in-CI** — adds [peat-mesh#184](https://github.com/defenseunicorns/peat-mesh/pull/184), the in-CI behavioural counterpart to the rc.25 architectural pin from [peat-mesh#180](https://github.com/defenseunicorns/peat-mesh/pull/180). Two `AutomergeBackend::with_iroh` peers route their iroh QUIC traffic through a userspace UDP relay applying per-direction token-bucket pacing (256 kbps, ~MTU burst capacity, 100 ms one-way delay) — same shape as the netem setup in the original issue. Bidirectional emission for 30 s steady + 10 s drain per case; worst-direction delivery ratio asserted against peat-mesh#175's thresholds:

| Rate  | peat-mesh#175 threshold | rc.26 in-CI (MTU bucket, both directions) |
|-------|-------------------------|-------------------------------------------|
| 1 Hz  | ≥ 99.5%                 | 100% / 100%                               |
| 10 Hz | ≥ 99.0%                 | 100% / 100%                               |
| 25 Hz | ≥ 95.0%                 | 100% / 100%                               |

Total wall-clock 124.7 s for all three cases. Fits in the main PR test job — no `#[ignore]`, no nightly tier.

### Added — regression tests

- **`tests/persistent_sync_delivery_under_constrained_bandwidth.rs`** ([peat-mesh#184](https://github.com/defenseunicorns/peat-mesh/pull/184), refs [peat-mesh#175](https://github.com/defenseunicorns/peat-mesh/issues/175)): three `#[tokio::test]` cases pin the issue's delivery-ratio thresholds under constrained-bandwidth shaping. Pairs with `tests/two_backend_persistent_sync_e2e.rs` (architectural pin from rc.25) — code-shape + measurement coverage for the peat-mesh#178 BLOCKER class. UAT scope is functional validation only (does the persistent stream hit the issue's thresholds — yes/no); parameter sweeps and slow-leak hunting belong in `peat-sim`, not in this per-repo UAT. Bucket capacity sized at ~MTU rather than 1 second of bandwidth so each packet pays its own refill wait at link rate (resolves the peat-mesh#184 QA round-1 [WARNING] that the shaping might be effectively unconstrained for the workload sizes).

### CI

- **QA review workflow: Sonnet 4.6 fallback after 3 failed Opus 4.7 attempts** ([peat-mesh#182](https://github.com/defenseunicorns/peat-mesh/pull/182)): `qa-review.yml` falls back to Claude Sonnet 4.6 when the primary Opus 4.7 model repeatedly fails to complete, keeping review coverage alive when the primary is degraded.

### Out of scope (acknowledged follow-up)

- **`AutomergeBackend::get` perf bug.** Surfaced while sizing the rc.26 UAT: `AutomergeBackend` doesn't override the `DocumentStore::get` trait default, so single-doc lookups route through `query() → scan_prefix + Automerge deserialize of the entire collection`. Every single-doc `get(collection, id)` is O(N); any caller looping over IDs is O(N²). The UAT's `count_delivered` works around it with a single `Query::All` + Rust set-intersection; the underlying ~10-line keyed-lookup override on `AutomergeBackend` (using `Self::key_for(collection, id)`, the same key shape the upsert path builds) will be filed as a separate peat-mesh perf issue with a reproducing micro-bench.
- **Workload-characterisation gap** ([peat-mesh#175](https://github.com/defenseunicorns/peat-mesh/issues/175) remains partly open): rc.25's release notes call out the 5-platform concurrent case under bandwidth contention with attachment download — that's bandwidth-bound, not sync-coordinator-bound, and remains workload-side characterisation work, not closed by this UAT.

## [0.9.0-rc.25] - 2026-05-27

**Persistent multiplexed sync streams for Automerge — ADR-063 closure** — closes [peat-mesh#175](https://github.com/defenseunicorns/peat-mesh/issues/175) and [peat-mesh#177](https://github.com/defenseunicorns/peat-mesh/issues/177). Three landed PRs ([peat-mesh#176](https://github.com/defenseunicorns/peat-mesh/pull/176), [peat-mesh#178](https://github.com/defenseunicorns/peat-mesh/pull/178), [peat-mesh#180](https://github.com/defenseunicorns/peat-mesh/pull/180)) replace the legacy per-message-stream sync path with one persistent multiplexed bi-stream per peer, driven by a writer-task + mpsc design that holds API-level concurrency unconstrained while serialising bytes on the wire. Architectural decision lives in [peat ADR-063](https://github.com/defenseunicorns/peat/blob/main/docs/adr/063-persistent-sync-streams.md).

Lab UAT against `peat-sim/experiments/7n-dual-c2`'s `sweep-telemetry-rate.sh` on the shaped 256 kbps / 100 ms link (single-platform emitter, 3 trials per rate) — all three delivery thresholds now pass:

| Rate  | rc.24 (legacy fallback) | rc.25 (persistent path) | peat-mesh#175 threshold |
|-------|-------------------------|-------------------------|-------------------------|
| 1 Hz  | 100% / 100%             | 100% / 100%             | ≥ 99.5% ✓               |
| 10 Hz | 94.6% / 94.6%           | 98.9% / 100%            | ≥ 99.0% ✓               |
| 25 Hz | 85.3% / 85.5%           | 98.8% / 100%            | ≥ 95.0% ✓               |

### Added — persistent sync channel wire-up ([peat-mesh#176](https://github.com/defenseunicorns/peat-mesh/pull/176), [peat-mesh#178](https://github.com/defenseunicorns/peat-mesh/pull/178))

- **`AutomergeBackend::with_iroh` wires `SyncChannelManager` unconditionally.** Every consumer (peat-protocol, peat-node, peat-sim) now takes the persistent multiplexed bi-stream per peer; the legacy per-message-stream path is retained only as a reconnect-window fallback per ADR-063 §D4. The strong `Arc<SyncChannelManager>` is owned as a field on `AutomergeBackend` so the coordinator's `Weak` upgrades for the lifetime of the backend (the alternative — relying on the construction-scope local — produced the silent fall-through bug that peat-mesh#178's QA review caught: every consumer ran the legacy path even though the wire-up appeared correct).
- **`SyncChannel` send path**: replaced the per-peer `Mutex<Option<SendStream>>` with `mpsc::Sender<Vec<u8>>` + a dedicated writer task per channel. User-visible `send()` is non-blocking (returns when the frame is queued, not when written), eliminating fan-out contention on the user-facing path. Bounded mpsc capacity (1024 frames) flows backpressure to callers when the writer can't keep up.
- **Wire-format symmetry on the v2 framing**. `SyncChannel::receive_loop` rewritten to call `AutomergeSyncCoordinator::receive_sync_payload_from_stream` in a loop, replacing the pre-#175 `[1 marker][4 len][N data]` reader that desynchronized on the first byte of every frame (the sender writes `[2 doc_key_len][N doc_key][1 msg_type][4 payload_len][payload]`). `handle_incoming_sync_stream` wrapped in a frame loop with `ReadExactError::FinishedEarly(0)` as the graceful-close signal.
- **`AutomergeSyncCoordinator::dispatch_received_payload`**: extracted as the single source of truth for the per-msg-type dispatch table (`Delta` / `StateSnapshot` / `Tombstone` / `TombstoneBatch` / `Batch` / `NegentropyInit` / `NegentropyResponse`). Both the accept-side persistent loop and the connect-side `SyncChannel::receive_loop` route through it.
- **`AutomergeSyncCoordinator::has_persistent_sync_channel(&self) -> bool`** — `pub` accessor that returns whether the coordinator's `Weak<SyncChannelManager>` currently upgrades. Used by the regression-pin integration test to detect dropped-`Arc` regressions directly rather than only via downstream delivery (legacy fallback also delivers).
- **Reconnect-race generation counter** (`peat-mesh#178` QA [WARNING]): each `SyncChannel` carries an `Arc<AtomicU64>` writer generation. `spawn_writer` captures its own generation at spawn time and only writes channel state if its generation still matches the channel's current — stale writers from prior reconnect incarnations log-and-exit silently rather than clobbering a freshly-installed `Connected` with `Reconnecting`.

### Added — regression tests

- **`sync_channel_send_writer_task_delivers_concurrent_batches`** ([peat-mesh#178](https://github.com/defenseunicorns/peat-mesh/pull/178), `automerge_sync.rs::tests::persistent_stream_wire_format_peat_mesh_175`): 8 concurrent `SyncChannel::send` calls; asserts every batch's `DeltaSync` entry materialises on the responder. Catches future regressions to the writer-task pipeline (re-introducing the per-peer mutex, capacity bugs, reconnect-race state clobbering).
- **Per-variant dispatch pins** ([peat-mesh#178](https://github.com/defenseunicorns/peat-mesh/pull/178), closes [peat-mesh#177](https://github.com/defenseunicorns/peat-mesh/issues/177)): round-trip pins for `StateSnapshot`, `Tombstone`, `TombstoneBatch` (counter-only; in-memory store no-ops `put_tombstone`), plus a combined parse-side pin for `NegentropyInit` / `NegentropyResponse`. Combined with the prior `Batch` and `DeltaSync` pins from [peat-mesh#176](https://github.com/defenseunicorns/peat-mesh/pull/176), the 7-arm dispatch table is now end-to-end covered.
- **`tests/two_backend_persistent_sync_e2e.rs`** ([peat-mesh#180](https://github.com/defenseunicorns/peat-mesh/pull/180)): integration test that builds two real `AutomergeBackend::with_iroh` instances, cross-populates their iroh `MemoryLookup`s, asserts the persistent channel is wired via `has_persistent_sync_channel()` on both sides, and exercises a full A → B sync round-trip. Catches the peat-mesh#178 BLOCKER class (dropped `Arc<SyncChannelManager>`) directly in CI rather than via lab UAT only. Verified to fail when the strong-ref field is removed from `AutomergeBackend`, with the diagnostic pointing straight at the BLOCKER context. Loopback convergence ~101 ms; 30 s timeout budget.

### Fixed

- `peat-mesh-node` binary's manual `SyncChannelManager` wiring at `src/bin/peat-mesh-node.rs:293-297` had the same latent dropped-`Arc` bug as the pre-fix `AutomergeBackend::new` — the local was moved into `set_channel_manager` which downgraded to `Weak` and dropped the strong ref. Fixed analogously by rebinding to `_channel_manager` for the lifetime of `main()`.

### Documentation

- `SyncChannel::send` doc-comment now contracts the delivery semantics: `Ok(())` = "enqueued, will be best-effort delivered" (not "written to the wire"). `DeltaSync` / `Batch` recover via Automerge vector-clock; `Tombstone` / `StateSnapshot` are at-most-once-eventually with silent drops across reconnects — caller-side retry flagged as future work.
- `SEND_QUEUE_CAPACITY` doc-comment clarifies the bound is per-FRAME-count (1024 frames ≈ 512 KB per peer for the typical workload), not per-byte: `StateSnapshot` payloads carry full `doc.save()` blobs and can push per-peer memory to gigabytes under snapshot pressure. Sized-Semaphore byte accounting flagged as the right next step if that pressure becomes a deployment concern.
- `bytes_sent` doc-comment notes the counter is incremented at enqueue, not at write; bounded drift per reconnect cycle.

### Out of scope (acknowledged follow-up)

- **Workload-characterisation gap** ([peat-mesh#175](https://github.com/defenseunicorns/peat-mesh/issues/175) remains partly open): the lab script runs telemetry concurrent with a 1 MB attachment download on the shaped 256 kbps link, so the combined offered load (~42 KB/s) exceeds capacity (32 KB/s). The thresholds in this release pass on the platform-3-only emitter case; the 5-platform concurrent case is bandwidth-bound, not sync-coordinator-bound — workload-side characterisation is now-isolated follow-up.
- **Persistent-stream registration on the accept side** (ADR-063 §D3, "send half held in per-peer channel state on accept side as well"): the bidirectional QUIC stream's send half is already reusable by handlers like `NegentropyResponse` via `dispatch_received_payload`'s `send` argument; full accept-side `SyncChannelManager` registration is deferred to a separate slice.
- **Reconnect-mid-send fault injection** (peat-mesh#178 QA [SUGGESTION]): the generation-counter fix is defended-by-construction; a deterministic mid-send fault-injection regression pin requires QUIC-stream-level fault tooling and is deferred.

## [0.9.0-rc.24] - 2026-05-26

**`QuicMeshConnection` trait, narrow Connection surface** — closes [peat-mesh#167](https://github.com/defenseunicorns/peat-mesh/issues/167). Replaces the `pub use iroh::endpoint::Connection` re-export at `peat_mesh::network::Connection` with a narrow trait `peat_mesh::network::QuicMeshConnection` that names exactly the four methods peat-protocol's formation-handshake legitimately uses (`open_bi`, `accept_bi`, `close_reason`, `remote_endpoint_id`). A future iroh `Connection` method addition no longer widens peat-protocol's reachable API surface by default. Closes the ADR-062-Phase-2-follow-up gap the QA reviewer of peat-mesh#166 flagged: "transport-agnosticism enforced only at the import path, not the API shape."

### ⚠ Breaking — public re-export surface change

`peat_mesh::network::Connection` (= `iroh::endpoint::Connection`, added in rc.22) is removed. The sole prior consumer — peat-protocol's `formation_handshake.rs` — migrates to `&dyn QuicMeshConnection` in the peat consumer-side PR ([peat#932](https://github.com/defenseunicorns/peat/issues/932), peat 0.9.0-rc.16). Any external downstream consumer that imported `peat_mesh::network::Connection` directly must switch to either `peat_mesh::network::QuicMeshConnection` (the narrow trait) or `iroh::endpoint::Connection` (the raw type, requires `iroh` as a direct dep).

### Added — ADR-062 Phase 2 follow-up: narrow trait ([peat-mesh#167](https://github.com/defenseunicorns/peat-mesh/issues/167))

- **`peat_mesh::network::QuicMeshConnection`** — narrow trait abstracting the QUIC-stream + identity surface that peat-protocol's `formation_handshake` consumes from a mesh connection. Four methods: `open_bi`, `accept_bi`, `close_reason`, `remote_endpoint_id`. Implemented for `iroh::endpoint::Connection`. Disambiguates from the pre-existing `crate::transport::MeshConnection` trait (which operates on `NodeId`-tagged lifecycle predicates rather than QUIC streams) — the `Quic` prefix marks the stream-oriented half.
- **`peat_mesh::network::{SendStream, RecvStream, ConnectionError}`** — re-exports of the iroh types the `QuicMeshConnection` trait references in its method signatures. Lets downstream consumers name them via peat-mesh paths.

### Removed

- **`pub use iroh::endpoint::Connection` from `peat_mesh::network`** — replaced by the `QuicMeshConnection` trait export. peat-protocol's `formation_handshake.rs` (the sole prior consumer) migrates to `&dyn QuicMeshConnection` instead of the raw iroh `Connection` handle. A future iroh `Connection` method addition (datagrams, paths, etc.) no longer widens peat-protocol's reachable API surface by default — the trait names exactly the four methods the handshake legitimately uses; adding more requires amending the trait. **Closes the ADR-062-Phase-2-follow-up gap the QA reviewer of peat-mesh#166 flagged: "transport-agnosticism enforced only at the import path, not the API shape."**

### Compile-only pin + runtime dispatch test

`network::quic_mesh_connection::tests::_connection_impls_quic_mesh_connection` is a `#[allow(dead_code)]` fn that takes a `Connection` and binds it as `&dyn QuicMeshConnection`. A future iroh release that removes / renames any of the four delegated methods fails to compile here at peat-mesh CI rather than at a downstream peat-protocol build site. Same shape as the `reexport_pin` typecheck for the type re-exports.

`network::quic_mesh_connection::tests::dyn_dispatch_resolves_via_async_trait` is a runtime test that exercises every one of the four trait methods through a `&dyn QuicMeshConnection` cast against a `MockConn` impl with synthetic return values. Catches `async_trait` codegen regressions (object-safety lapses, lifetime drift on the returned `Pin<Box<dyn Future + Send>>`, async-fn-in-trait dispatch resolution) at runtime. Real-iroh end-to-end coverage lands cleanly via peat-protocol's `formation_handshake` e2e tests once the consumer-side migration ships ([peat#932](https://github.com/defenseunicorns/peat/issues/932), peat 0.9.0-rc.16).

## [0.9.0-rc.23] - 2026-05-26

**`parse_close_reason` structured-variant refactor** — closes [peat-mesh#164](https://github.com/defenseunicorns/peat-mesh/issues/164). Replaces substring-on-`Display` matching in `transport::iroh_mesh::IrohMeshConnection::parse_close_reason` with an exhaustive structured match on `iroh::endpoint::ConnectionError`'s 8 variants. No transport semantics, peer discovery, or wire-shape change. Documents the stable per-variant payload-tag contract on `DisconnectReason::NetworkError` / `DisconnectReason::ApplicationError`'s public enum surface in `src/transport/mod.rs`.

### Changed — `parse_close_reason` structured-variant match ([peat-mesh#164](https://github.com/defenseunicorns/peat-mesh/issues/164))

- **`transport::iroh_mesh::IrohMeshConnection::parse_close_reason`** now pattern-matches on `iroh::endpoint::ConnectionError` variants directly instead of substring-matching the `Display` text. Routing key moves from "does the formatted string contain 'timeout'/'reset'/'closed'/'application'?" to "which structured enum variant?". Three regression classes the legacy substring code was vulnerable to are now closed:
  - **`thiserror` wording tweaks** in upstream iroh / noq-proto would have silently shifted every disconnect into the `NetworkError(_)` fallback arm. Structured match is wording-independent.
  - **`LocallyClosed` ↔ `RemoteClosed` confusion** — the legacy code's `contains("closed")` collapsed both into `RemoteClosed`. The new match disambiguates: `LocallyClosed` → `DisconnectReason::LocalClosed`, `Reset` and `ConnectionClosed(_)` → `DisconnectReason::RemoteClosed`.
  - **New iroh variant addition** — the exhaustive match in `map_connection_error` refuses to compile if a `ConnectionError` variant is added upstream, surfacing the change at peat-mesh CI rather than after a downstream binary swallows it as `NetworkError`. `noq-proto`'s `ConnectionError` is not marked `#[non_exhaustive]` as of 1.0.0-rc.0, so exhaustive match is structurally enforceable.
- **`VersionMismatch` and `CidsExhausted`** now carry stable per-variant tags (`"version-mismatch"`, `"cids-exhausted"`) in the `NetworkError` payload instead of the raw `Display` text. Audit-log parsers downstream stay stable across iroh wording changes.
- **`ApplicationClosed(_)`** payload format: `"code={error_code} reason={utf8-lossy(reason)}"` — gives operators a structured handle on the close-frame contents instead of an opaque `Display`.
- **New unit test** `transport::iroh_mesh::tests::parse_close_reason_per_variant_pin` covers the 5 unit-shape variants. The 3 data-bearing variants (`ConnectionClosed`, `ApplicationClosed`, `TransportError`) can't be constructed from outside `noq-proto` (private/non-exhaustive struct internals), but the **exhaustive `match` in `map_connection_error` itself** is the load-bearing regression guard for new variants.
- Carryover from peat-protocol's pre-Phase-2 `IrohMeshTransport`; reviewer of [peat-mesh#162](https://github.com/defenseunicorns/peat-mesh/pull/162) flagged this as a follow-up at the time and explicitly framed it as out-of-scope for that PR ([CHANGELOG entry under 0.9.0-rc.21 / 0.9.0-rc.22 "Deferred (tracked separately)"](#0.9.0-rc.22)).

#### ⚠ Consumer-visible behavior delta

Code that **branches on `DisconnectReason::RemoteClosed`** now sees fewer hits and **`DisconnectReason::LocalClosed`** sees more, because the legacy `contains("closed")` substring match collapsed `iroh::endpoint::ConnectionError::LocallyClosed` into `RemoteClosed`. Consumers that need to distinguish "we hung up" from "they hung up" — operational dashboards, reconnect-policy gates that suppress on local intentional close — should match `LocalClosed` separately now; consumers that previously branched only on `RemoteClosed` will see those locally-initiated closes route to a different arm. Same enum surface, different routing.

Stable payload tags on `DisconnectReason::NetworkError` and `DisconnectReason::ApplicationError` are now documented on the enum itself in `src/transport/mod.rs` — the per-variant tag contract (`"version-mismatch"`, `"cids-exhausted"`, `"transport-error: {inner}"`, `"code={n} reason={utf8-lossy(bytes)}"`) is part of the public surface, not an implementation detail of `iroh_mesh`. A `format_application_close_payload_pin` test pins the `ApplicationError` format directly via an extracted `format_application_close(error_code, reason)` helper.

## [0.9.0-rc.22] - 2026-05-25

**ADR-062 Phase 2 follow-up** — adds two re-exports rc.21's Phase 2 surface accidentally missed: `peat_mesh::network::Connection` (= `iroh::endpoint::Connection`) consumed by `peat-protocol`'s formation-handshake, and `peat_mesh::network::DiscoveryEvent` (= `iroh_mdns_address_lookup::DiscoveryEvent`) consumed by peat-protocol's mDNS event handler. Unblocks the peat consumer-side PR ([peat#926](https://github.com/defenseunicorns/peat/issues/926)) to simultaneously satisfy the SKILL.md transport-agnosticism gate AND drop `iroh` + `iroh-mdns-address-lookup` from peat-protocol's direct deps.

No transport semantics or wire-shape change. Pure additive re-export.

### Added — ADR-062 Phase 2 follow-up re-exports ([defenseunicorns/peat#926](https://github.com/defenseunicorns/peat/issues/926))

- **`peat_mesh::network::Connection`** — re-export of `iroh::endpoint::Connection`. Consumed by `peat-protocol/src/network/formation_handshake.rs`, whose protocol-level handshake (Ed25519 challenge, formation-key verification) runs against the raw `iroh::Connection` returned by `IrohTransport::connect_*` per ADR-062 §"What peat-protocol still owns". The original rc.21 surface only re-exported `EndpointId`; this follow-up adds the `Connection` type the formation-handshake consumer requires.
- **`peat_mesh::network::DiscoveryEvent`** — re-export of `iroh_mdns_address_lookup::DiscoveryEvent`. Consumed by `peat-protocol/src/sync/automerge.rs`'s mDNS event handler. Adding it lets the peat consumer-side PR drop `iroh-mdns-address-lookup` from `peat-protocol`'s direct deps (ADR-062 acceptance gate #2).

These two re-exports were accidentally missed from rc.21's Phase 2 surface. Without them, the peat consumer-side PR ([peat#926](https://github.com/defenseunicorns/peat/issues/926)) can't simultaneously satisfy the SKILL.md transport-agnosticism gate (no `use iroh::` lines outside the re-export shim) AND drop `iroh` + `iroh-mdns-address-lookup` from `peat-protocol`'s direct deps. Either we keep them as direct deps (violates ADR-062) or we ship a follow-up surface release (this one).

No transport semantics or wire-shape change. Pure additive re-export.

A `network::reexport_pin::_typecheck` compile-only test pins the upstream paths, so a future `iroh` / `iroh-mdns-address-lookup` rename surfaces at peat-mesh's CI rather than at downstream peat-protocol's build.

### Deferred (tracked separately)

- [peat-mesh#167](https://github.com/defenseunicorns/peat-mesh/issues/167) — narrow the `Connection` re-export to a peat-mesh-owned trait surface. Re-exporting the full `iroh::endpoint::Connection` exposes every Connection method to downstream consumers; the transport-agnosticism gate is currently enforced only at the import path, not the API shape. Reviewer of #166 explicitly framed as out-of-scope for this follow-up; tracking the design conversation in #167.

## [0.9.0-rc.21] - 2026-05-25

**ADR-062 Phase 2** — moves `IrohMeshTransport` from peat-protocol into `peat_mesh::transport::iroh_mesh` and re-exports `iroh::EndpointId` from `peat_mesh::network`. peat-mesh is now the canonical home for both the iroh `Endpoint` wrapper (`IrohTransport`, moved in rc.18) and the iroh-backed `MeshTransport` trait impl (this release). The peat consumer-side PR ([defenseunicorns/peat#926](https://github.com/defenseunicorns/peat/issues/926)) deletes `peat-protocol/src/transport/iroh.rs` and drops `iroh`, `iroh-blobs`, `iroh-mdns-address-lookup` from `peat-protocol`'s direct dep section in `peat 0.9.0-rc.15`. No transport semantics or wire-shape changes — this release is the structural relocation that closes the SKILL.md transport-agnosticism gate (no `use iroh::` lines in peat-protocol/src outside the re-export shim).

### Validation

- 1658 lib tests pass (1648 pre-existing + 10 new in `transport::iroh_mesh::tests`).
- Migrated `tests/dual_active_simultaneous.rs` (3 tests) passes against the new canonical surface.
- `cargo clippy --features automerge-backend --all-targets -- -D warnings` clean.
- Bidirectional surface-tier proof: `test_two_iroh_mesh_transports_real_connect` exercises the round-trip nature of connect ↔ `subscribe_peer_events` against two real `IrohTransport::bind("127.0.0.1:0")` endpoints — covers the most likely class of transport-tier regression (mapping inversion, accept-loop ordering, ALPN mismatch).
- **End-to-end validated on rpi-ci + rpi-ci2 + laptop** (192.168.228.0/24): all four QUICKSTART scenarios converge cleanly against this release's surface, transitive gossip working across heterogeneous hardware (x86_64 laptop + aarch64 Pi 5s).

### Added — ADR-062 Phase 2 ([defenseunicorns/peat#926](https://github.com/defenseunicorns/peat/issues/926))

- **`peat_mesh::transport::iroh_mesh::IrohMeshTransport`** — iroh-backed [`MeshTransport`] implementation. Wraps `peat_mesh::network::iroh_transport::IrohTransport` and provides the trait interface that `TopologyManager`, `TransportManager`, and PACE routing consume. Replaces `peat_protocol::transport::iroh::IrohMeshTransport`, which the peat consumer-side PR ([peat#926](https://github.com/defenseunicorns/peat/issues/926)) deletes in `peat 0.9.0-rc.15`.
- **`peat_mesh::transport::iroh_mesh::IrohMeshConnection`** — companion [`MeshConnection`] impl wrapping `iroh::endpoint::Connection`. Liveness detection via QUIC `close_reason()` carries over byte-for-byte from peat-protocol.
- **`peat_mesh::network::EndpointId`** — re-export of `iroh::EndpointId`. Lets peat-protocol's post-Phase-2 code reference `EndpointId` via a peat-mesh path, satisfying `peat/SKILL.md`'s transport-agnosticism gate (no `use iroh::` lines in peat-protocol/src outside the re-export shim).
- **`tests/dual_active_simultaneous.rs`** — migrated from peat-protocol. Preserves the dual-simul-connect invariant: real Iroh QUIC + mock BLE coexist in the same `TransportManager` with correct routing and PACE fallback when Iroh stops.

### Notable API delta vs. peat-protocol's `IrohMeshTransport`

peat-protocol's constructor took `Arc<RwLock<PeerConfig>>`. peat-mesh's takes `Vec<PeerInfo>` directly. The `formation` and `local` fields of `PeerConfig` were never used inside the transport — only `config.peers: Vec<PeerInfo>` was consulted, for address resolution and reconnection. Decoupling from `PeerConfig` keeps the formation-key model in peat-protocol's security layer where it belongs (per ADR-062 §"What peat-protocol still owns"). A new `set_static_peers(&self, peers: Vec<PeerInfo>)` method covers the runtime-update path that `Arc<RwLock<PeerConfig>>` previously enabled.

`set_static_peers` registers every well-formed entry **inline** into the `NodeId ↔ EndpointId` map and the static-peer set, so post-call `connect()` for any new peer succeeds without a separate `register_peer` call. Malformed entries (invalid hex `node_id`) are skipped with a `warn!` — same surfacing as `start()`. Operationally additive with respect to the registration maps: entries that fall out of the new list are not removed, matching the peat-protocol `Arc<RwLock<PeerConfig>>` semantic.

### Other QA-driven refinements

- `start()` and `set_static_peers()` `warn!` on `PeerInfo` entries whose `node_id` hex is malformed, instead of silently dropping them. Operators get a diagnostic line on the most common static-config typo.
- Lock acquisitions in `iroh_mesh.rs` use `unwrap_or_else(|e| e.into_inner())` for poison recovery, matching the rest of peat-mesh (e.g., `transport::health`). The long-running cleanup/reconnection task no longer turns a poisoned read into a task-killing panic.
- Bidirectional surface-tier integration test in `transport::iroh_mesh::tests::test_two_iroh_mesh_transports_real_connect` — two real `IrohMeshTransport` instances establish a QUIC connection with verifiable evidence on both sides (`PeerEvent::Connected` on the dialer's mesh-tier subscription, `peer_count() > 0` on the receiver's underlying `IrohTransport`).

### Deferred (tracked separately)

- [peat-mesh#164](https://github.com/defenseunicorns/peat-mesh/issues/164) — `parse_close_reason` should pattern-match on structured `iroh::endpoint::ConnectionError` variants instead of substring-matching the `Display` text. Carryover behavior from peat-protocol; reviewer explicitly framed as out-of-scope for the Phase 2 move.

## [0.9.0-rc.20] - 2026-05-23

**iroh ecosystem bump from 0.98 → 1.0.0-rc.0**, addressing the Android SIGABRT observed under iroh 0.98 in peat-ffi's Android Surface Test (defenseunicorns/peat#923). Active QUIC crypto provider remains aws-lc-rs (FIPS-approved); the bump is API-migration work plus the swarm-discovery + bionic-libc compatibility improvements that landed between iroh 0.98 and 1.0.

### ⚠ Breaking — public re-export surface change

`peat_mesh::storage::iroh_types` previously re-exported `PathInfo` and `PathWatcher` from iroh. iroh 1.0.0-rc.0 retired both types in favor of a borrowed `Path<'a>` + owned `PathStats` split, with `Connection::paths_stream()` returning a `PathListStream` (a `Stream<Item = PathList<'_>>`) where the prior `PathWatcher::updated().await` polling shape used to live. The re-exports now expose `Path`, `PathEvent`, `PathEventStream`, `PathList`, `PathListStream`, `PathStats` (in addition to `Connection`, `EndpointHooks`, `PathId` which were already there).

Downstream consumers (peat-protocol, peat-ffi, peat-mesh-node integrators) that imported `peat_mesh::storage::iroh_types::PathInfo` or `PathWatcher` directly must migrate:

- `PathInfo` → `Path<'a>` for borrowed access **or** `peat_mesh::storage::mesh_sync_transport::PathSnapshot` (a new owned struct introduced in this release; see [Added](#added) below) when the snapshot needs to outlive the iteration.
- `PathWatcher::updated()` → `Connection::paths_stream()` consumed via `Stream::next()` (from `tokio_stream::Stream` or any equivalent `StreamExt`). The stream ends on connection close, replacing the prior `Result`-based loop termination.

### ⚠ Semantics change — `MeshSyncTransport::peer_rtt`

`Path::rtt()` returns `Duration` directly in iroh 1.0.0-rc.0 (was `Option<Duration>` on the retired `PathInfo`). `MeshSyncTransport::peer_rtt`'s outer `Option` now means strictly "no selected path" — pre-rc.20 it could also mean "RTT not yet measured." Callers that branched on `is_some()` to detect "do we have telemetry yet?" should switch to comparing the inner `Duration` against zero / a small threshold. The new docstring on `peer_rtt` calls this out explicitly.

### Added

- **`peat_mesh::storage::mesh_sync_transport::PathSnapshot`** — owned per-path struct (`id`, `is_selected`, `is_ip`, `is_relay`, `rtt`, `stats`) returned by `MeshSyncTransport::peer_paths`. iroh 1.0.0-rc.0's `Path<'a>` borrows from a `PathList<'_>` snapshot, which would force callers to thread the snapshot's lifetime through their own code. `PathSnapshot` captures the borrowed predicates + `PathStats` at the moment of `peer_paths()` so callers can hold a `Vec<PathSnapshot>` indefinitely.
- **`iroh-mdns-address-lookup = "=0.2.0"`** as a new direct dep. iroh 1.0.0-rc.0 extracted the mDNS support from `iroh = { features = ["address-lookup-mdns"] }` into a separate crate. The type name (`MdnsAddressLookup`) and behavior are unchanged; only the import path moved.
- **`test_path_snapshot_id_and_stats_pin`** — structural pin on `PathSnapshot.id` (`PathId: Copy + Eq + Hash`) and `PathSnapshot.stats` (`PathStats::udp_tx` / `udp_rx` field shape from noq-proto). Catches future iroh-side refactors that change `Path::id()` / `Path::stats()` return types.

### Changed

- **`iroh = "=1.0.0-rc.0"`** (was 0.98). Exact-pinned during the iroh 1.0 rc-cycle.
- **`iroh-blobs = "=0.101.0"`** (was 0.100). Same.
- **`iroh-mdns-address-lookup = "=0.2.0"`** exact-pinned alongside iroh (extracted in the 1.0 cut, pre-1.0 SemVer applies to it).
- **Removed `ed25519 = "=3.0.0-rc.4"` + `pkcs8 = "=0.11.0-rc.11"`** pre-release pins. iroh 1.0.0-rc.0's `ed25519-dalek 3.0.0-pre.7` requires stable `ed25519 ^3` which the resolver picks cleanly without our intervention.
- **`iroh::endpoint::ConnectionInfo` → `iroh::endpoint::Connection`** (rename in iroh 1.0). Affects `IrohBlobStore`'s `EndpointHooks::after_handshake` impl (formation-member check is byte-for-byte preserved).
- **`MeshSyncTransport::start_sync_connection`** — path-watcher loop rewired from `PathWatcher::updated().await` to `Connection::paths_stream()` + `Stream::next()`. The stream ends naturally on connection close, replacing the prior `Result`-based loop termination.

### CI

No CI config changes vs. rc.19. The same `--ignore RUSTSEC-2026-0118 / -0119 / -0120` advisory-set carries forward; iroh 1.0.0-rc.0's hickory transitive may resolve some of these on a future patch — revisit then.

## [0.9.0-rc.19] - 2026-05-23

**FIPS posture: active iroh QUIC crypto provider switched from `ring` to `aws-lc-rs`.** Implements the substantive half of [defenseunicorns/peat#923](https://github.com/defenseunicorns/peat/issues/923) and the CLAUDE.md hard rule on FIPS-approved cryptographic primitives. The TLS provider that every iroh `Endpoint::builder()` in peat-mesh invokes is now `aws_lc_rs::default_provider()`. Production TLS handshakes and key derivations route through aws-lc-rs (FIPS 140-3 validated), not ring (not FIPS-validated).

### Operator verification

After upgrading, operators can confirm the change is active with `cargo tree -i aws-lc-rs` (should show aws-lc-rs linked via `noq-proto`, `iroh`, and `rustls`) and the new `aws_lc_rs_provider_is_linked` structural test (fails to compile if peat-mesh's `aws-lc-rs` rustls feature is ever dropped). No behavior change at the protocol layer: ALPN bytes (`b"cap/automerge/1"`), HKDF-SHA-256 KDF (rc.18), QUIC tactical config, and connection-recycle env handling are all unchanged. The 4-scenario QUICKSTART regression on rpi-ci + rpi-ci2 + laptop will gate the peat consumer side before the peat workspace floor moves to rc.19.

### ⚠ What this release does NOT close

`ring v0.17.x` is still **linked in the binary** via four transitive default-feature activations the workspace cannot reach: `rcgen`, `rustls-webpki`, `noq-proto`, and rustls itself. Cargo's feature resolution is additive — a workspace override can ADD features but cannot SUBTRACT features another consumer in the dep graph activated. Verified experimentally; documented under [peat#923](https://github.com/defenseunicorns/peat/issues/923) with upstream-PR plan against rcgen / rustls-webpki / noq-proto. The active QUIC provider is aws-lc-rs; ring remains as dead code in the linked binary. peat#923 stays open to track the upstream remediation.

### Added

- **`iroh = "0.98"` + `iroh-blobs = "0.100"`** ([defenseunicorns/peat#923](https://github.com/defenseunicorns/peat/issues/923)). iroh 0.98 added the pluggable `Endpoint::builder().crypto_provider(...)` API ([n0-computer/iroh#3978](https://github.com/n0-computer/iroh/issues/3978)) that this release uses to wire `aws-lc-rs` explicitly. Workspace floor was 0.97 → 0.99 in rc.18; this release advances both.
- **`peat_mesh::security::tls_provider`** — single canonical helper `iroh_quic_provider() -> Arc<rustls::crypto::CryptoProvider>` returning the FIPS-approved aws-lc-rs provider. Every iroh endpoint construction site in peat-mesh (3 production paths + 9 test helpers) routes through it. Future contributors who add a new iroh endpoint anywhere in peat-mesh inherit the approved provider by construction, not by reviewer vigilance. Module-doc holds the full FIPS rationale in one place; call sites point readers there with a one-line comment.
- **Structural FIPS pin** (`aws_lc_rs_provider_is_linked` in `storage::iroh_blob_store::tests`). Calls through `tls_provider::iroh_quic_provider()` — fails to compile if peat-mesh's `aws-lc-rs` feature is ever dropped or flipped back to `ring`. Loud build failure instead of silent FIPS regression.

### Changed

- **iroh default features:** `default-features = false` with explicit `["address-lookup-mdns", "metrics", "fast-apple-datapath", "portmapper", "tls-aws-lc-rs"]`. Replaces the default `tls-ring`.
- **peat-mesh's direct `rustls` dep:** features flipped from `["ring", "logging", "std", "tls12"]` to `["aws-lc-rs", "logging", "std", "tls12"]`. The kube-rs Kubernetes discovery path and iroh QUIC now share the same provider.
- **`Endpoint::empty_builder()` → `Endpoint::builder(presets::Empty).crypto_provider(...)`** at every endpoint construction site (production + test). iroh 0.98 retired `empty_builder()`.
- **`SecretKey::generate(&mut rng)` → `SecretKey::generate()`** at all 29 call sites. iroh 0.98 dropped the caller-side RNG parameter; uses an internal CSPRNG now.

### CI

- **`.github/workflows/ci.yml`: `--ignore RUSTSEC-2026-0120`** added alongside the existing 0118/0119 ignores. RUSTSEC-2026-0120 (hickory-net NSEC3 closest-encloser proof validation unbounded loop) is fixed in hickory-net 0.26.1, but iroh 0.98.x exact-pins `hickory-resolver =0.26.0-beta.4` which transitively pins hickory-net to the vulnerable version — workspace overrides cannot resolve the exact-equal constraint. Exploit surface is DNSSEC cross-zone validation, which peat-mesh's mDNS-driven local discovery does not exercise. Drop this ignore when peat-mesh advances to an iroh release with the upstream fix (iroh 0.98.3 or 1.0 stable).
- Inline `#` comments on both `cargo audit` invocations now distinguish the advisory-output run (`|| true`) from the JSON-gated run that actually fails the job.

## [0.9.0-rc.18] - 2026-05-23

**Wire-breaking release.** Implements [ADR-062](https://github.com/defenseunicorns/peat/blob/main/docs/adr/062-iroh-transport-consolidation.md) Phase 1 / [defenseunicorns/peat#918](https://github.com/defenseunicorns/peat/issues/918): the canonical Iroh QUIC transport for the peat ecosystem moves from `peat-protocol::network::iroh_transport` into `peat_mesh::network::iroh_transport`. peat-protocol will re-export from peat-mesh in its rc.14 release so existing consumers compile unchanged across the rc.13 → rc.14 boundary.

### ⚠ Wire-visible NodeId break

The deterministic-seed `IrohTransport::from_seed(seed)` / `from_seed_at_addr(seed, addr)` constructors now derive the iroh `SecretKey` via **HKDF-SHA-256** with v2 salt/info (`peat-iroh-v2-salt` / `peat-iroh-key-v2:`), replacing the rc.13-era `SHA-256("peat-iroh-key-v1:" || seed)` prefix construction.

This is the FIPS posture correction mandated by CLAUDE.md §"FIPS-approved cryptographic primitives only" — the legacy construction was a one-pass digest, not an approved KDF (SP 800-56C / SP 800-108). The change is correctness-improving but interop-breaking for deployments with pinned NodeIds: **every node running a deterministic-seed configuration gets a new NodeId at the rc.18 boundary**, and static peer configs referencing rc.13/rc.17 NodeIds will fail to dial until regenerated. mDNS-only deployments self-heal on the next discovery cycle.

Operators upgrading from rc.17:

1. Stop all peat-mesh nodes that use deterministic-seed configurations.
2. Upgrade all nodes to rc.18 simultaneously — there is no v1/v2 KDF coexistence window.
3. Regenerate any static peer-config TOML that names NodeIds: the NodeId for `--name alpha` (or any other seed) under rc.18 is **not** the rc.17 NodeId. Use `IrohTransport::endpoint_id_from_seed(seed)` to recompute, or just read the new NodeId out of the node's startup log (`node 'alpha' ready (id=…)`).
4. Random-NodeId deployments (`IrohTransport::new` / `new_at_addr`) are unaffected — those don't go through the KDF.

The change is structurally pinned by a unit test (`derive_iroh_secret_bytes_differs_from_legacy_sha256`) that fails loudly if anyone reverts the KDF. ALPN bytes (`b"cap/automerge/1"`), QUIC tactical config (5s idle / 1s keep-alive), and the connection-recycle env handling (`PEAT_CONNECTION_RECYCLE_SECS`) are all untouched.

### Added

- **`peat_mesh::network::iroh_transport` module** ([defenseunicorns/peat#918](https://github.com/defenseunicorns/peat/issues/918), ADR-062). The 2200-line transport moved verbatim from peat-protocol — same `IrohTransport` struct, same constructors (`new`, `new_at_addr`, `from_seed`, `from_seed_at_addr`, `from_seed_with_discovery`, `with_discovery`, `from_endpoint`), same accept-loop conflict resolution (lower-NodeId-wins, four close codes 100/101/102/103, stable_id-scoped monitor cleanup), same connection recycler (Issue #244 stale-peer cleanup, Issue #275 event channel, Issue #315 fast disconnect detection, peat#892 recycler env handling, peat#890 interface filter integration). 30 tests land in the new modules (27 in `iroh_transport.rs` + 3 in `peer_info.rs`), of which six are new pins added during the peat-mesh#156 QA cycles: the FIPS KDF gate (`derive_iroh_secret_bytes_differs_from_legacy_sha256` + `_is_deterministic` + `_differs_per_seed`), `disconnect_unknown_peer_is_idempotent`, `mark_accept_loop_managed_is_exclusive`, `emit_event_prunes_dropped_subscribers`.
- **`peat_mesh::network::peer_info::PeerInfo`** ([defenseunicorns/peat#918](https://github.com/defenseunicorns/peat/issues/918)). Minimal dial-shape struct (name + hex `node_id` + `addresses` + optional `relay_url`) with `endpoint_id()` / `socket_addrs()` parse helpers. Same fields and derives as peat-protocol's pre-#918 `peer_config::PeerInfo`. Three error-path unit tests pin the user-facing error-message quality (non-hex node_id, wrong-length node_id, invalid SocketAddr).
- **`peat_mesh::network::*` convenience re-exports** for `IrohTransport`, `TransportPeerEvent`, `TransportEventSender`, `TransportEventReceiver`, `CAP_AUTOMERGE_ALPN`, `PeerInfo`. Consumers can write `peat_mesh::network::IrohTransport` instead of the longer module path.

### Changed

- **Dropped-event-subscriber pruning** (peat-mesh#156 QA finding on c5029de carrying over to d08e2a7). `emit_event` and the spawned connection-monitor task's mirror copy previously iterated `event_senders` with a read lock + `let _ = tx.try_send(...)` that dropped events on `Closed` channels but never removed those entries from the list — over a long-running process with ephemeral subscribers (diagnostics taps, heartbeats), the list would grow monotonically. Both call sites now hold a write lock and `retain()` on `Ok` / `Full` (slow-but-alive), pruning only on `Closed`. Behavior change is bounded-memory only; emit semantics are unchanged.

### Validation

Cross-LAN validated end-to-end against rpi-ci (aarch64) + rpi-ci2 (aarch64) + laptop (x86_64) on 2026-05-23 before this release was cut. All four QUICKSTART scenarios pass under the new HKDF-v2 KDF: 2-node static (S1), 3-node static with transitive gossip (S2), 3-node mDNS (S3), 3-node cross-LAN static (S4). NodeIds confirmed distinct from the rc.17 keyspace.

## [0.9.0-rc.17] - 2026-05-22

Feature release adding the **iroh interface-advertisement filter** that backs the [defenseunicorns/peat#890](https://github.com/defenseunicorns/peat/issues/890) Scenario 4 fix in the consumer crate (peat-protocol). No wire-format change; no protocol surface change. Combines [peat-mesh#152](https://github.com/defenseunicorns/peat-mesh/pull/152) (feature) with [peat-mesh#154](https://github.com/defenseunicorns/peat-mesh/pull/154) (test-quality cleanup: extracted testable `select_advertise_interfaces_with` variant, synthetic-fixture surface tests, `if_addrs` error-path coverage).

### Added

- **`peat_mesh::storage::interface_filter` module** ([defenseunicorns/peat#890](https://github.com/defenseunicorns/peat/issues/890)). When a caller binds the iroh endpoint to an unspecified address (`0.0.0.0` / `[::]`), iroh's default behavior enumerates every local interface and publishes the full set to peers as candidate dial targets — including docker bridges, tailscale CGNAT, and stale leases. Peers race the candidates and dial the unreachable ones, producing `noq_udp: sendmsg EIO/EINVAL` warnings (cosmetic, but loud) and burning path-probe budget. The new module enumerates interfaces via `if_addrs`, drops loopback unconditionally, drops link-local (`169.254.0.0/16` v4, `fe80::/10` v6), drops tailscale CGNAT (`100.64.0.0/10`) by IP range *and* the `tailscale*` interface-name pattern, and drops docker / podman / CNI bridges by name pattern. Docker user-defined networks specifically match `br-<12-hex>` (docker's actual convention — e.g. `br-5ca0dfe21ee8`) rather than the broader `br-*`, so legitimate Linux bridges named `br-corp` / `br-lan` / `br-vlan10` aren't false-positives. Matching by name rather than by IP range protects operators whose actual LAN happens to sit in the RFC 1918 `172.16.0.0/12` range. Two env-var overrides: `PEAT_ADVERTISE_ALL_INTERFACES=1` bypasses defaults (loopback still always dropped); `PEAT_ADVERTISE_INTERFACES=eno1,eth0` restricts to an explicit allowlist of interface names. The CIDR prefix from each interface is threaded through to `BindOpts::set_prefix_len` so iroh routes outgoing flows by longest-prefix match rather than rejecting the second bind with "Only a single default address can be set per IP family."
- **`IrohBlobStore::build_endpoint_with_hooks` wires the filter automatically.** When `IrohConfig::bind_addr` is an unspecified address, the builder now calls `interface_filter::select_advertise_interfaces`, replaces iroh's pre-configured `0.0.0.0` / `[::]` defaults via `clear_ip_transports()`, and binds to each survivor with `bind_addr_with_opts` carrying its real CIDR prefix. Specific bind addresses still take the existing single-`bind_addr` path. If the filter produces zero survivors (e.g. an exotic environment where every interface looks suspicious), the builder falls back to the original unspecified bind and surfaces a `WARN` instead of failing.

### Added (dev-dependencies)

- **`serial_test = "3.1"`** to serialize process-wide env-var mutations in the `interface_filter` surface tests. Already in the wider peat workspace under the same version pin.

## [0.9.0-rc.16] - 2026-05-22

Feature release enabling **transitive sync via gossip** end-to-end. Hub-and-spoke topologies (e.g. one alpha + two spokes with `--peer` pointed only at alpha) now converge: a doc received from peer X is re-pushed to every other connected peer except X, without the ping-pong loop the original Issue #115 fix was guarding against.

Closes [defenseunicorns/peat#891](https://github.com/defenseunicorns/peat/issues/891) (field report) and serves as the upstream half of [defenseunicorns/peat#907](https://github.com/defenseunicorns/peat/issues/907) (architectural follow-up).

### Added

- **Origin-tagged change broadcast.** `AutomergeStore` now exposes a third broadcast channel alongside `change_tx` and `observer_tx`:
  - `subscribe_to_changes_with_origin() -> broadcast::Receiver<DocChange>` — fires on ALL document mutations (local writes and sync-received remote applies), each event carrying a `ChangeOrigin` enum:
    - `ChangeOrigin::Local` — local put on this node.
    - `ChangeOrigin::Remote(String)` — applied from a remote peer; the string is the peer's stable identifier (for Iroh transport, `EndpointId::to_string()`). The equality contract with a consumer's connected-peer identifier is documented at the variant — both sides must use the same stringification (`EndpointId::to_string()` on the Iroh path) for echo suppression to work; mixed-transport meshes adding BLE/Lite/LoRa must coordinate the same encoding.
  - Gossip drivers subscribe here and filter on `origin` — `Local` pushes to all peers, `Remote(peer)` pushes to all peers except `peer`. This is the architectural primitive that makes transitive gossip work without echoing back to the source.
- **`AutomergeStore::put_with_origin(key, doc, origin)`** — explicit-origin put. The sync coordinator's receive-from-peer path now calls this with `ChangeOrigin::Remote(peer_id.to_string())` instead of `put_without_notify`. Legacy callers that don't have a peer id can keep using `put` / `put_without_notify` unchanged.
- **`AutomergeStore::put_with_origin_and_ttl(key, doc, origin, ttl_manager)`** — combined origin-attributed put + TTL registration. Mirrors the existing `put_with_ttl` / `put_without_notify_with_ttl` pair so the put + TTL pairing stays at the API surface. The receive path uses this single entry point, eliminating the key-prefix → collection extraction duplication peat-mesh#151 QA flagged.

### Changed

- **`AutomergeSyncCoordinator::put_received` now takes `peer_id: EndpointId`** and routes through `put_with_origin_and_ttl` / `put_with_origin`. The receive paths (`receive_sync_message`, the state-snapshot apply, both arms of the snapshot get/merge branch) have been updated. Per peat-mesh#151 QA, the TTL branch goes through the combined `put_with_origin_and_ttl` so the key-prefix → collection extraction stays defined exactly once in `AutomergeStore::apply_ttl`.
- **Issue #115 invariant lifted from suppression to attribution.** Pre-fix the receive path called `put_without_notify` and the broadcast was simply not fired — that closed the original ping-pong loop but also killed transitive gossip. Post-fix `change_tx` (the legacy local-only outbound pusher) still does NOT fire on receive (so the existing consumer's contract is unchanged), but `gossip_tx` (the new origin-tagged channel) DOES fire with `Remote(peer_id)`. Echo prevention now lives in the gossip consumer's per-peer filter rather than in broadcast suppression. The original tests asserting "change_tx does not fire on receive" were rewritten to assert the new contract (change_tx silent, observer_tx fires, gossip_tx fires with `Remote(peer_id)`); see `issue_115_receive_path_notify_tests`.
- **Module re-exports.** `peat_mesh::storage::ChangeOrigin` and `peat_mesh::storage::DocChange` are now part of the public API surface (feature-gated behind `automerge-backend`).

### Tests

- `receive_sync_message_change_tx_silent_gossip_tx_attributed` (renamed from `receive_sync_message_does_not_fire_change_tx`) — asserts all three invariants: change_tx silent, observer_tx fires, gossip_tx fires with `Remote(peer_id)`.
- `receive_sync_message_with_ttl_registers_ttl_and_skips_notify` — extended to also assert gossip_tx fires with `Remote(peer_id)` on the TTL-applying receive path.
- `local_put_fires_gossip_tx_with_local_origin` — new positive control: a local put must fire gossip_tx with `ChangeOrigin::Local`, otherwise the gossip pusher would skip a peer that should receive the write.
- `put_without_notify_still_fires_gossip_tx_as_local` — legacy callers that don't supply an origin still get the gossip-channel attribution they were expecting (`Local`), preserving the existing semantic for the legacy `change_tx` consumer.
- `put_with_origin_local_fires_change_tx_and_gossip_tx` (per peat-mesh#151 QA) — direct surface-tier test of `AutomergeStore::put_with_origin` with `ChangeOrigin::Local`. Guards against future drift in the `matches!(origin, ChangeOrigin::Local)` branch that decides whether `change_tx` fires.

### Deprecated

- **`AutomergeStore::put_without_notify_with_ttl`** — the receive path was its only intended caller, and that path now routes through the new `put_with_origin_and_ttl` so the origin attribution makes it onto the gossip broadcast. Callers retained on the legacy entry point would silently break transitive gossip for the docs they store (the gossip pusher would treat them as `ChangeOrigin::Local` and push to every peer including the source, wasting bandwidth per peat-mesh#115). Deprecation note steers migration; the method remains in the public API for backward compatibility through the 0.9.x rc cycle.

### Verification

- `cargo fmt --check` clean
- `cargo clippy --features automerge-backend --all-targets -- -D warnings` clean
- `cargo test --features automerge-backend --lib` — 1593 passed, 0 failed (6 new + 1587 unchanged)

## [0.9.0-rc.15] - 2026-05-21

Bug-fix release closing the receive-side half of [defenseunicorns/peat#897](https://github.com/defenseunicorns/peat/issues/897): mDNS-over-loopback discovery now works end-to-end, so the literal QUICKSTART Scenario 3 form (`--bind 127.0.0.1:PORT --mdns` × N nodes) finally produces a working mesh.

### Fixed

- **mDNS daemon now joins the loopback multicast interface** ([#149](https://github.com/defenseunicorns/peat-mesh/pull/149), [defenseunicorns/peat#897](https://github.com/defenseunicorns/peat/issues/897)). Pre-fix, `mdns_sd 0.11.5::my_ip_interfaces` unconditionally filtered out loopback before any `enable_interface` selection ran, so the daemon never multicast announces on `lo`. Two daemons on the same host advertising on `127.0.0.1` couldn't see each other. Fix: bumped `mdns_sd` 0.11 → 0.19 (which adds explicit `IfKind::LoopbackV4` / `IfKind::LoopbackV6` variants for exactly this case — upstream rustdoc names "OSCQuery" as the motivating use case) and call `daemon.enable_interface(IfKind::LoopbackV4)` + `LoopbackV6` in `MdnsDiscovery::start()`. Errors are logged with `warn!` rather than propagated so a host that legitimately has no loopback (rare) still gets non-loopback discovery.

### Changed

- **`mdns_sd` dependency bumped 0.11 → 0.19** (12 major versions). The API surface peat-mesh consumes (`ServiceDaemon`, `ServiceInfo::new`, `register`/`browse`/`unregister`, `TxtProperties` iteration, `ServiceEvent` variants) remained stable across that window. Two breaking changes did affect us:
  - `ServiceEvent::ServiceResolved` now wraps `Box<ResolvedService>` instead of `ServiceInfo`. `MdnsDiscovery::handle_resolved` and `parse_service_info` adapted to the new type.
  - `ResolvedService::addresses` is `HashSet<ScopedIp>` (was `HashSet<IpAddr>`). `parse_service_info` uses `ScopedIp::to_ip_addr()` to recover the plain `IpAddr` peat-mesh `PeerInfo` expects. **Known limitation:** for IPv6 link-local addresses with scope IDs (`fe80::%iface`), `to_ip_addr()` drops the scope; this is no regression from rc.14 (`mdns_sd 0.11` used `IpAddr` with no scope to begin with) but the upgrade is the first opportunity to preserve scope and the daemon does now expose that information. Tracking as a follow-up; no current consumer exercises link-local IPv6 mDNS.

### Tests

- The previously-aspirational `tests/mdns_advertise_with_addr.rs::two_nodes_mutual_loopback_discovery` integration test that had been `#[ignore]`-as-failing since the consolidation epic began **now passes**. `#[ignore]` is retained as a CI guardrail — GitHub Actions and most containerized CI runners block the multicast joins this needs. Locally and on bare-metal hardware: `cargo test --test mdns_advertise_with_addr -- --ignored` runs the gate.
- `parse_service_info` unit tests tightened to assert the resolved `IpAddr` matches the published IP. Pre-fix the tests asserted only `!peer.addresses.is_empty()` + port; a silent publish→resolve IP rewrite (the exact failure mode peat#897 was about) would have slipped past. Per QA on peat-mesh#149.

### Verification

- `cargo test --lib discovery::mdns`: 40 passed.
- `cargo test --test mdns_advertise_with_addr -- --ignored`: passes on bare metal (rpi-ci).
- `cargo test --test mdns_long_node_id -- --ignored`: still passes.
- `cargo clippy --all-targets -- -D warnings`: clean.
- `cargo fmt --check`: clean.
- rpi-ci literal QUICKSTART Scenario 3 (`--bind 127.0.0.1:39001/39002/39003 --mdns`) — three-node mDNS-over-loopback mesh, all three reach `[peers=2]` + emit `sync:` events.

### Migration

Pure consumer migration — bump the pin:

```toml
peat-mesh = "0.9.0-rc.15"
```

No public API changes. `MdnsDiscovery::new()`, `advertise()`, `advertise_with_addr()`, `start()`, `stop()`, `discovered_peers()`, and the `DiscoveryStrategy` trait are all unchanged.

The cross-repo peat workspace consumes via `peat-mesh = ">=0.9.0-rc.14, <0.9.1"` (bumped in peat#901 phase 1 cleanup) — rc.15 is picked up automatically on `cargo update`.

### Known follow-ups

- **IPv6 link-local scope-ID preservation** through `ScopedIp::to_ip_addr()` — tracked, no caller currently exercises this.
- **CI loopback-multicast probe** — convert the `#[ignore]` gate on `two_nodes_mutual_loopback_discovery` into a runtime skip when multicast is genuinely unavailable, so CI runs it on any runner where multicast works. Suggestion from peat-mesh#149 QA review; not blocking.

## [0.9.0-rc.14] - 2026-05-21

Feature + fix release closing the mDNS-discovery half of peat#897 and unblocking the consolidation epic at [defenseunicorns/peat#898](https://github.com/defenseunicorns/peat/issues/898). Adds `MdnsDiscovery::advertise_with_addr` for callers that need to publish an explicit `SocketAddr` (the `--bind 127.0.0.1:PORT` case), fixes a latent DNS-label-length bug that silently broke `advertise()` for 64-character node ids (the common Iroh `EndpointId` case), and fixes the receive-side `handle_removed` lookup so `PeerLost` actually fires for long-id peers.

### Added

- **`MdnsDiscovery::advertise_with_addr(node_id, addr: SocketAddr, metadata)`** ([#147](https://github.com/defenseunicorns/peat-mesh/pull/147), [defenseunicorns/peat#897](https://github.com/defenseunicorns/peat/issues/897)). New explicit-address publish path that skips `mdns_sd::ServiceInfo::enable_addr_auto`. Use this when the listener is bound to a specific address (loopback, a single explicit interface, etc.) and the caller doesn't want every interface auto-detected. Rejects unspecified addresses (`0.0.0.0` / `::`) at the boundary with a `DiscoveryError::MdnsError` that directs callers at `advertise()` for wildcard binds — publishing a literal unspecified address is unroutable. The existing `advertise(node_id, port, metadata)` is unchanged and remains the right call for `--bind 0.0.0.0:PORT` wildcard binds.

### Fixed

- **DNS-SD instance label now truncates `node_id` to ≤63 octets** ([#147](https://github.com/defenseunicorns/peat-mesh/pull/147)). Previously, `format!("peat-{node_id}")` produced a 69-octet label for a 64-character hex Iroh `EndpointId` — past the RFC 1035 §2.3.4 limit. `mdns_sd::ServiceDaemon::register` silently rejected the over-long registration: no panic, no error return, just no announcements on the wire. The receive side never saw the over-long peer at all. The bug pre-existed `advertise_with_addr`; it only surfaced once the consolidation work in peat#898 made `peat-quickstart` start advertising with the hex `EndpointId` as `node_id`. Shorter callers (e.g. `peat-mesh-node`) had always been under the limit by coincidence. Fix: centralize instance label construction in `MdnsDiscovery::instance_name(node_id)`, which truncates the embedded id at the largest UTF-8 boundary ≤ `63 - "peat-".len() = 58` octets. The full `node_id` always stays in the TXT record so receivers can recover it; truncation only affects on-wire DNS labels.

- **`handle_removed` no longer drops `PeerLost` events for long-id peers** ([#147](https://github.com/defenseunicorns/peat-mesh/pull/147)). `handle_resolved` keyed `discovered` by the **full** `node_id` from the TXT record; `handle_removed` could only recover the **truncated** instance label from the fullname (after the DNS-label fix above). A straight `peers.remove(&label)` missed every entry keyed by the full 64-character id — `PeerLost` was never emitted and stale entries accumulated in `discovered` forever. Fix: look up the stored full id whose truncated instance form matches the label extracted from the fullname; remove by the full id; emit `PeerLost` carrying the full id, matching the authoritative TXT-record contract. Linear scan over `discovered`, bounded by local-segment peer count.

### Tests

- **`tests/mdns_advertise_with_addr.rs`** (integration, `#[ignore]`'d): aspirational two-instance loopback discovery test for the `advertise_with_addr` end-to-end path. Currently fails on bare metal because `mdns_sd::ServiceDaemon` only multicasts the announce on interfaces hosting the explicit IP — for a loopback-only IP, the announce goes out on `lo` only and other daemons on the same host don't subscribe to the loopback multicast group by default. Lives here as living documentation of the target state. Closing peat#897 end-to-end on the same-host loopback case is a separate daemon-config follow-up.
- **`tests/mdns_long_node_id.rs`** (integration, `#[ignore]`'d): two-instance loopback discovery with 64-character `node_id`s through the existing `advertise()` / `enable_addr_auto` path. Failed before the DNS-label truncation fix; passes after.
- **Unit-tier publish-side coverage**: `test_advertise_with_addr_accepts_loopback_v4` and siblings now inspect the constructed `mdns_sd::ServiceInfo` (via a private `build_explicit_addr_service_info` helper) and assert `get_addresses()` contains the supplied IP, `get_port()` equals the supplied port, and `is_addr_auto()` is false. This catches the silent-IP-rewrite class of regression at unit tier without any multicast dependency — the failure mode peat#897 was about.
- **Long-id regression coverage**: `test_build_advertise_parts_truncates_long_node_id` asserts both the instance and hostname labels stay ≤63 octets for a 64-character `node_id`. `test_instance_name_short_node_id_unchanged` locks in the wire-identity invariant for existing short-name callers (`peat-mesh-node`, etc.). `test_handle_removed_long_id_peer` regression-tests the receive-side fix end-to-end (insert by full id → remove by truncated label → assert empty map AND `PeerLost(full_id)` on the channel). `test_instance_name_truncates_on_byte_boundary_for_non_ascii` locks the byte-count (not char-count) truncation invariant against a 30×`ñ` (60-byte / 30-char) input.

### Identity model

After this release, the DNS-SD instance label embeds at most the first 58 octets of `node_id`. For 256-bit Iroh keys (64-char hex), this retains 232 bits of entropy — random prefix collisions are astronomically improbable. The contract has shifted from "full id uniqueness on the wire" to "58-octet prefix uniqueness on the wire". The TXT-record `node_id` property remains the **authoritative** identifier; downstream dedup, routing, and connection management must key off TXT, not the DNS label. `instance_name`, `handle_removed`, and `extract_node_id` rustdocs spell this out for readers.

### Verification

- `cargo test --lib discovery::mdns` — 40 tests pass (3 new regressions + tightened existing).
- `cargo test --test mdns_long_node_id -- --ignored` — passes on bare metal; confirms long `node_id` end-to-end via `advertise()`.
- `cargo clippy --all-targets -- -D warnings` — clean.
- `cargo fmt --check` — clean.

### Migration

Pure consumer migration — bump the pin:

```toml
peat-mesh = "0.9.0-rc.14"
```

Backward-compatible: `advertise(node_id, port, metadata)`, the `DiscoveryStrategy::advertise(node_id, port)` trait method, and all other public surfaces are unchanged. The DNS-label truncation is internal and only affects on-wire DNS labels; existing short-name callers see byte-for-byte identical instance names. The new `advertise_with_addr` method is additive.

The cross-repo peat workspace consumes via `peat-mesh = ">=0.9.0-rc.12, <0.9.1"` — rc.14 is picked up automatically on `cargo update`. The temporary `[patch.crates-io]` block and `[policy.peat-mesh]` / `[[exemptions.peat-mesh]]` entries that [defenseunicorns/peat#900](https://github.com/defenseunicorns/peat/pull/900) carries (for the duration of the mDNS-consolidation phase 1) can be removed in a follow-up cleanup PR once this release is on crates.io.

### Known follow-ups

- **End-to-end closure of peat#897 on the same-host loopback case** needs a daemon-side change to either join the loopback multicast group or accept an additional "advertise on interface" hint alongside the explicit IP in `advertise_with_addr`. The publish-side contract this release adds is correct; the receive-side cross-instance discovery on a loopback-only IP is structurally limited by `mdns_sd::ServiceDaemon`'s current interface-selection behavior. Tracked in peat#897.
- **`DiscoveryStrategy` trait extension** for the explicit-addr variant: downstream `dyn DiscoveryStrategy` consumers currently have to downcast to `MdnsDiscovery` to reach `advertise_with_addr`. Tracked under peat#898 phase 2.
- **IPv6 link-local with scope ID** (`%iface`): no current callers exercise this; the boundary check should grow an `is_unicast_link_local()` arm when one appears.

## [0.9.0-rc.13] - 2026-05-19

Bug-fix release closing [#131](https://github.com/defenseunicorns/peat-mesh/issues/131) — the `automerge_sync` rediscovery loop on an unestablished iroh connection that fed the Android OOM chain ATAK reported on rc.8 (also tracked in [defenseunicorns/peat#873](https://github.com/defenseunicorns/peat/issues/873)). Adds a peer-level circuit-breaker gate at the multi-document fan-out entry, matching the per-doc gate that ec67de9 (rc.10) wired into `initiate_sync`.

### Fixed

- **`sync_all_documents_with_peer` now short-circuits when the circuit breaker is open for the target peer** ([#131](https://github.com/defenseunicorns/peat-mesh/issues/131), [#132](https://github.com/defenseunicorns/peat-mesh/pull/132)). Prior to this fix, the multi-document fan-out at `src/storage/automerge_sync.rs:1638` iterated all N documents unconditionally; each per-doc `initiate_sync` call bailed fast at the existing per-doc gate at line 800, but the outer iteration still logged "Syncing N existing documents" + N "Failed to sync document X" warnings every mDNS rediscovery (~10s on Android against an iroh-unreachable peer). The repeated per-rediscovery work fed the Android OOM chain when paired with the 60s peat-protocol connection recycler. The new peer-level gate at `automerge_sync.rs:1650` returns `Ok(())` immediately when the breaker is open, without scanning the store or iterating documents. One debug log per rediscovery instead of 11+. Per-doc bail still works as before for the breaker-closed transient-failure case.

### Tests

- **`sync_all_documents_with_peer_short_circuits_when_breaker_open`** (new): pre-populates an in-memory store with a sentinel doc, trips the breaker, asserts `Ok` return + `scan_prefix_call_count() == 0` + breaker still open. The scan_prefix counter is the differentiating signal between the peer-gated and doc-gated paths — both return Ok, both leave the breaker open, both leave peer_stats untouched. Counter is `#[cfg(test)]` on `AutomergeStore` so production builds carry zero per-call overhead.
- **`production_call_sites_use_should_block_sync_not_is_circuit_open`** (extended): the existing structural pin now includes `sync_all_documents_with_peer` alongside `initiate_sync` and `sync_documents_batch`. A future refactor that drops the peer-level gate fails this test.

### Verification

- `cargo test --features automerge-backend --lib` — 1573 tests pass (1572 pre-PR + 1 new).
- `cargo fmt --check` clean.
- Both new behavior + structural tests confirm the gate's location independently — the QA review pinned that as the strengthened-test asymmetry it wanted.

### Migration

Pure consumer migration — bump the pin:

```toml
peat-mesh = "0.9.0-rc.13"
```

No public API changes. The cross-repo peat workspace consumes via `peat-mesh = ">=0.9.0-rc.12, <0.9.1"` (peat #871) — rc.13 is picked up automatically on `cargo update`.

### Known follow-ups

- **[defenseunicorns/peat#873](https://github.com/defenseunicorns/peat/issues/873)** — the 60s peat-protocol connection recycler vs ~10s mDNS rediscovery rate mismatch. Should become non-symptomatic with this fix (failure rate drops, allocations no longer outpace recycling). If ATAK still sees Android OOM kills on rc.13, the recycler interval becomes the next intervention.
- **[#130](https://github.com/defenseunicorns/peat-mesh/issues/130)** — iroh-side `swarm_discovery::sender` failure on Android (mDNS announce direction). Orthogonal to this fix; requires Iroh upstream investigation.

## [0.9.0-rc.12] - 2026-05-18

FIPS-posture release. Ships the [peat ADR-060 §5](https://github.com/defenseunicorns/peat/blob/main/docs/adr/060-encryption-tiers-rest-and-transit.md#5-cryptographic-primitives-fips-posture) primitive swap on the existing crypto paths plus the at-rest `Cipher` trait that ADR-060 Phase A specifies. Two substantive changes in one rc:

1. The `EncryptionKeypair` / `EncryptionManager` / `BypassChannelSecurity` paths move from ChaCha20-Poly1305 + X25519 to AES-256-GCM + ECDH-P256, the FIPS 140-3 approved equivalents (peat-mesh [#125](https://github.com/defenseunicorns/peat-mesh/pull/125)).
2. `AutomergeStore` gains an at-rest `Cipher` trait hook — peat-mesh stays crypto-agnostic, concrete primitives live in consumer crates like peat-node (peat-mesh [#128](https://github.com/defenseunicorns/peat-mesh/pull/128), closes [#124](https://github.com/defenseunicorns/peat-mesh/issues/124)).

### Added

- **`Cipher` trait at `src/storage/cipher.rs`.** New `pub trait Cipher: Debug + Send + Sync` with `encrypt(&[u8]) -> Result<Vec<u8>>` and `decrypt(&[u8]) -> Result<Vec<u8>>`. Re-exported from `src/storage/mod.rs`. Implementations live in consumer crates — peat-node provides AES-256-GCM via its `StoreCipher`.
- **`AutomergeStore::open_with_cipher(path, Option<Arc<dyn Cipher>>)`** constructor. Threads the optional cipher into the store; every byte stored in `DOCUMENTS_TABLE` passes through `Cipher::encrypt` on write and `Cipher::decrypt` on read. The LRU cache holds plaintext post-decrypt; `open_in_memory` hard-codes `cipher: None` (cipher is at-rest only by definition). Tombstones in `TOMBSTONES_TABLE` are stored plaintext in this version; metadata encryption is a follow-up.
- **`AutomergeBackendConfig.cipher: Option<Arc<dyn Cipher>>`** field, threaded through to the store.
- **Two new at-rest tests** in `src/storage/automerge_store.rs`: a `MarkerCipher` round-trip exercising the public surface end-to-end and inspecting raw redb bytes to confirm `encrypt` was actually applied, plus a no-cipher sanity test pinning the legacy path.
- **Constants** `ECDH_PUBLIC_KEY_SIZE = 33` and `ECDH_SECRET_KEY_SIZE = 32` in `src/security/encryption.rs` for the new P-256 surface.
- **Module-level "Deployment constraint — ARM crypto extensions" block** in `src/security/encryption.rs` documenting the 5–10× AES software slowdown on ARM cores without AESv8 / Crypto Extensions and the ~3–5× ECDH-P256 vs X25519 session-establishment delta. Targeted rPi-class benchmarks tracked as [#126](https://github.com/defenseunicorns/peat-mesh/issues/126).

### Changed

- **BREAKING — symmetric AEAD swapped from ChaCha20-Poly1305 to AES-256-GCM** (NIST SP 800-38D, FIPS-approved) on every encryption path in `src/security/encryption.rs` (`SymmetricKey`, `SecureChannel`, `GroupKey`, `EncryptionManager`) and in `src/transport/bypass.rs` (`BypassChannelSecurity::encrypt` / `decrypt`). Ciphertext format width is identical (12-byte nonce + 16-byte tag), but the cipher itself is different — peers on the previous primitives cannot interoperate with peers on the new ones. Pre-production swap; no live peers are on the previous primitives.
- **BREAKING — DH key exchange swapped from X25519 to ECDH on NIST P-256** (NIST SP 800-56A, FIPS-approved). `EncryptionKeypair` now wraps `p256::SecretKey` / `PublicKey`; `dh_exchange` returns `p256::ecdh::SharedSecret`. Public-key serialization moves to **compressed SEC1 (33 bytes)** — was 32 bytes (raw X25519 pubkey). Consumer-facing surfaces affected: `EncryptionKeypair::public_key_bytes() -> [u8; 33]`, `EncryptionManager::public_key_bytes() -> [u8; 33]`, `EncryptionManager::establish_channel(&[u8; ECDH_PUBLIC_KEY_SIZE])`.
- **BREAKING — `EncryptionKeypair::from_secret_bytes` is now fallible.** Returns `Result<Self, SecurityError>`; the zero scalar and any scalar `≥ n` (the P-256 curve order) are rejected. X25519 accepted any 32 bytes; P-256 does not.
- **BREAKING — `EncryptionManager::establish_channel` may now return `SecurityError::EncryptionError`** when the peer's 33-byte SEC1 input does not decode to a valid P-256 point. The decode error propagates explicitly through the API rather than silently no-op'ing.
- **BREAKING — at-rest device-key derivation input width.** `EncryptionManager::new` seeds the per-device HKDF from `EncryptionKeypair::public_key_bytes()`, which is now 33 bytes (compressed SEC1 P-256) — was 32 bytes (raw X25519 pubkey). Any data encrypted under the pre-swap derivation will not decrypt. Pre-production; no live at-rest deployments under this path. The new `Cipher` trait at-rest follow-up consumers must account for this.
- **Dependencies:** `chacha20poly1305 = "0.10"` removed, `aes-gcm = "0.10"` added; `x25519-dalek = "2"` removed, `p256 = { version = "0.13", features = ["ecdh"] }` added.
- **ADR-0005 amended** (`docs/adr/0005-e2e-encryption-key-management.md`): status updated to "Proposed (amended 2026-05-18 for FIPS posture per peat ADR-060 §5)" with an amendment block superseding the inline ChaCha20-Poly1305 / X25519 / OpenMLS-ChaCha20-suite references. MLS ciphersuite for the future MLS integration is now `MLS_128_DHKEMP256_AES128GCM_SHA256_P256`; the OpenMLS provider must be a FIPS-mode (e.g., `aws-lc-rs`-backed) provider before MLS ships — the default `ring` backend `openmls_rust_crypto` brings is not FIPS-validated.

### Migration

Library consumers of the affected surfaces:

- `peat-protocol` (in the peat repo workspace) re-exports `EncryptionKeypair` and tests `shared.as_bytes()` at `peat-protocol/src/security/encryption.rs:42-46`. Under ECDH-P256 the accessor is `shared.raw_secret_bytes().as_slice()`. One-line test update; tracked in peat ADR-060 §References → Cross-repo coordination.
- `peat-node` — no consumption of the affected surfaces; no coordination needed for the AEAD/DH swap. For the at-rest cipher hook: peat-node already ships `StoreCipher` (AES-256-GCM, FIPS-approved); a follow-up consumer PR will plumb that into `AutomergeBackendConfig.cipher`.
- `peat-ffi` — no consumption.
- `peat-btle` — independent parallel primitive swap landed in peat-btle [#62](https://github.com/defenseunicorns/peat-btle/pull/62).

Default consumers (`from_endpoint_with_protocols` + `IrohConfig::default()` + no manual `EncryptionKeypair` construction) need only bump the pin:

```toml
peat-mesh = "0.9.0-rc.12"
```

### Tracked follow-ups (not in this release)

- **rPi-class perf evidence** for AES-256-GCM + ECDH-P256 on ARM-without-crypto-extensions targets — [#126](https://github.com/defenseunicorns/peat-mesh/issues/126).
- **FIPS-mode rustls/Iroh provider swap.** Iroh's quinn/rustls defaults to `ring`, which is not FIPS-validated. Switching to `aws-lc-rs` is assigned to peat-mesh as the canonical Iroh consumer; tracked in peat ADR-060 §References → Cross-repo coordination.
- **Tombstone metadata encryption** for `TOMBSTONES_TABLE` (collection names, document IDs, deletion reasons). Plaintext in this release; if the operational threat model requires protection, a Phase-A follow-up.
- **Backend-tier `cipher` wiring test.** The store-level cipher tests are rigorous, but neither updated `AutomergeBackend::with_iroh` test currently passes a non-None cipher. Add one to prevent silent regressions on the trivial wiring.

## [0.9.0-rc.11] - 2026-05-17

Bug-fix release closing [#121](https://github.com/defenseunicorns/peat-mesh/issues/121) — the 30 s hardcoded `fetch_blob` per-attempt timeout that broke attachment delivery on slow links. Replaces the wall-clock cap with a progress-stall watchdog so live slow transfers at any rate now complete; only genuinely stuck peers rotate.

### Fixed

- **Blob-fetch wall-clock timeout no longer kills slow transfers**
  ([#121](https://github.com/defenseunicorns/peat-mesh/issues/121),
  [#122](https://github.com/defenseunicorns/peat-mesh/pull/122)).
  `NetworkedIrohBlobStore::fetch_blob` previously wrapped each peer
  attempt in `tokio::time::timeout(download_timeout, ...)` with a
  hardcoded 30 s default. Any transfer whose natural duration exceeded
  the cap was killed, and no state was preserved across retries —
  effectively capping the viable attachment-delivery envelope at
  `rate × 30 s` (e.g. ~960 KB at 256 kbps). Replaced with a
  progress-stall watchdog driven by the downloader's existing progress
  stream: a fetch is abandoned only after `download_stall_timeout` of
  no meaningful activity (offset-advancing `Progress` or any
  discovery event), so a live slow transfer at any rate completes.

### Changed

- **BREAKING — `IrohConfig` field surface.** Removed `download_timeout`;
  added `connect_timeout` (default 30 s, connection-pool establishment
  semantic) and `download_stall_timeout` (default 30 s, new
  progress-stall semantic). Pre-1.0 break — any caller constructing
  `IrohConfig` directly with the old field name (notably `peat-node`'s
  config plumb-through, if/when added) must rename. The simpler
  default-using constructors (`from_endpoint_with_protocols`,
  `from_config`) are unaffected.
- **BREAKING — `NetworkedIrohBlobStore::from_endpoint_with_protocols_with_timeouts`
  signature.** Adds one parameter — the previous single
  `download_timeout` argument is now split into `connect_timeout` and
  `download_stall_timeout`. Callers using the lower-level constructor
  (rare; most use `from_endpoint_with_protocols` which is unaffected)
  must update accordingly.

### Migration

No source changes for the common consumer path
(`from_endpoint_with_protocols` + `IrohConfig::default()`). Bump
`peat-mesh` pin from `"0.9.0-rc.10"` to `"0.9.0-rc.11"`. Callers
constructing `IrohConfig` directly or using
`from_endpoint_with_protocols_with_timeouts` must rename
`download_timeout` and supply the new parameter respectively — see
the Changed entries above.

## [0.9.0-rc.10] - 2026-05-16

Sync-layer fix for
[defenseunicorns/peat#864](https://github.com/defenseunicorns/peat/issues/864) —
the auto-sync push task silently dropped the second of two back-to-back
local writes to the same `(peer, doc)` when they fired within
`FlowController.sync_cooldown` (default 100 ms). The change existed in
the local store but never reached peers, manifesting end-to-end as
missing terminal frames on `peat-protocol`'s `subscribe_progress`
broadcast for cross-peer file-distribution transfers.

### Changed

- **`AutomergeSyncCoordinator::initiate_sync`** now waits the remaining
  cooldown out (via `tokio::time::sleep`) before doing the
  flow-control check, instead of returning
  `FlowControlError::CooldownActive`. The cooldown's rate-limit intent
  is preserved; the silent-drop is fixed. Bounded retry loop
  (`MAX_COOLDOWN_DEFERRALS = 5`) closes the TOCTOU between
  `remaining_cooldown` and the post-loop check for concurrent callers
  on the same `(peer, doc)`. After the loop, the cooldown branch of
  flow control is bypassed entirely via the new
  `FlowController::check_rate_limit_only` — re-checking it would be
  redundant (the loop just waited it out) and on retry-budget
  exhaustion would re-introduce the silent-drop failure mode (a
  still-armed cooldown would surface `CooldownActive` to the auto-sync
  push site, which swallows the error). Rate-limit + memory checks
  are unchanged.
- **`AutomergeSyncCoordinator::sync_document_with_all_peers`** now runs
  per-peer syncs concurrently via `futures::join_all` instead of a
  serial `.await` loop. The change keeps the worst-case latency to
  drain a single `change_rx` event bounded at one `sync_cooldown` on
  any size mesh, instead of `O(N_peers * sync_cooldown)` after the
  defer-on-cooldown change above. Per-peer errors are still logged and
  swallowed at the same site.

#### Behavioral contract change for `pub` callers

`AutomergeSyncCoordinator::initiate_sync`,
`::sync_document_with_peer`, and `::sync_document_with_all_peers`
remain identical at the signature level. The **timing contract**
changed: a call that previously fast-failed with
`FlowControlError::CooldownActive` now blocks up to `sync_cooldown`
(default 100 ms) before resolving. External callers
(`peat-protocol`, `peat-node`) that relied on the fast-fail behavior to
queue retries themselves will now see the deferral happen inside
peat-mesh. No consumer in the workspace today depends on the fast-fail
path; the previous behavior was load-bearing only on the auto-sync
push task that this PR fixes.

### Added

- **`FlowController::remaining_cooldown(peer_id, doc_key) -> Duration`**
  — read-only sibling of `check_sync_allowed`'s cooldown branch.
  Returns `Duration::ZERO` for unknown pairs or expired cooldowns. Does
  not bump `blocked_count` (a deferred wait is not a real block) and
  does not require `&mut self`. Backed by the new
  `SyncCooldownTracker::remaining`.
- **Three flow-control unit tests** covering the read-only accessor
  (`remaining_cooldown_zero_for_unknown_pair`,
  `remaining_cooldown_decays_after_record`,
  `remaining_cooldown_is_per_pair`).
- **New `FlowController::check_rate_limit_only`** — the cooldown-less
  sibling of `check_sync_allowed`, called by `initiate_sync` after the
  defer loop. Surfaces rate-limit + memory enforcement without the
  cooldown branch that the loop already obeyed.
- **Three sync-coordinator regression tests**:
  - `initiate_sync_defers_instead_of_dropping_on_cooldown` — pins
    the load-bearing behavior change. A future refactor that drops
    the `tokio::time::sleep` from `initiate_sync` and reverts to
    fast-fail fails this test on both timing and error-shape
    assertions.
  - `sync_document_with_all_peers_runs_peer_cooldowns_concurrently` —
    pins the `join_all`-based concurrent-peer-sync. Uses a
    `TwoPeerTransport` mock with two stamped cooldowns and asserts
    elapsed ≈ one cooldown (≥80 ms, <180 ms), not two
    (~200 ms). A revert to the serial `.await` loop fails the
    ceiling.
  - `initiate_sync_loop_re_observes_concurrent_record_sync` — pins
    the bounded retry loop's TOCTOU-closure behavior. Spawns a
    concurrent `record_sync` mid-sleep and asserts the loop iterates
    a second time (elapsed ≥115 ms, <250 ms). A shrink to
    `MAX_COOLDOWN_DEFERRALS = 1` or removal of the loop fails the
    floor.

### Verification

- `cargo fmt --check` clean.
- `cargo clippy --all-targets --features automerge-backend -- -D warnings` clean.
- `cargo test --features automerge-backend --lib` — full suite passes.
- End-to-end via `[patch.crates-io]` override from
  [peat-node#76](https://github.com/defenseunicorns/peat-node/pull/76):
  PRD-006 test 23 (`subscribe_emits_progress_then_terminal`) — the
  test
  [defenseunicorns/peat#864](https://github.com/defenseunicorns/peat/issues/864)'s
  acceptance criteria explicitly names — passes in 5 seconds (1
  IN_PROGRESS frame, exactly 1 terminal frame for a 4 MiB two-peer
  transfer). Without this fix the same test stalls indefinitely.

### Open follow-ups (deferred)

- `check_sync_allowed`'s cooldown branch is now unreachable from the
  auto-sync push path (which calls `check_rate_limit_only` after the
  defer loop). External callers using `check_sync_allowed` directly
  still hit it. A future cleanup could prune `check_sync_allowed` to
  not duplicate the rate-limit logic, or split the cooldown check into
  its own method that callers opt into.
- `SyncCooldownTracker::remaining` allocates one `String` per call for
  the `HashMap<(_, String), _>::get` lookup. The read path is
  auto-sync-task scoped, not on a per-message hot path, so the
  allocation is acceptable; a future cleanup could switch to a
  two-level `HashMap<EndpointId, HashMap<String, _>>` mirroring
  `FlowController::rate_limiters`.
- `futures::join_all` materializes all per-peer futures into a `Vec`
  before awaiting. Fine at current mesh sizes (<100 peers); at larger
  fan-out, `FuturesUnordered` would let completions stream and reduce
  peak memory. Forward-looking, non-blocking.

## [0.9.0-rc.9] - 2026-05-12

Critical sync-layer fix for
[#115](https://github.com/defenseunicorns/peat-mesh/issues/115) — a
self-sustaining sync ping-pong loop in two-peer cross-cluster Iroh
deployments that drained the per-peer flow-control bucket and
starved first-sync of new documents
([#116](https://github.com/defenseunicorns/peat-mesh/pull/116)). Also
lands the `AutomergeBackendConfig` BREAKING change accumulated under
Unreleased since rc.8
([#114](https://github.com/defenseunicorns/peat-mesh/pull/114)).

### Added

- **`peat_mesh::sync::AutomergeBackendConfig`** — `Config`-struct input
  for `AutomergeBackend::with_iroh`, matching the
  `Config + ::new(cfg)` pattern used elsewhere in this crate
  (`MeshConfig`, `TransportManagerConfig`, `LiteTransportConfig`, …).
  Adds `iroh_bind_addr: Option<SocketAddr>` so the Iroh QUIC endpoint
  can be pinned to a known UDP port; required for direct-addressed
  deployments (Docker compose, k8s `hostPort` / `containerPort`,
  cross-cluster harnesses) that reach peers by `<host>:<port>` without
  relay assistance. Closes the reopen scope on
  [#106](https://github.com/defenseunicorns/peat-mesh/issues/106).
- **`AutomergeStore::put_without_notify_with_ttl`** — TTL-aware variant
  of the existing Issue #346 `put_without_notify` escape hatch.
  Required by the #115 fix below; receive-side persist paths use it
  to avoid firing the sync coordinator's outbound-sync trigger.

### Changed

- **BREAKING:** `AutomergeBackend::with_iroh` now takes a single
  `AutomergeBackendConfig` argument instead of
  `(data_dir, formation_id, base64_shared_key)`. Migration:
  ```rust
  // before (rc.8)
  AutomergeBackend::with_iroh(data_dir, formation_id, base64_shared_key).await?
  // after
  AutomergeBackend::with_iroh(AutomergeBackendConfig {
      data_dir: data_dir.into(),
      formation_id: formation_id.into(),
      base64_shared_key: base64_shared_key.into(),
      iroh_bind_addr: None,
  }).await?
  ```
  No behavioral change for callers that leave `iroh_bind_addr = None`.
- **`FlowController` rate-limiter is now keyed per `(peer, doc)`** —
  internal change (no public surface impact for callers using
  `check_sync_allowed` / `available_tokens`, both of which already
  took `doc_key`). Nested `HashMap<EndpointId, HashMap<String, _>>`
  layout so cache-hit lookups take `&str` without allocation.
- **`FlowController::available_tokens` signature** — now takes
  `(peer_id, doc_key)`. Previously `(peer_id)`. No external callers
  in-tree; cited as a defense-in-depth follow-up to #115 to prevent
  one chatty doc from rate-limit-starving other docs on the same peer.

### Fixed

- **Sync ping-pong loop saturating per-peer rate limit
  ([#115](https://github.com/defenseunicorns/peat-mesh/issues/115),
  [#116](https://github.com/defenseunicorns/peat-mesh/pull/116)).**
  `receive_sync_message` and `apply_state_snapshot` previously
  persisted received documents via `put_with_ttl`, which fires
  `AutomergeStore::change_tx`. That broadcast drives the outbound
  auto-sync pusher in `automerge_backend::start_sync`, so every
  received message caused the coordinator to re-broadcast the same
  doc back to the sender. `sync_document_with_all_peers` then cleared
  the per-peer sync state before re-initiating, so the protocol
  restarted every round and never converged. In a two-peer
  cross-cluster k3d repro this produced ~170 sync messages/sec on a
  single converged document, draining the per-peer flow-control
  bucket. A subsequent first-sync attempt for a *new* document by
  either peer was rate-limit-blocked and never retried. Routed all
  four receive-side persist sites through a new `put_received`
  wrapper that uses `put_without_notify_with_ttl`; `observer_tx`
  still fires so hierarchical aggregation (Issue #377) is preserved.

### Migration (from 0.9.0-rc.8)

The `AutomergeBackend::with_iroh` BREAKING is the only source-level
change. See the **Changed** section above for the diff. Callers that
go through the `AutomergeBackendConfig` form gain access to
`iroh_bind_addr` for pinned-UDP-port deployments.

The #115 fix is purely internal — no API change, no on-disk format
change, no migration. Existing data directories read cleanly.

## [0.9.0-rc.8] - 2026-05-12

Ships peat-mesh's first production `DataSyncBackend` implementation,
`AutomergeBackend`, behind the existing `automerge-backend` feature
([#106](https://github.com/defenseunicorns/peat-mesh/issues/106) /
[#110](https://github.com/defenseunicorns/peat-mesh/pull/110)).
Consumers like `peat-node` that previously hand-wired the 7-step
`AutomergeStore` + `MeshSyncTransport` + `AutomergeSyncCoordinator`
+ `SyncProtocolHandler` + `NetworkedIrohBlobStore` bootstrap can now
construct an `Arc<dyn DataSyncBackend>` in a single
`AutomergeBackend::with_iroh(data_dir, formation_id, base64_shared_key)`
call and hand it to `peat_mesh::Node::new`. Also includes the
relay-default tightening accumulated under Unreleased since rc.7.

### Added

- **`peat_mesh::sync::AutomergeBackend`** behind the `automerge-backend`
  feature. Implements all four traits in the `peat_mesh::sync::traits`
  stack (`DocumentStore`, `PeerDiscovery`, `SyncEngine`,
  `DataSyncBackend`) by composing existing peat-mesh storage
  primitives. Wire-compatible with on-disk data written by the prior
  7-step bootstrap — `AutomergeStore::open(data_dir.join("automerge"))`
  reads existing `automerge.redb` directly; no migration needed.
  Strictly additive: zero new dependencies; `AutomergeStore`,
  `MeshSyncTransport`, `AutomergeSyncCoordinator`,
  `SyncProtocolHandler`, `NetworkedIrohBlobStore`, `FormationKey`,
  trait definitions, and `InMemoryBackend` are all consumed read-only.
  21 adapter unit tests cover the trait surface, including:
  dual-emission single-event contract under `start_sync`, the pre-bake
  guarantee that an upsert immediately followed by a remove still
  emits the Updated event, observer-task termination on backend drop
  (`Weak<AutomergeStore>` + per-iteration upgrade after
  `rx.recv().await` resolves, breaking the prior ownership cycle),
  apply_tombstone round-trip, and a receive-side observer-forwarder
  test that drives the inbound-sync path via direct `store.put`.
  Closes [#106](https://github.com/defenseunicorns/peat-mesh/issues/106).
- Cargo feature `relay-n0-hosted` (off by default) as a grep-able
  opt-in escape hatch that restores the previous relay behavior.
  Build with `--features relay-n0-hosted` to re-enable n0's hosted
  relay pool and DNS pkarr discovery; an empty `IrohConfig::relay_urls`
  then falls through to n0's defaults again.

### Changed

- **BREAKING (default behavior):** `peat-mesh` no longer enables n0's
  hosted iroh relay pool or DNS pkarr discovery by default. The
  `NetworkedIrohBlobStore::build_endpoint*` constructors now use
  `Endpoint::empty_builder()` instead of
  `iroh::endpoint::presets::N0`, so default builds never reach
  `*.iroh.network` or `*.iroh-canary.iroh.link`. `IrohConfig::relay_urls`
  semantics shift accordingly: an empty `relay_urls` now means "no
  relays at all" rather than "fall through to n0". Cross-internet
  hole-punching that implicitly relied on n0 relays will fail in
  default builds; LAN/mDNS and direct addresses continue to work, and
  callers that supply their own relay URLs (`RelayMode::Custom`) are
  unaffected. Mirrors the same change in `peat-protocol`. See
  [defenseunicorns/peat#833](https://github.com/defenseunicorns/peat/issues/833).

### Migration (peat-node and other consumers)

```rust
use peat_mesh::sync::AutomergeBackend;
use peat_mesh::sync::DataSyncBackend;
use peat_mesh::Node;
use std::sync::Arc;

let backend: Arc<AutomergeBackend> = AutomergeBackend::with_iroh(
    "/var/lib/myapp",
    "my-formation",
    "base64-shared-key=",
).await?;
let node = Node::new(backend as Arc<dyn DataSyncBackend>);
```

Existing peat-node `data-dir` written under rc.7's hand-wired
bootstrap reads cleanly via this path — the adapter uses
`AutomergeStore::open(data_dir.join("automerge"))`, same layout
`SidecarNode::new` writes.

### Known limitations (deferred follow-ups)

Tracked across the round-1/2/3 QA reviews on
[#110](https://github.com/defenseunicorns/peat-mesh/pull/110):

- `Document::updated_at` is restamped to `SystemTime::now()` on every
  read, unlike `InMemoryBackend` which preserves the original timestamp.
- `subscriptions` HashSet grows unbounded; `SyncSubscription` has no
  revocation hook (matches the current peat-mesh trait surface).
- `shutdown()` stops sync activity but does not tear down the iroh
  router; the router shuts down naturally when the last
  `Arc<NetworkedIrohBlobStore>` drops.
- Rapid-sequential local upserts to the same key overwrite each
  other's `PendingEmission`. Fix requires `VecDeque<PendingEmission>`
  per key.
- `on_peer_event` only fires for adapter-initiated outbound connects;
  inbound connects, disconnects, and reconnects are not surfaced.
  Full coverage requires a peer-event channel on `MeshSyncTransport`
  itself.

## [0.9.0-rc.7] - 2026-05-06

Reciprocal half of the rc-train circular-pin closure. Relaxes the
`=`-exact peat-btle pin to a `^` caret range, matching peat-btle
0.3.4-rc.5's reciprocal relaxation
([defenseunicorns/peat-btle PR #49](https://github.com/defenseunicorns/peat-btle/pull/49)).
Both sides relaxed simultaneously is what closes the loop — one
alone leaves the other direction tight.

### Changed

- **`peat-btle` dep relaxed from `=0.3.4-rc.4` to
  `>=0.3.4-rc.3, <0.3.5`.** Allows any `0.3.4-rc.x ≥ rc.3` and the
  `0.3.4` final to match — that's the rc-cycle motion the
  `=`-exact pin was blocking — while keeping a hard upper bound at
  the next patch line. A `0.3.5` final, `0.3.5-rc.x`, or anything
  `≥ 0.3.5` requires an explicit re-pin. **Why not `^0.3.4-rc.3`:**
  cargo's caret on `0.x.y` expands to `<0.4.0` and would auto-pull
  any `0.3.x` final (`0.3.5`, `0.3.6`, …); for pre-1.0 crates in
  active development that's too generous, since any `0.3.x → 0.3.y`
  bump can carry a wire-format change to the `mesh-translator`
  byte format. rc.3 is the floor because that's the first peat-btle
  release carrying `BlePeerLinkInfo` + `BleAdapter::peer_link_info` +
  `BluetoothLETransport::peer_link_info`.
- **Coordination comment block rewritten** to document the new
  policy and the rationale: rc.1–rc.6's exact-pin policy caused a
  4-release rc-train ladder per landed feature (peat-mesh rc.5 →
  rc.6 → peat-btle rc.4 → peat-mesh rc.7 → peat-btle rc.5 → …).
  The explicit range protects what the `=` was actually for —
  wire-shape coupling — at the right granularity (allow rc.x
  motion, lock to the 0.3.4 patch line).

### Migration from 0.9.0-rc.6

- No code change. Source-compatible.
- Workspace consumers can now bump peat-btle independently of
  peat-mesh within the rc cycle without forcing a coordinated
  peat-mesh release. peat-btle's reciprocal relaxation
  (`peat-mesh = ">=0.9.0-rc.5, <0.9.1"`) ships in peat-btle
  0.3.4-rc.5.

## [0.9.0-rc.6] - 2026-05-05

Coordination release closing the rc-train circular pin. peat-btle
0.3.4-rc.4 (defenseunicorns/peat-btle PR #48) bumped its peat-mesh
dep to `=0.9.0-rc.5`, but peat-mesh rc.5 was still pinning peat-btle
`=0.3.4-rc.3` — leaving consumers (peat workspace) unable to resolve
both crates together. This release bumps the peat-btle pin to
`=0.3.4-rc.4` so the two crates' rc trains line up.

### Changed

- **`peat-btle` dep bumped from `=0.3.4-rc.3` to `=0.3.4-rc.4`.** No
  surface change; the bumped crate is the lockstep coordination
  release of peat-btle (also no surface change there). The exact-pin
  (`=`) policy is preserved per the deliberate rc-cycle wire-shape
  lockstep rationale; relaxing the policy was considered as the
  alternative to this kind of cross-crate ladder release and is
  tracked separately if/when it lands.

### Migration from 0.9.0-rc.5

- No-op for source-level callers — no API surface changed.
- Workspace consumers pulling both peat-mesh and peat-btle through
  crates.io now resolve cleanly with `peat-mesh = "=0.9.0-rc.6"` +
  `peat-btle = "=0.3.4-rc.4"`.

## [0.9.0-rc.5] - 2026-05-05

Per-peer link state surface for ADR-032 §Amendment A
([defenseunicorns/peat#823](https://github.com/defenseunicorns/peat/pull/823)),
landed across PRs #93 and #96. Unblocks downstream consumers
(`peat-ffi`'s `peer_transport_state` accessor, the ATAK plugin's
multi-transport visualization).

### Added

- **`Transport::peer_link_state(peer_id) -> Option<LinkState>` trait
  method** with default-`None` impl, so existing transport
  implementations continue to compile; visualization shows "unknown"
  until each transport opts in. Defined in
  `src/transport/capabilities.rs` (#93, commit `5a289f0`).
- **Transport-agnostic `LinkState` struct** carrying
  `transport_id`, `transport_type`, `interface`, `quality`,
  `rtt_ms`, `rssi_dbm`, and `path_kind`. Optional fields are
  populated where meaningful per transport — adding LoRa /
  satellite later requires no struct change.
- **`LinkQuality` enum** (`Excellent` / `Good` / `Fair` / `Weak` /
  `Unknown`) — bucketed quality for UI tier indicators; consumers
  don't need to know transport-specific thresholds.
- **`PathKind` enum** (`Direct` / `Relay`). `Mixed` was considered
  during the amendment and intentionally deferred until a real
  multi-path-concurrent emitter exists.
- **`PeatBleTransport::peer_link_state` impl** in
  `src/transport/btle.rs` (#96, commit `f16ca7b`). Reads
  peat-btle's `peer_link_info` accessor (peat-btle 0.3.4-rc.3),
  bridges identity via the existing `peat_to_btle_node_id`
  function, buckets RSSI per peat-btle's existing thresholds
  (≥ −50 Excellent, ≥ −70 Good, ≥ −85 Fair, < −85 Weak), with
  state taking precedence over RSSI per the ADR's "disconnected
  but known returns Some" contract.
- **`pub const BLE_TRANSPORT_ID_PLACEHOLDER: &str = "ble"`** — the
  placeholder `transport_id` returned by
  `PeatBleTransport::peer_link_state`. The `TransportManager`-walker
  (peat-ffi) overlays the registered `TransportInstance.id`
  ("ble-hci0") onto the returned `LinkState`, mirroring how
  `interface` is overlaid. Producers that don't know their own
  registered id (because construction precedes registration) emit
  the placeholder and let the consumer fill it.

### Changed

- **`peat-btle` dep bumped from `"0.2"` to `"=0.3.4-rc.3"`** (#96).
  The older floating range was building against `0.2.4`, far behind
  the 0.3.x line where `BlePeerLinkInfo` / `BleAdapter::peer_link_info`
  / `BluetoothLETransport::peer_link_info` live. Exact-pin (`=`) is
  deliberate for the rc cycle: any future peat-btle release will
  require a coordinated peat-mesh follow-up rather than an automatic
  `cargo update`-driven bump.

### Migration from 0.9.0-rc.4

- **No-op for transport authors** who don't override
  `peer_link_state` — the default-`None` impl preserves source
  compatibility. Visualization simply shows "unknown" until the
  transport opts in.
- **Consumers that walk `TransportManager` for per-peer state**
  (peat-ffi, host-side renderers): see ADR-032 §Amendment A's
  host-rendering rule. Walk
  `available_instances_for_peer(peer_id)` and call
  `peer_link_state` on each transport. Overlay
  `LinkState.transport_id` and `LinkState.interface` from the
  registered `TransportInstance`.

## [0.9.0-rc.4] - 2026-04-30

Hotfix release of 0.9.0-rc.3 — corrects a `#[non_exhaustive]` design
flaw discovered immediately on contact with peat-protocol's
`AutomergeIrohBackend` migration. No new features.

### Changed

- **`#[non_exhaustive]` removed from individual `ChangeEvent` variants
  (`Updated`, `Removed`, `Initial`).** The enum-level
  `#[non_exhaustive]` is preserved — external matchers still need a
  wildcard arm, which protects the forward-compat-of-new-variants
  case the variant-level marker was trying to cover.

  rc.3 marked the variants `#[non_exhaustive]` "for symmetry," which
  blocked external `DocumentStore` backends from constructing
  `ChangeEvent::Updated { collection, document, origin }` with
  struct-expression syntax (Rust E0639). That pattern is exactly
  what backends like peat-protocol's `AutomergeIrohBackend` need to
  do to emit observer events. The variant-level marker was the wrong
  tool for differentiating "future-proof against new fields" from
  "block external struct construction" — the latter is dominant in
  practice.

  Adding fields to existing variants in future slices is technically
  a breaking change for backend-author construction sites, but: (1)
  such construction is rare and scoped to backend code, (2) the
  migration when it happens is mechanical (add field to all known
  call sites in lockstep). Trading that future cost for present
  unblocked construction is the right call.

### Migration from 0.9.0-rc.3

- No-op for callers who never construct `ChangeEvent::Updated`/`Removed`/`Initial`
  with struct-expression syntax.
- Backend authors can now write `ChangeEvent::Updated { collection,
  document, origin }` from outside peat-mesh; rc.3 rejected this with
  E0639.

## [0.9.0-rc.3] - 2026-04-30

Third release candidate for 0.9.0. Cuts the foundation half of the
ADR-059 cross-transport document bridging work (peat-mesh ADR-0011,
canonical: `peat/docs/adr/059-cross-transport-document-bridging.md`)
so peat-protocol's `BleTranslator` refactor and peat-ffi's generic
`OutboundFrameCallback` surface can begin consuming the new APIs.

### Added

- `Translator` trait + `TranslationContext` (peat-mesh#87, ADR-059
  Slice 1.a). The codec contract every non-iroh transport (BLE, TAK,
  LoRa, SBD, mavlink, …) implements to bridge documents between the
  mesh's `Node` document store and the transport's foreign wire
  format. iroh stays out of the `Translator` set — it's the CRDT
  sync substrate, not a wire-format codec. `transport_id() ->
  &'static str`, `async fn encode_outbound(&Document, &TranslationContext)
  -> Option<Vec<u8>>`, `async fn decode_inbound(&[u8],
  &TranslationContext) -> anyhow::Result<Option<Document>>`.
  `TranslationContext` is `#[non_exhaustive]` with named constructors
  (`outbound`, `inbound`) and builder methods (`with_callsign`,
  `with_cell`, `with_peer`, `with_local_wire_id`, `with_collection`).
- `Node::publish_with_origin(collection, doc, origin: Option<String>)`
  (peat-mesh#87). Used by transport bridges to mark a document as
  ingested via transport `X` so `TransportManager`'s observer-driven
  fan-out can suppress the same-node echo loop.
- `ChangeEvent.origin: Option<String>` on `Updated` and `Removed`
  variants (peat-mesh#87). Per-event, per-node metadata — **not stored
  on the document, not serialized to the wire, not replicated by
  Automerge sync.**
- `ChangeEvent::Initial.collection: String` (peat-mesh#88). Symmetric
  with `Updated`/`Removed`; populated by the observer task from its
  subscription target so codecs can dispatch by doc type without
  re-deriving from subscription state.
- `TranslationContext.collection: Option<String>` + `with_collection()`
  builder (peat-mesh#88). Codec-dispatch field — `BleTranslator` reads
  this in `encode_outbound` to choose between `position_to_track` /
  `peripheral_to_platform` / etc.
- `TransportManager::register_translator(translator, sink, config)` +
  `unregister_translator` + `has_translator` (peat-mesh#87). Rejects
  on `transport_id` collision per ADR-059's uniqueness invariant.
- `TransportManager::start_fanout(node, collections) -> FanoutHandle`
  (peat-mesh#87). Observer-driven fan-out: subscribes to
  `Node::observe` for each collection, dispatches each `ChangeEvent`
  through every registered translator's `encode_outbound`, into
  per-transport bounded mpsc channels with QoS-aware drop on overflow.
  Per-transport drain tasks call `OutboundSink::send_outbound` with
  the encoded bytes.
- `OutboundSink` trait — transport-side recipient of encoded bytes.
  Native Rust transports wrap their send path; JNI-bound transports
  (Android plugin) plug in via the `OutboundFrameCallback` generic
  FFI surface that ships in Slice 1.b (peat-ffi).
- `QosDropPolicy` trait + `TailDropPolicy` default. Slice 1.a ships
  tail-drop with telemetry; Slice 2 will add priority-aware eviction
  per ADR-019.
- `TranslatorRegistrationConfig` with per-family channel-depth
  defaults: BLE (32), TAK (256), LoRa (16), SBD (8) — sized per
  transport throughput and burst-tolerance characteristics.
- Lifecycle: parent/child cancellation tokens. Each `start_fanout`
  call gets a child token; dropping the `FanoutHandle` cancels only
  the observer tasks for that fanout, not the drain tasks. Dropping
  `TransportManager` cancels the parent token, which propagates to
  all children. Observer tasks hold `Arc<FanoutState>` (not
  `Arc<TransportManager>`) so the manager remains droppable.
- Local ADR pointer at `docs/adr/0011-cross-transport-document-bridging.md`
  → canonical `peat/docs/adr/059-…` for cross-repo discoverability.

### Changed

- `DocumentStore::upsert_with_origin(collection, doc, origin)` is now
  the **required** trait method (peat-mesh#87). The origin-less
  `upsert(collection, doc)` is a default convenience that delegates
  to `upsert_with_origin(.., None)`. Backend authors who only
  implement the prior `upsert` shape get a compile error rather than
  silently dropping origin and breaking ADR-059's echo-loop
  suppression.
- `Query` enum is now `#[non_exhaustive]` (peat-mesh#87). Prevents
  external exhaustive matchers from breaking when Slice 2 adds the
  `AllowedTransport(String)` variant.
- `ChangeEvent` enum and all three variants are now `#[non_exhaustive]`
  (peat-mesh#87). Enables the per-variant additive-only changes used
  by Slice 1.a (`origin`) and Slice 1.b.1 (`collection`).

### Migration

- External `DocumentStore` impls must change from implementing
  `upsert(...)` to implementing `upsert_with_origin(.., origin)`.
  In-tree impls (`InMemoryBackend`, three test fixtures) are migrated.
  Out-of-tree backends (e.g. peat-protocol's `AutomergeIrohBackend`)
  need the same migration in their tracking branches; Slice 1.b.2 in
  the peat repo handles this.
- Exhaustive destructurers on `Query` and `ChangeEvent`/its variants
  must use wildcard match arms or `..` rest patterns.

## [0.9.0-rc.2] - 2026-04-28

Second release candidate for 0.9.0. Cuts immediately so peat-protocol
and downstream crates can begin consuming the new `Node` document
layer (the foundation step of the peat-mesh-completion / peat-btle-reduction
work tracked in `PEAT-MESH-COMPLETION-0.9.0.md`).

### Added

- `peat_mesh::Node` — generic application-level mesh document layer
  (peat-mesh#85). Wraps `Arc<dyn DataSyncBackend>` and exposes
  `publish` / `get` / `query` / `delete` / `observe` over the existing
  trait surface, transport-agnostic and doc-type-agnostic. Per-doc-type
  convenience surfaces (`send_chat`, `send_emergency`, …) and the
  cross-transport BLE↔iroh gateway will compose on top of `Node` in
  subsequent releases. The backend's existing observer is the single
  source of change events — `Node` does not synthesize alongside it.

### Fixed

- Pinned the pre-release transitives `ed25519 = "=3.0.0-rc.4"` and
  `pkcs8 = "=0.11.0-rc.11"` (gated behind `automerge-backend`) so a
  fresh CI resolve under rustc ≥ 1.95 doesn't pick incompatible newer
  rcs that break compilation in `ed25519-dalek-3.0.0-pre.1`'s source
  (the `pkcs8::Error::KeyMalformed` variant changed shape between rcs).
  Library-correct workaround that mirrors iroh's own `=` pin pattern;
  to be removed once the upstream stack stops shipping pre-release
  transitives that don't survive their own dep ranges (peat-mesh#85).

### Changed

- `.githooks/pre-commit` now also runs `cargo test --lib --quiet`,
  matching CI's third gate. Closes the gap that let peat-mesh#85's
  initial commit pass pre-commit while CI hit a then-uncovered
  resolver issue (peat-mesh#85).

## [0.9.0-rc.1] - 2026-04-23

First release candidate for 0.9.0. Publishing as an rc so the `peat`
workspace can validate the upgrade through its CI pipeline before we
promote to a stable 0.9.0 on crates.io.


### Removed

- **BREAKING**: `TtlConfig::ditto_alter_system_commands()` and
  `TtlConfig::ditto_env_vars()` — legacy helper methods deprecated in
  0.8.x. `TtlConfig` consumers should use the `tombstone_ttl_hours`,
  `tombstone_reaping_enabled`, and `days_between_reaping` fields
  directly (peat-mesh#81).

### Changed

- **BREAKING**: Default features now include `automerge-backend`. Crates
  that want to compile peat-mesh without any storage backend must
  declare the dependency with `default-features = false` (peat-mesh#78).

### Added

- String-based helpers on `NetworkedIrohBlobStore` for downstream crates
  that don't want to thread `Hash` types through their own public API
  (peat-mesh#82).

### Fixed

- Per-document striped locks around Automerge compaction prevent a rare
  compact/sync data-loss race where a sync write could land on a
  document mid-compaction (peat-mesh#79).
- Flaky QUIC endpoint tests (peer_paths, peer_rtt, formation_auth,
  p2p_blob) stabilized via tokio mutex, bind/connect timeouts, and
  explicit `Router` handle retention (part of peat-mesh#78).
- 16 pre-existing clippy warnings across the workspace, plus two
  rust-1.95 lints (`manual_checked_division`, `unnecessary_sort_by`)
  (peat-mesh#81).

## [0.8.2] - 2026-04-05

### Added

- Per-collection, sync-mode-aware Automerge CRDT compaction to prevent
  unbounded memory growth on long-running nodes and mobile devices (peat#760).
  Compaction only runs on `LatestOnly` sync-mode collections, preserving
  change history needed for delta sync on `FullHistory` collections.
  Disabled by default; enable with `PEAT_COMPACTION_ENABLED=true`.

## [0.8.1] - 2026-04-03

### Fixed

- Formation auth handshake deadlock over iroh 0.97 connections (#759).
  `accept_bi()` requires the opener to write before the peer can accept the
  stream — the initiator now sends a `FORMATION_AUTH_VERSION` preamble byte
  immediately after `open_bi()`, unblocking the acceptor.

### Added

- Integration tests for formation auth handshake over live iroh connections
  (successful auth and wrong-key rejection).

## [0.8.0] - 2026-04-02

### Security

- **BREAKING**: Enforce mandatory formation credentials on all Iroh QUIC connections
  - `FormationKey` is now required (not optional) for `SyncProtocolHandler` and `MeshSyncTransport`
  - New `FormationEndpointHooks` gates ALL QUIC connections (sync, blobs) at transport level — only formation members accepted
  - New `FormationPeerSet` tracks known formation member EndpointIds, populated by discovery
  - Outgoing sync connections now run `respond_to_formation_auth()` HMAC challenge-response
  - Removed "warn-and-allow" certificate mode — certificates are always hard-enforced when configured
  - Removed `PEAT_REQUIRE_CERTIFICATES` env var — certificates always required when bundle is present

### Added

- `FormationPeerSet` type in `security::formation_peers` for thread-safe peer tracking
- `FormationEndpointHooks` in `storage::iroh_blob_store` for QUIC-level connection gating
- `MeshSyncTransport::connect_and_authenticate()` for authenticated outbound connections
- `SyncTransport::get_or_connect()` trait method with default fallback implementation
- `NetworkedIrohBlobStore::build_endpoint_with_formation_peers()` for production endpoint construction

### Changed

- `SyncProtocolHandler::new()` now requires `FormationKey` parameter (breaking)
- `SyncProtocolHandler::with_certificate_bundle()` no longer takes `require` parameter (breaking)
- `MeshSyncTransport::new()` now requires `FormationKey` parameter (breaking)
- `PeerConnector::new()` now requires `FormationPeerSet` parameter (breaking)
- `PeerConnector::with_certificate_bundle()` no longer takes `require` parameter (breaking)
- All sync connection sites use `get_or_connect()` instead of `get_connection()` for authenticated fallback

## [0.2.0] - 2026-02-20

### Added

- Extract sync modules from peat-protocol into peat-mesh (automerge_sync, sync_channel, sync_forwarding)
- Wire AutomergeSyncCoordinator into peat-mesh-node binary with 5s polling task
- Update deployment guide for Automerge sync stack

### Fixed

- Apply cargo fmt and clippy fixes for CI compliance

## [0.1.0] - 2026-02-18

### Added

- Initial extraction as standalone crate from peat-protocol
- Complete Peat-Lite CRDT encodings, query handler, and InMemoryBackend
- Kubernetes support: discovery, headless service, StatefulSet (ADR-0001)
- Kubernetes deployment infrastructure: Dockerfile, Helm chart, health probes (ADR-0001 Phase 2)
- HTTP/WS service broker with REST API and WebSocket event streaming
- Device keypair (Ed25519) and formation key security primitives
- mDNS, static, and Kubernetes peer discovery strategies
- Topology management with partition detection and autonomous operation
- Beacon broadcasting and observation for geographic mesh awareness
- GOA CI script for Radicle patches
- ADR-0001 through ADR-0009 transferred from peat repo
- README with CI and usage details

### Fixed

- CI pipe buffer deadlock (redirect verbose output to log file)
- Add crates.io metadata for discoverability
