Explorar o código

openspec: add darkirc-delivery-status proposal

darkfi hai 1 semana
pai
achega
f4276ece32

+ 2 - 0
openspec/changes/darkirc-delivery-status/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-26

+ 222 - 0
openspec/changes/darkirc-delivery-status/design.md

@@ -0,0 +1,222 @@
+# Design: darkirc-delivery-status
+
+## Context
+
+Today `publish_events` (bin/darkirc/src/irc/client.rs) commits an event
+locally, calls `p2p.broadcast(&EventPut(...))`, and never learns anything
+about the outcome. The receiving handler
+(`ProtocolEventGraph::handle_event_put`, src/event_graph/proto.rs)
+silently `continue`s on every skip path (duplicate, too old, unsynced,
+invalid). `synced` is a one-way latch, so the existing `args_queue`
+covers only the initial-sync window, not later disconnections; once
+`dag_prune_task` rotates the DAG, peers reject any event with
+`timestamp < genesis_ts` forever. See proposal.md for motivation.
+
+Constraints that shape the design:
+
+- `ProtocolEventGraph` is per-peer-connection; the only shared state is
+  on `EventGraph` (pattern: `event_pub`/`static_pub` publishers).
+- darkirc and `bin/app` both publish chat events directly
+  (`client.rs::publish_events` and `app/src/plugin/darkirc.rs::handle_send`);
+  taud uses the same protocol without chat semantics.
+- RLN is currently inactive on the network (empty blobs), but the
+  enabled path must stay correct: recreations must reserve a fresh
+  message slot or risk self-slashing.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Outcome replies for `EventPut` with zero policy in the generic layer.
+- darkirc: durable outbound tracking, rebroadcast-first, bounded
+  recreate; survives restart; safe with RLN on or off.
+- Stable wire format (`u8` discriminants), no new panics on untrusted
+  input.
+
+**Non-Goals:**
+
+- Pull-based possession verification (`EventReq` challenges) — dropped;
+  status replies are the only signal, treated as hints whose worst-case
+  lie is bounded by attempt caps and uid dedup.
+- Status for `StaticPut`, IRC-surface receipt display, read-by-recipient
+  semantics, changes to strike/flood policing or the relay path.
+
+## Decisions
+
+### D1: Wire message — `EventPutStatus`
+
+```rust
+#[derive(Clone, SerialEncodable, SerialDecodable)]
+pub struct EventPutStatus {
+    pub event_id: blake3::Hash,
+    pub result: EventPutResult,
+}
+
+#[repr(u8)]
+enum EventPutResult {
+    Has { inserted: bool } = 0,
+    Nack { reason: NackReason } = 1,
+}
+
+#[repr(u8)]
+enum NackReason { TooOld = 0, NotSynced = 1, Invalid = 2, Busy = 3 }
+```
+
+- Name: `EventPutStatus` (not `EventPutRep`) to avoid confusion with
+  `EventRep`, which answers `EventReq`.
+- Discriminants are explicit `u8`s; encoding stays the
+  `darkfi-serial` derive (variant tag + payload). Unknown variant tags
+  must decode-fail into a warn-and-drop, not a panic and not a strike.
+- Payload carries only `event_id` + outcome — never channel, nick, or
+  content (privacy: the reply leaks nothing beyond what the event itself
+  already revealed to that peer).
+- Unicast reply on the channel the `EventPut` arrived from; never
+  relayed. Uses the default metering configuration like the other
+  event-graph messages.
+
+Alternative considered: separate ack/nack messages — rejected, one
+subscription and one dispatch is simpler and the enum keeps them
+versioned together.
+
+### D2: Emission points in `handle_event_put`
+
+| Path (in current code order) | Reply |
+|---|---|
+| `!is_synced()` skip | `Nack { NotSynced }` |
+| `main_tree` duplicate | `Has { inserted: false }` |
+| `timestamp < genesis_ts` | `Nack { TooOld }` |
+| `validate_new` / structural / RLN / parent-fetch failure | `Nack { Invalid }` |
+| internal insert error after verification | `Nack { Busy }` |
+| `insert_verified_signal` success | `Has { inserted: true }` |
+
+`Has{inserted:false}` is load-bearing: after reconnect-and-rebroadcast it
+is the dominant positive outcome ("already propagated") and the only
+thing distinguishing it from "never arrived". Strike/flood paths keep
+current behavior and send nothing. The duplicate check sits before the
+flood-window tick, so replying there is free.
+
+### D3: Dumb pipe — `receipt_pub` on `EventGraph`, policy in apps
+
+`EventGraph` gains `receipt_pub: Publisher<(EventPutStatus, ChannelPtr)>`
+plus a `receipt_subscribe()` accessor, republishing every inbound status
+unfiltered. No registry, no retry state at this layer.
+
+Alternative: a generic outbound-registry with retry policy inside
+`EventGraph` — rejected: retry policy is app-specific (darkirc recreates
+chat, taud would want its own rules, the app embeds evgr in-process and
+needs the events for UI, not policy). Keeping the generic layer stateless
+also keeps it out of the security-sensitive blast radius.
+
+### D4: darkirc outbound table
+
+New kvdb tree `darkirc_outbound`, key = event id, value (serial):
+`{ uid, event_id, plaintext Privmsg fields, created_ts, state:
+Pending|Delivered|Failed, attempts: u16, last_broadcast_ts,
+superseded_by: Option<event_id> }`. Written in `publish_events` before
+`p2p.broadcast`. Plaintext-at-rest is inside the existing local-wallet
+trust boundary (same kvdb that holds RLN identity secrets).
+
+`uid`: 16 random bytes from `OsRng` (invariant: CSPRNG, never reused
+across logical messages, always reused across recreations of one
+message).
+
+### D5: Delivery monitor (darkirc)
+
+One task on `IrcServer` (started alongside the client loop):
+
+- Subscribes `receipt_pub`; per (event_id, channel-address) aggregates
+  replies; any `Has` closes the record as Delivered.
+- Periodic sweep (30 s) over `Pending` records, gated by
+  `is_synced() && connection_count >= K` (K = 2, both session directions
+  counted via the existing session APIs).
+- No replies → rebroadcast the original `EventPut` unchanged (event and
+  blob refetched from the local DAG + `dag_blob_fetch`), backoff
+  doubling from 30 s, at most `R_MAX` (= 5) rounds.
+- Any `TooOld`, or all-observed-replies-are-nacks, or `R_MAX` exhausted
+  → recreate (D6).
+- Records older than the current genesis ts are recreated unconditionally
+  on the next eligible sweep (local rotation knowledge; no peer reply
+  needed).
+
+### D6: Recreate procedure
+
+Reuse the existing send path internals: stored plaintext →
+`try_encrypt` (fresh nonce — never reuse the old ciphertext) →
+`Event::new` (fresh tips/timestamp) → RLN branch:
+`reserve_rln_message_id` → `BudgetExhausted` parks the record until
+epoch rollover; `MissingIdentity` closes it as Failed → `create_signal`
+→ `insert_signal_with_blob` → broadcast → write the new record with
+`superseded_by` and shared `uid`; `attempts` increments per uid and caps
+at `A_MAX` (= 2), then Failed + client notice (IRC error reply; the app
+reads the record state through its subscription).
+
+Same `uid` across original and recreations is what makes the duplicate
+rendering problem disappear for uid-aware clients.
+
+### D7: `Privmsg` version bump + `uid`
+
+`Privmsg.version` exists and is currently always 0. New payloads use
+version 1 and carry `uid`. Decoder is version-matched: v0 decodes
+without uid (treated as untracked/legacy), v1 requires it. Old peers
+that cannot decode a v1 payload simply fail content-deserialization and
+skip the event (existing behavior on undecodable content) — their DAG
+still carries it. This serialization change must be sequenced/merged
+with `darkirc-mod`'s content tag byte (same struct, same wire).
+
+### D8: App integration
+
+`bin/app` `handle_send` gains the same record-writing and rebroadcast
+hooks (its path is RLN-disabled, empty blob). Message identity for
+display dedup switches from the ciphertext-hash `msg_id()` to `uid`
+(foreign v0 messages keep a synthetic hash id). A subscription task maps
+`receipt_pub` events onto per-uid state (`sending`/`delivered`/`failed`)
+and notifies the UI; relayed (foreign) statuses are ignored except
+optionally as network telemetry.
+
+## Risks / Trade-offs
+
+- [Fake statuses: a peer can lie `Has` (suppresses recreate → silent
+  loss) or spam `Nack` (forces recreates → budget burn)] → bounded:
+  statuses only count for ids in our own outbound table (blake3 ids are
+  unguessable to non-recipients), attempts capped at `A_MAX`, duplicates
+  deduped by uid, and a peer that has the event relays it anyway. Total
+  eclipse defeats this — accepted (an eclipsed node has larger
+  problems).
+- [Reply amplification: one reply per relay edge] → tiny fixed-size
+  unicast, traffic is RLN-rate-limited anyway, and the flood window is
+  untouched (replies are not EventPuts).
+- [Unknown-message compatibility: peers without `EventPutStatus`
+  receive an unsolicited message type] → verify the channel's behavior
+  on unknown message ids during implementation (test with a mixed
+  version pair); if unknown ids can destabilize old channels, gate
+  replies until a capability flag exists. First implementation task
+  resolves this.
+- [Mixed-version rollout: old clients render a recreation as a
+  duplicate message] → transitional only; documented; uid clients are
+  unaffected.
+- [Clock skew: slightly-future local timestamps can earn spurious
+  `TooOld` nacks] → worst case is a harmless recreate (uid dedup).
+- [RLN interplay: recreate with a stale slot would self-slash] →
+  impossible by construction: recreations go through
+  `reserve_rln_message_id`, parking on exhaustion, never reusing a
+  reserved slot.
+- [Plaintext messages persisted in kvdb] → same trust boundary as the
+  existing wallet/RLN secrets; tree is local-only.
+
+## Migration Plan
+
+Additive wire message first (`src/event_graph`), verified against a
+mixed-version two-node test; then darkirc table+monitor; then the
+`Privmsg` version bump (coordinated with `darkirc-mod`); then app UI.
+Rollback: the message and records are inert for old code; reverting
+leaves a harmless `darkirc_outbound` tree. The `Privmsg` version bump,
+once shipped, is wire-irreversible — hence sequencing it last.
+
+## Open Questions
+
+- Exact values of K, `R_MAX`, `A_MAX`, sweep interval — tuning consts,
+  safe to adjust after rollout.
+- Whether `bin/app`'s outbound records live in its app db or a dedicated
+  tree — implementation convenience, no behavioral impact.
+- Whether taud later reuses the same policy for task events — deferred,
+  out of scope.

+ 85 - 0
openspec/changes/darkirc-delivery-status/proposal.md

@@ -0,0 +1,85 @@
+# Proposal: darkirc-delivery-status
+
+## Why
+
+When a darkirc node is synced but offline (or loses all peers), outgoing
+messages are committed to the local DAG and broadcast into the void. Nothing
+ever retries them, and once the DAG rotation window passes, every peer
+silently rejects them (`event.header.timestamp < genesis_ts`) — the message
+is permanently undeliverable and the user never learns it was lost. There is
+also no delivery feedback of any kind today: `EventPut` is fire-and-forget,
+so clients such as `app` cannot display whether a message reached the
+network.
+
+## What Changes
+
+- New p2p message `EventPutStatus` in `src/event_graph/proto.rs`: a reply
+  sent by the receiver of an `EventPut` describing the outcome. Enum
+  variants use explicit `u8` discriminants for a stable wire format:
+  - `Has { inserted: bool }` — peer has the event (freshly inserted, or
+    already known from earlier propagation)
+  - `Nack { reason }` with coarse reasons: `TooOld`, `NotSynced`,
+    `Invalid`, `Busy`. No fine-grained validation detail (avoids turning
+    nacks into a validation oracle).
+- `ProtocolEventGraph::handle_event_put` emits statuses at each outcome
+  (inserted, already-known, too-old, not-synced, invalid/failed).
+- `EventGraph` gains a `receipt_pub` publisher (same pattern as
+  `event_pub`) that republishes every inbound `EventPutStatus` unfiltered.
+  The event layer is a dumb pipe; delivery policy lives in applications.
+- darkirc (`bin/darkirc`):
+  - Persistent outbound table (kvdb tree, written before broadcast):
+    event id, `uid`, plaintext privmsg, state, attempts.
+  - Receipt aggregation keyed by (event_id, peer channel).
+  - Delivery monitor: rebroadcast-first when reconnected and synced;
+    recreate when nacked `TooOld`, when all responses are nacks, or when
+    zero responses persist after bounded retries.
+  - Recreate = new event from stored plaintext (fresh parents/timestamp,
+    fresh saltbox nonce, same `uid`), new RLN slot when RLN is enabled
+    (`BudgetExhausted` parks the attempt until the next epoch), bounded
+    attempt count.
+- `Privmsg` gains a client-generated `uid` field (constant across
+  recreates) with a version bump, so receiving clients dedup a recreated
+  message against its original. Serialization change must be sequenced
+  with the in-flight `darkirc-mod` content-tag work.
+- `app` (`bin/app`): subscribes to `receipt_pub` in-process and maps
+  `uid` to per-message delivery state (sending / delivered / failed);
+  message identity switches to `uid`-based dedup.
+
+Non-goals: read receipts stored in the event graph (pollutes the DAG,
+burns RLN budget, leaks linkability); IRC-surface receipt display (only
+the app displays them); pull-based possession verification via
+`EventReq`; `StaticPut` status (nickserv already has a deferred-broadcast
+queue); recipient-identifying receipts (nodes are anonymous; this is
+delivery-to-network evidence only).
+
+## Capabilities
+
+### New Capabilities
+
+- `msg-delivery-status`: outcome reporting for `EventPut` broadcast,
+  delivery-state exposure to applications, sender-side rebroadcast/recreate
+  policy for darkirc, and client-side dedup/display of delivery state in
+  the app.
+
+### Modified Capabilities
+
+(none — `chatview` is untouched; app-side rendering is covered by
+`msg-delivery-status` requirements.)
+
+## Impact
+
+- `src/event_graph/proto.rs`, `src/event_graph/mod.rs` — new wire message,
+  status emission, receipt publisher. Protocol-handler surface: must stay
+  panic-free on untrusted input, keep flood/strike policing unchanged.
+- `bin/darkirc` (client.rs, server.rs, lib.rs, crypto/rln.rs interplay) —
+  outbound table, monitor task, recreate path.
+- `bin/app/src/plugin/darkirc.rs` — send path wrapping, `uid` dedup,
+  receipt subscription.
+- `bin/tau/taud` — unaffected consumer; may adopt the same pipe later.
+- Wire compatibility: peers without `EventPutStatus` support simply never
+  reply; senders treat silence as "no response" (drives rebroadcast
+  retries, never correctness). New `Privmsg` field requires version-aware
+  decoding.
+- Sequencing: coordinate `Privmsg` serialization with `darkirc-mod`.
+- Per repo policy, `event_graph` protocol changes require
+  `@anon-security-review` before apply/archive.

+ 220 - 0
openspec/changes/darkirc-delivery-status/specs/msg-delivery-status/spec.md

@@ -0,0 +1,220 @@
+# Spec Delta: msg-delivery-status
+
+## Purpose
+
+Gives senders of event-graph broadcast messages evidence about whether
+peers accepted them, and defines the sender-side rebroadcast/recreate
+policy that prevents messages written while offline from being silently
+lost to DAG rotation. Covers the reply wire format, delivery-state
+exposure to applications, darkirc's outbound tracking and recreate flow,
+and client-side dedup/display of delivery state.
+
+## ADDED Requirements
+
+### Requirement: EventPut outcome reply
+
+A node that receives an `EventPut` SHALL send the sender an
+`EventPutStatus` reply on the same channel describing the outcome:
+
+- `Has { inserted: true }` when the event was newly accepted into the
+  node's DAG
+- `Has { inserted: false }` when the node already has the event
+- `Nack { reason: TooOld }` when the event predates the node's current
+  rotation window
+- `Nack { reason: NotSynced }` when the node is still performing its
+  initial DAG sync and skipped the event
+- `Nack { reason: Invalid }` when the event fails structural or
+  proof validation
+- `Nack { reason: Busy }` when an internal condition prevented
+  processing
+
+Replies apply to relayed events equally: the receiver cannot and does not
+distinguish originator from relay.
+
+#### Scenario: Fresh event accepted
+
+- **WHEN** a peer receives an `EventPut` for an event it does not have,
+  which passes all validation
+- **THEN** it replies `Has { inserted: true }`
+
+#### Scenario: Event already known
+
+- **WHEN** a peer receives an `EventPut` whose event is already in its
+  DAG
+- **THEN** it replies `Has { inserted: false }`
+
+#### Scenario: Event older than the rotation window
+
+- **WHEN** a peer receives an `EventPut` whose event timestamp precedes
+  its current genesis
+- **THEN** it replies `Nack { reason: TooOld }`
+
+#### Scenario: Receiving node still syncing
+
+- **WHEN** a peer that has not finished initial sync receives an
+  `EventPut`
+- **THEN** it replies `Nack { reason: NotSynced }`
+
+#### Scenario: Malicious input keeps existing policing
+
+- **WHEN** a peer receives an `EventPut` that trips existing
+  strike/flood/parent-depth policing
+- **THEN** existing strike, ban, and disconnect behavior is unchanged
+  and no status reply is owed
+
+### Requirement: Coarse nack reasons and stable wire format
+
+`EventPutStatus` payloads SHALL carry only the event id and a coarse
+outcome. Nack reasons SHALL NOT encode fine-grained validation detail.
+All enum variants SHALL use explicit `u8` discriminants so the wire
+format is stable. Decoding an `EventPutStatus` with an unknown variant
+or malformed body SHALL be rejected without panicking, and the receiving
+node SHALL NOT strike or ban the sender solely for an undecodable
+status.
+
+#### Scenario: Unknown variant from a future peer
+
+- **WHEN** a node decodes an `EventPutStatus` carrying a variant it does
+  not know
+- **THEN** the status is discarded, the connection stays up, and no
+  panic occurs
+
+#### Scenario: Statuses carry no message content
+
+- **WHEN** any `EventPutStatus` is serialized
+- **THEN** it contains only the event id and outcome — no channel, nick,
+  plaintext, or proof material
+
+### Requirement: Delivery-state exposure to applications
+
+The event graph SHALL expose a subscription that republishes every
+inbound `EventPutStatus` unfiltered, together with the peer channel it
+arrived on. The event graph itself SHALL NOT track outbound deliveries
+or make retry decisions; that policy belongs to applications.
+
+#### Scenario: Application observes replies for its own messages
+
+- **WHEN** a reply arrives for an event an application previously
+  broadcast
+- **THEN** the application's subscription delivers the reply and the
+  application can correlate it with the event id
+
+#### Scenario: Replies for relayed events are also published
+
+- **WHEN** a reply arrives for an event the node relayed (not
+  originated)
+- **THEN** the subscription still delivers it; consuming it is the
+  application's choice
+
+### Requirement: darkirc persists outbound records before broadcast
+
+When darkirc publishes a chat message event, it SHALL first persist an
+outbound record containing the event id, the message's `uid`, the
+plaintext message fields needed to rebuild it, a state, and an attempt
+counter. Records SHALL survive restart. Records SHALL be closed
+(removable) once a positive outcome is observed or the attempt cap is
+reached.
+
+#### Scenario: Restart does not lose pending sends
+
+- **WHEN** darkirc commits an outbound event locally, broadcasts it, and
+  the process restarts before any reply arrives
+- **THEN** after restart the outbound record is still present and the
+  delivery policy resumes evaluating it
+
+### Requirement: Rebroadcast-first delivery policy
+
+For an outbound record with no positive outcome, darkirc SHALL wait
+until it is DAG-synced and has at least a configured minimum number of
+peer connections, then rebroadcast the original event unchanged. It
+SHALL retry with backoff, bounded by a configured maximum number of
+rounds. A single `Has` outcome (either `inserted` value) SHALL close the
+record as delivered.
+
+#### Scenario: Reconnect triggers rebroadcast
+
+- **WHEN** a pending record exists, the node is synced, and the minimum
+  connection count is reached
+- **THEN** the original event is rebroadcast unchanged
+
+#### Scenario: Prior propagation is detected, not recreated
+
+- **WHEN** a rebroadcast reaches peers that already hold the event via
+  earlier propagation
+- **THEN** their `Has { inserted: false }` replies close the record and
+  no recreation happens
+
+### Requirement: Recreate on unrecoverable or rejected delivery
+
+darkirc SHALL create a replacement event from the stored plaintext —
+fresh timestamp and DAG parents, fresh encryption nonce, same `uid` —
+when any of:
+
+- a `Nack { reason: TooOld }` arrives (the rotation window has passed)
+- every observed reply across the retry rounds is a nack
+- zero replies are observed after the maximum number of rebroadcast
+  rounds
+
+Recreation SHALL consume a fresh rate-limit slot when rate limiting is
+enabled; if the epoch budget is exhausted the attempt SHALL be parked
+and retried after epoch rollover rather than dropped or sent unproven.
+Recreation attempts SHALL be capped; once the cap is reached the record
+SHALL be closed as failed and the failure surfaced to the client.
+
+#### Scenario: Rotation makes the original undeliverable
+
+- **WHEN** a pending record's event predates the current rotation window
+  and any peer nacks `TooOld`
+- **THEN** a replacement event with the same `uid` and fresh
+  parents/timestamp is published
+
+#### Scenario: Budget exhaustion parks, not drops
+
+- **WHEN** recreation is due but the rate-limit epoch budget is spent
+- **THEN** no unproven replacement is broadcast and the record waits for
+  the next epoch
+
+#### Scenario: Attempt cap surfaces failure
+
+- **WHEN** the configured recreation attempt cap is reached without any
+  positive outcome
+- **THEN** the record is closed as failed and the client is informed
+
+### Requirement: Message uid for recreate dedup
+
+The chat message payload SHALL carry a sender-generated `uid` that
+remains identical across recreations of the same message. Receiving
+clients SHALL treat messages with equal `uid` as one logical message for
+display purposes. Payload decoding SHALL be version-aware so peers that
+do not understand the new field still decode old payloads.
+
+#### Scenario: Recreated message does not double-render
+
+- **WHEN** a receiving client has already displayed the original message
+  and later receives its recreation
+- **THEN** the recreation is not rendered as a second message
+
+#### Scenario: Old payloads still decode
+
+- **WHEN** a client that understands `uid` receives a payload without
+  one
+- **THEN** the payload decodes via its version and renders normally
+
+### Requirement: App displays delivery state
+
+The app SHALL subscribe to delivery statuses for its own outbound
+messages and expose per-message delivery state keyed by `uid`:
+`sending` (no replies yet), `delivered` (any `Has` reply), `failed`
+(record closed at the attempt cap). Delivery state SHALL NOT be
+presented as evidence that any particular recipient read the message.
+
+#### Scenario: Tick on first acceptance
+
+- **WHEN** any peer replies `Has` for the app's outbound message
+- **THEN** the message's state becomes `delivered`
+
+#### Scenario: Failure is visible
+
+- **WHEN** a message's record closes as failed
+- **THEN** the app shows the message as failed rather than leaving it
+  indefinitely in `sending`

+ 98 - 0
openspec/changes/darkirc-delivery-status/tasks.md

@@ -0,0 +1,98 @@
+# Tasks: darkirc-delivery-status
+
+## 1. Wire compatibility spike (blocks everything)
+
+- [ ] 1.1 Verify unknown-message tolerance: using the event-graph test
+  harness (`src/event_graph/test_helpers.rs`), connect two nodes where
+  only one registers an `EventPutStatus` dispatch, send one from the
+  other, and confirm the receiving channel stays up (no stop/strike/
+  panic). Record the outcome in the change notes; if unknown ids
+  destabilize channels, stop and re-discuss gating before proceeding.
+  Verify via a new test in `src/event_graph/tests.rs`.
+
+## 2. Event-graph layer (`src/event_graph`)
+
+- [ ] 2.1 Define `EventPutStatus`/`EventPutResult`/`NackReason` in
+  `proto.rs` with explicit `u8` discriminants, `impl_p2p_message!` with
+  default metering, and register dispatch/subscription in
+  `ProtocolEventGraph::init`. Verify `make` builds and clippy is clean.
+- [ ] 2.2 Emit statuses from `handle_event_put` at every outcome per the
+  design table (NotSynced, Has{false} duplicate, TooOld, Invalid, Busy,
+  Has{true} inserted), unicast on the receiving channel; strike/flood
+  paths unchanged and silent. Verify each emission point with a
+  two-node test asserting the exact reply variant.
+- [ ] 2.3 Add `receipt_pub: Publisher<(EventPutStatus, ChannelPtr)>` and
+  `receipt_subscribe()` to `EventGraph`; republish every inbound status
+  unfiltered from the per-channel handler. Verify with a test that
+  receives both own-origin and relayed statuses on the subscription.
+- [ ] 2.4 Decode hardening: unknown `EventPutResult`/`NackReason`
+  variant or malformed body is dropped with a warning, no panic, no
+  strike, channel stays connected. Verify with a fuzz-style unit test
+  feeding truncated/random payloads through the decoder.
+
+## 3. darkirc outbound tracking (`bin/darkirc`)
+
+- [ ] 3.1 Add the `darkirc_outbound` kvdb tree and serializable record
+  (`uid`, event id, plaintext Privmsg fields, state, attempts, ts,
+  `superseded_by`), plus helpers to insert/close/update records. Verify
+  with round-trip serialization + tree unit tests in `server.rs`.
+- [ ] 3.2 Generate `uid` (16 bytes, `OsRng`) in the send path, and write
+  the outbound record in `publish_events` before `p2p.broadcast`; close
+  as Delivered on any `Has`. Verify with a unit test that a
+  crash-restart (reopen kvdb) still finds the record Pending.
+
+## 4. darkirc delivery monitor (`bin/darkirc`)
+
+- [ ] 4.1 Receipt aggregation task: subscribe `receipt_pub`, filter to
+  tracked ids, dedupe per (event id, channel address), update records.
+  Verify with a unit test driving synthetic statuses through the
+  aggregator.
+- [ ] 4.2 Sweep + rebroadcast: periodic sweep gated by
+  `is_synced() && connection_count >= K`, rebroadcasting the original
+  `EventPut` (event + blob from local DAG) with backoff up to `R_MAX`
+  rounds. Verify with a multi-node harness test that a peer which
+  already has the event answers `Has{inserted:false}` and the record
+  closes without recreation.
+- [ ] 4.3 Recreate: on `TooOld`, all-nack, or `R_MAX` exhaustion, build
+  a fresh event from stored plaintext (fresh nonce, same `uid`, fresh
+  parents/timestamp), park on RLN `BudgetExhausted`, cap attempts at
+  `A_MAX`, surface failure to the client on cap. Verify with harness
+  tests: rotation-forced recreate reuses the uid; budget exhaustion
+  parks (RLN test harness); attempt cap reaches Failed.
+- [ ] 4.4 End-to-end darkirc scenario test: node A publishes while
+  disconnected from B, A reconnects after DAG rotation, B must end up
+  holding a recreate-generation event with the same `uid`. Verify the
+  assertion holds in the multi-node harness.
+
+## 5. Privmsg uid wire change (`bin/darkirc/src/lib.rs`)
+
+- [ ] 5.1 Add `uid` behind `Privmsg.version = 1` with a version-matched
+  decoder (v0 decodes without uid). Verify with golden serialization
+  tests for both versions, including a v0 peer ignoring an undecodable
+  v1 payload without error propagation.
+- [ ] 5.2 Re-check sequencing against `darkirc-mod`'s rotating-content
+  tag byte (same struct): confirm merge order or combined encoding in
+  the change notes before merge. Verify by reviewing both deltas against
+  the final `Privmsg` wire format.
+
+## 6. App integration (`bin/app`)
+
+- [ ] 6.1 Wrap `handle_send` with uid generation, outbound records, and
+  the rebroadcast/recreate hooks (RLN-disabled path). Verify
+  `make compile-dev` in `bin/app` succeeds.
+- [ ] 6.2 Switch display dedup from ciphertext-hash `msg_id()` to `uid`
+  (synthetic id for legacy v0 messages) and add the `receipt_pub`
+  subscription mapping per-uid state to `sending`/`delivered`/`failed`
+  with UI notification. Verify with an app-side test or manual dev-run
+  showing state transitions and no double-render of a recreated message.
+
+## 7. Hardening and review gate
+
+- [ ] 7.1 Full workspace gates green: `make`, `make clippy`, `make test`
+  (proofs + contracts built first per AGENTS.md), `make fmt` in
+  `bin/app`; confirm no `unwrap`/`expect`/`panic!` on any new
+  attacker-controlled decode path and no peer addresses logged with
+  receipt state.
+- [ ] 7.2 Invoke `@anon-security-review` on the full diff; treat FAIL as
+  blocking and address findings before marking the change ready to
+  apply. Verify the review verdict is recorded in the change notes.