Kaynağa Gözat

openspec: add darkirc-mod and evgr-resume-bug proposals

darkfi 2 hafta önce
ebeveyn
işleme
a585da8e32

+ 2 - 0
openspec/changes/darkirc-mod/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-31

+ 342 - 0
openspec/changes/darkirc-mod/design.md

@@ -0,0 +1,342 @@
+## Context
+
+The event graph (`src/event_graph/`) runs two DAG families: rotating hourly
+DAGs carrying darkirc `Privmsg` traffic, and a single never-pruned static DAG
+whose admission path (`handle_static_put` → `rln_verify_static_event`)
+currently interprets every event's content as an RLN node when RLN is enabled,
+and accepts anything structurally valid when RLN is disabled
+(`commit_static_event_unverified`). The static DAG already syncs on every node,
+including the app (which runs RLN disabled). `Privmsg` has no signature fields;
+identity on the wire is limited to the (unauthenticated) nick. `irc2`
+(bin/darkirc, embedded by bin/app) has a working IRC-service precedent in
+NickServ, and `chanserv` is already a reserved nick in the relay path.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Carry channel metadata in the existing static DAG with a content-type
+  discrimination scheme that keeps RLN semantics byte-for-byte intact.
+- Deterministic, self-certifying channel ownership without consensus changes.
+- Client-side policy enforcement (hide, never delete) covering spam-driven
+  signal-to-noise loss; flooding stays RLN's problem.
+- Owner/admin flows usable from desktop darkirc via ChanServ; app is a policy
+  consumer with enable/disable toggles.
+
+**Non-Goals:**
+
+- Encrypted-channel and DM moderation (privacy design needed; follow-up).
+- User-installed policies (data model must not preclude them).
+- Any change to RLN circuits, proofs, identity tree semantics, or rate limits.
+- Server-side message rejection: unsigned/disallowed messages always
+  propagate; policy only affects local rendering.
+
+## Decisions
+
+### D1: One static DAG with tagged content, not a second EventGraph
+
+Application payloads ride the existing static DAG. Content is discriminated by
+its first byte:
+
+```
+static content first byte:
+  0x00 | 0x01  → RLN payload (existing RLNNode encoding, unchanged)
+  0x02..       → application payload tag (registry below)
+```
+
+Rationale: a second EventGraph instance would collide on the fixed p2p message
+type names and the hardcoded `"static-dag"` DAG name used by `static_sync`,
+requiring invasive protocol namespacing for no benefit. The static DAG's
+canonical `(layer, event_id)` ordering — which RLN historical-root consistency
+depends on — is content-agnostic, so interleaving app events does not disturb
+it. `rebuild_rln_state_from_static` skips app-tagged content deterministically.
+
+Collision note: `darkfi-serial` encodes enum variants as `u64` LE, so
+`RLNNode::Registration` starts `0x00` and `RLNNode::Slashing` starts `0x01`.
+Reserving exactly those two first bytes for RLN is therefore precise today;
+if `RLNNode` ever gains variants, its variant space and the app tag registry
+must be reconciled in the same change (assert this in a test). The RLNNode
+encoding itself is NOT changed: deployed static DAGs hold RLN events in this
+form and they must keep parsing.
+
+Version skew: pre-change nodes fail `RLNNode` deserialization on app-tagged
+content (variant index reads as garbage ≥ 2) and skip it without penalty; they
+do not relay it. Mixed-version networks are unsupported (see Migration Plan).
+
+### D2: Rotating-DAG content tag byte
+
+Darkirc rotating content gets a leading tag byte; relay paths dispatch on it
+instead of trial deserialization. This is a **hard break** of the chat wire
+format: there is no untagged form.
+
+```
+0x00  Privmsg     (existing fields + optional signer pk + schnorr signature)
+0x01  HideAction
+0x02+ reserved for future types; unknown tags are skipped, not errors
+```
+
+The tag byte is the sole content-type discriminator; the Privmsg struct keeps
+its internal fields (`version`, `msg_type`, channel, nick, msg) unchanged and
+the signature fields are part of the Privmsg payload itself.
+
+The tag identifies content **type only**. It must not distinguish
+encrypted-channel from encrypted-DM payloads: that distinction is metadata an
+observer of the public DAG must not get for free. Plaintext vs encrypted is
+already self-evident on the wire (plaintext is readable), so a plaintext tag
+leaks nothing new; the encrypted forms share one path and resolution between
+channel-key and DM-key decryption remains a local trial.
+
+### D3: Per-channel owner chains for ordered state
+
+The static DAG provides replication but no total order. Ordering comes from a
+self-certifying per-channel chain:
+
+```
+REGISTER #chan {owner_pk}         prev: ∅
+  └─► POLICY LIST [...]           prev: <prev static event id>   sig: owner_pk
+        └─► TRANSFER {pk_new}     prev: ...                      sig: owner_pk
+              └─► POLICY / PIN    prev: ...                      sig: pk_new
+```
+
+- Every action names the previous action's **static DAG event id** and is
+  signed by the current owner key (schnorr, pallas).
+- Resolution: winning registration (first in canonical order among valid
+  registrations of the same name) + longest chain of validly linked, validly
+  signed actions; canonical `(layer, event_id)` order as tie-break.
+- Signature covers channel name, action payload, and prev link — so a valid
+  signature also proves chain position intent.
+
+Alternatives considered: last-writer-wins by DAG order (racy — two nodes see
+different orders and flip-flop); side-events for owner ops (unnecessary — owner
+ops are rare, and chains give transfer-of-authority for free).
+
+### D4: Definitions in the static chain, application in the rotating DAG
+
+- **Static chain actions**: `Register`, `Transfer`, `PolicyList`, `Pin`.
+  Authorization data (owner key, admin sets, allow-lists, filter params,
+  default-enabled flags) must be durable and ordered → chain.
+- **Rotating events**: `HideAction { channel, target_event_id, hidden, actor_pk,
+  sig }`. Hides are frequent, need no ordering beyond last-wins-per-target in
+  canonical rotating order, and gain two properties by expiring with the
+  window: no permanent public record that an event existed/was hidden, and no
+  static-DAG churn. The chain's admin set authorizes them; an action signed by
+  a key outside the current (enabled) admin set resolves to nothing.
+
+Hide tombstones reference the **DAG event id** (stable, node-independent,
+available pre-decryption), not the client-side content msg_id (timestamp-
+correction hacks make it unstable). The hide check runs in the relay path where
+both ids are in hand, before msg_id conversion.
+
+### D5: Policy model — hardcoded u8 enum, opaque params
+
+```
+u8   ChanServ name   params
+0    ALLOWLIST       Vec<pk>   render only messages with valid sig from set
+1    ADMINHIDE       Vec<pk>   keys may publish HideActions
+2    FILTER          rules     regexes over the privmsg (nick and/or msg)
+```
+
+The wire format carries the `u8`; the ChanServ command surface accepts the
+policy name (`ALLOWLIST`, `ADMINHIDE`, `FILTER`) and parses it to the enum
+value, rejecting unknown names.
+
+A `PolicyList` chain action replaces the whole list wholesale; each entry is
+`{policy: u8, params: Vec<u8>, enabled: bool}`. Params are opaque bytes owned
+by the built-in evaluator for that id; unknown ids are ignored at resolution
+(forward compatibility, also the seam where user-installed policies land
+later). The enum numbering is frozen at merge; adding policies appends.
+
+`FILTER` evaluation happens on the decoded plaintext privmsg (after
+channel/DM decryption for encrypted targets — same local trial as rendering).
+Regex compilation from policy params is fallible: an uncompilable rule is
+ignored and the remaining rules still apply (untrusted-input invariant: policy
+params are owner-controlled but owners can be adversarial). The evaluator
+uses a linear-time regex engine (Rust `regex` crate: finite-automata, no
+catastrophic backtracking) so hostile patterns cannot wedge a client.
+Note: `regex` is already a `bin/app` dependency but is a new dependency for
+`irc2` (`bin/darkirc/Cargo.toml`) — dependency addition requires human review
+per repo policy.
+
+### D6: Privmsg signing
+
+The Privmsg payload keeps its existing field order and appends optional
+`signer_pk` + `sig`. The signature is over the serialized core fields
+(`version | msg_type | channel | nick | msg`), computed on plaintext before
+any channel/DM encryption. For encrypted targets the sig fields are encrypted
+along with the rest (nothing visible outside); for plaintext channels they are
+public. Signatures do not cover the header timestamp — client-side timestamp
+correction must not break verification, and content replays under a new event
+id are indistinguishable from quotes, which IRC legitimately allows.
+
+Posting in an allow-listed channel is **opt-in linkability**: the policy only
+makes sense where users accept a persistent per-channel signing key. This is
+stated UX, not a hidden cost.
+
+### D7: Pins snapshot and re-encrypt
+
+`Pin { target_event_id, snapshot }` is an owner chain action. The snapshot
+embeds the message content (nick, msg, original timestamp) because the pinned
+event expires from the rotating window after 24h. For saltbox channels the
+snapshot is encrypted under the channel key — the static DAG must never carry
+plaintext of an encrypted channel. FUD icon links etc. are future chain action
+kinds; the action enum is extensible.
+
+### D8: Static app-event admission bounds and sync
+
+- Admission: existing structural checks + a content size bound (order of a few
+  KiB, exact constant at implementation) for app-tagged static events, applied
+  identically in both RLN modes. `handle_static_put`'s existing moving-window
+  flood guard covers the live path.
+- `static_sync` currently requires a non-empty blob per non-genesis event (for
+  RLN proof re-verification). App-tagged events carry no proof: the blob
+  alignment check must treat empty blobs as valid for app tags. Structural
+  checks (parents present, content-hash match, size bound) are re-applied at
+  sync; signature/chain validity is resolved above the sync layer.
+- Everything parsing untrusted bytes stays fallible (no unwraps); malformed
+  app events are skipped, never fatal.
+
+### D9: ChanServ
+
+Mirrors NickServ: NOTICE replies, HELP texts, command dispatch from
+`handle_cmd_privmsg` on the reserved `ChanServ` nick. Authentication is
+possession of the locally configured key: ChanServ signs with the config's
+owner (or admin) key and refuses commands whose matching key is absent.
+
+```
+/msg ChanServ REGISTER #channel
+/msg ChanServ INFO [#channel]          owner, chain tip, policy list, pins
+/msg ChanServ TRANSFER #channel <pk>
+/msg ChanServ POLICY #channel LIST
+/msg ChanServ POLICY #channel SET <name> <params>     e.g. SET ADMINHIDE <pk1>,<pk2>
+/msg ChanServ POLICY #channel DEFAULT <name> ON|OFF
+/msg ChanServ PIN #channel <event_id>
+/msg ChanServ UNPIN #channel <event_id>
+/msg ChanServ HIDE #channel <event_id>                (admin key)
+/msg ChanServ UNHIDE #channel <event_id>              (admin key)
+/msg ChanServ HELP [command]
+```
+
+Usage examples with real values, one per policy type:
+
+```
+--- ALLOWLIST (policy 0): only signed messages from these keys render ---
+
+/msg ChanServ POLICY #darkfi SET ALLOWLIST 9mkH5rwnYtV4JCvfH2N7yc6bT1eSQkWLDpGXzKR8uFq3,7dRm2cVbXwNZtPjLKe84TghY6FqsaU1JzCNoEWBkv5Py
+/msg ChanServ POLICY #darkfi DEFAULT ALLOWLIST ON
+   → the channel now defaults to rendering only messages signed by
+     one of the two listed keys; senders attach their pk + signature
+
+--- ADMINHIDE (policy 1): these keys may hide/unhide messages ---
+
+/msg ChanServ POLICY #darkfi SET ADMINHIDE FgYU9dV1K2vQmTHeqXPnWZuLycA4sBDr7EJk6atMZiWo
+/msg ChanServ HIDE #darkfi 9a41c7e0d3b8f6521e0a4cd7b83f19e2c5a6d0b8f3e2714c9d5a8b6e3f0c2d17
+   → the admin-key holder hides the spam event; clients mark it hidden
+/msg ChanServ UNHIDE #darkfi 9a41c7e0d3b8f6521e0a4cd7b83f19e2c5a6d0b8f3e2714c9d5a8b6e3f0c2d17
+   → restores it if hidden in error
+
+--- FILTER (policy 2): rules matching nick and/or message content ---
+
+/msg ChanServ POLICY #darkfi SET FILTER nick:^spam\w*$,nick:^ninja\d+$,msg:(?i)(airdrop|free coins|giveaway)
+/msg ChanServ POLICY #darkfi DEFAULT FILTER ON
+   → each rule is field:regex, comma-separated; nick rules match the
+     sender nick, msg rules match the message body; matching messages
+     are hidden for clients applying the policy
+```
+
+Public keys are base58-encoded pallas points (schnorr public keys); event ids
+are the blake3 DAG event ids shown by `INFO` or by the future app
+context-action. `HIDE`/`UNHIDE` take a pasted event id for now; the app later
+adds a context-menu action emitting the same underlying flow.
+
+### D10: Key provisioning
+
+Schnorr keypairs (pallas) configured as secrets in darkirc TOML (desktop) and
+the app settings store; public keys derived, never configured separately.
+Secrets are used only for local signing and are never logged or transmitted
+(hard invariant: no secret leakage).
+
+### D11: App integration — clickable channel label and policy overlay
+
+In the app, entering a channel from the menu shows the chat screen with the
+channel name (e.g. `#dev`) as a label at the top. That channel-name label
+becomes tappable and opens the per-channel policy overlay:
+
+```
+chat screen                          overlay (modal layer)
+┌───────────────────────────┐        ┌────────────────────────────────┐
+│ [#dev]  ← channel label   │   tap  │ #dev — channel policy         │
+│───────────────────────────│  ───►  │ ──────────────────────────────│
+│ 12:01 <alice> hey all     │        │ [x] AllowList      (default)  │
+│ 12:02 <bob>  ...          │        │ [x] AdminHide      (default)  │
+│        ...                │        │ [ ] FILTER         (default)  │
+└───────────────────────────┘        │  overridden locally: ON       │
+                                     └────────────────────────────────┘
+```
+
+What changes in the app:
+
+- **Channel-label tap target** (`bin/app/src/app/schema/chat.rs`): the chat
+  screen already places the channel-name label at fixed coordinates
+  (`CHANNEL_LABEL_X`/`CHANNEL_LABEL_Y` and friends). The tap target is a
+  normal button node placed on top of that existing label — same button
+  pattern the chat screen already uses for send/emoji buttons — whose
+  activation opens the policy overlay for the displayed channel. No changes
+  to the chatview text rendering or hit-testing are required.
+- **Policy overlay**: a new overlay scene node (following the app's existing
+  overlay/layer patterns) fed by the resolved channel policy state from the
+  plugin cache (task group 6). It lists the owner's current default policy
+  list — one row per policy with the default state — plus toggle switches.
+  Toggling writes/updates the user's override row in the local table and
+  triggers a re-filter of that channel's buffer; because hidden messages are
+  marked rather than dropped (D4), re-filtering is a pure view update with no
+  refetch. Rows reflect `default || override` resolution: the overlay shows
+  both the owner default and the user's effective state.
+- **Unregistered channels**: a channel with no resolved registration shows an
+  empty/ informational overlay ("no owner policy"), leaving room for future
+  user-local policy rows in the same table.
+- This is deliberately a latter stage (after the plugin cache, override
+  table, and evaluators land) so the overlay only wires together existing
+  resolved state.
+
+## Risks / Trade-offs
+
+- [Static app-event flooding: no proof gates publication, and the static DAG
+  is never pruned] → size bound + existing moving-window peer flood guard;
+  resolution cost of garbage is one signature check; monitor growth. If it
+  becomes abuse, gate app static events behind stake/registration in a future
+  change.
+- [Touching the RLN admission path (`handle_static_put`, `static_sync`,
+  rebuild) is security-critical] → RLN branches stay byte-identical; new code
+  is additive dispatch on first byte; dedicated tests assert RLN behavior
+  unchanged; human review on the diff (per repo policy).
+- [Mixed-version networks: pre-change nodes cannot decode tagged rotating
+  content and skip app static payloads] → unsupported state by decision: the
+  chat wire format is a hard break and all darkirc nodes upgrade together;
+  rotating content self-drains within one rotation window (≤24h); static RLN
+  encoding is unchanged so RLN state survives the upgrade untouched.
+- [Owner key loss bricks the channel chain] → inherent to key-based ownership;
+  documented; transfer is the only recovery path while the key exists.
+- [Hide actions are censorable by policy-off clients — moderation is advisory]
+  → by design: policy is local preference, not network consensus.
+- [Signing into allow-listed channels is linkable across messages] → explicit
+  UX tradeoff (D6), per-channel keys mitigate.
+
+## Migration Plan
+
+Hard break of the darkirc chat wire format; no compat shims and no legacy
+untagged Privmsg. Deploy: all darkirc nodes (desktop + app) upgrade in one
+coordinated step. Existing untagged messages in rotating DAGs become
+unreadable but age out permanently within one rotation window (≤24h) — no
+data migration, the network self-cleans. Static-DAG RLN history remains valid
+(the RLNNode encoding is untouched); application static payloads are new and
+only exist post-upgrade. Rollback = redeploy pre-change binaries and let the
+window drain again. After merge, run `make` (proofs/contracts unaffected) and
+`make test`.
+
+## Open Questions
+
+- Exact static app-content size bound (few KiB; fix during implementation).
+- Whether `UNHIDE` is a separate command or `HIDE` with a flag (command
+  surface only; resolution semantics already fixed as last-wins-per-target).
+- App-side representation of the resolved-policy cache (in-memory only vs
+  persisted); does not affect wire or resolution semantics.

+ 85 - 0
openspec/changes/darkirc-mod/proposal.md

@@ -0,0 +1,85 @@
+## Why
+
+DarkIRC (and the chat in `app`, which embeds the `irc2` stack) has no moderation:
+any RLN-rate-limited message renders for everyone, so signal-to-noise in public
+channels depends entirely on posters' good behavior. Moderation today would
+require either a central operator or protocol changes rushed into the chat path.
+The event graph already provides the right primitives — a replicated, never-
+pruned static DAG (currently RLN-only) and rotating chat DAGs — so we can add
+owner-designated, client-enforced channel policy without a central party and
+without touching anonymity: policy targets signal-to-noise, not traffic (flooding
+remains RLN's job).
+
+## What Changes
+
+- **Static DAG app payloads**: static-DAG admission learns a content tag byte
+  that separates RLN payloads from application payloads. DarkIRC channel
+  metadata (registration, ownership, default policy list, pins) rides the
+  existing static DAG. No second EventGraph instance is introduced.
+- **Content type tag byte on rotating DAG events**: darkirc rotating content
+  is discriminated by a leading tag byte (Privmsg, hide action) instead of
+  trial deserialization. **BREAKING**: this is a hard break of the chat wire
+  format — there is no untagged legacy form; all darkirc nodes must upgrade
+  together. Existing messages age out within one 24h rotation window and the
+  network self-cleans.
+- **Channel registry with owner chains**: each public (`#`) channel can be
+  registered in the static DAG with an owner public key. Owner actions
+  (transfer ownership, set default policy list, pin messages) form a
+  prev-linked, owner-signed chain; resolution is deterministic (longest valid
+  chain, canonical tie-break).
+- **Policy model**: a hardcoded `u8` policy enum (`AllowList`, `AdminHide`,
+  `Filter`, …) with named values on the ChanServ command surface
+  (`ALLOWLIST`, `ADMINHIDE`, `FILTER`). The owner publishes the channel's
+  default policy list with per-policy enable flags and opaque params. Users
+  override defaults locally.
+- **Hide actions in the rotating DAG**: admins (keys named by the `AdminHide`
+  policy) publish signed hide/unhide actions as rotating events referencing a
+  target event id. Hidden messages are marked hidden in clients, never removed
+  from the DAG. Tombstones expire with their targets (same rotation window).
+- **ChanServ** in `irc2`: `/msg ChanServ REGISTER|INFO|TRANSFER|POLICY|PIN|HIDE|
+  UNHIDE|HELP` for channel owners and admins. Works in desktop darkirc; the app
+  does not expose owner flows in this version.
+- **Owner/admin signing keys in config**: schnorr keypairs configured in
+  darkirc TOML (desktop) / app settings store (app).
+- **App policy UI**: the channel-name label at the top of the chat screen
+  (e.g. `#dev`) becomes tappable via a button placed over the existing label;
+  it opens a per-channel overlay listing the owner's current default policy
+  set, where the user can toggle each policy off/on locally. Overrides are
+  stored as rows in the app's local table. No user-installed policies in this
+  version.
+- **Pins**: owner-signed static events embedding a snapshot of the pinned
+  message; snapshots for encrypted channels are re-encrypted under the channel
+  saltbox. (Public channels only in this version, but snapshots are encrypted
+  wherever the source channel is encrypted so the mechanism is safe by default.)
+
+## Capabilities
+
+### New Capabilities
+
+- `event-graph/app-payloads`: Tagged application payloads in the static DAG and
+  content-type tag bytes on darkirc rotating events; admission, dispatch, and
+  unknown-content handling for both DAG kinds.
+- `chat-moderation`: Channel registration and owner chains, the hardcoded
+  policy enum and default policy lists, rotating-DAG hide actions, ChanServ
+  commands, and client-side policy application (desktop + app UI toggles).
+
+### Modified Capabilities
+
+(none — no existing specs)
+
+## Impact
+
+- `src/event_graph/` — static-DAG admission (`handle_static_put`), `static_sync`
+  re-verification, RLN state rebuild must skip app payloads; touches the RLN
+  admission path (security-critical zone; needs review).
+- `bin/darkirc/` (`irc2`) — Privmsg wire format (tagged content, optional
+  signature fields), content-tag dispatch in the relay path, ChanServ service,
+  config keys for signing keypairs, static-event broadcast for owner chains.
+- `bin/app/` — `plugin/darkirc.rs` relay (tag dispatch, hidden marking),
+  policy cache, per-channel policy toggle UI, override rows in the local table,
+  owner-key storage in the settings store; the chat screen's channel-name
+  label gets a button overlay opening the policy overlay.
+- Wire compatibility: hard break — mixed-version networks are unsupported;
+  rotating content drains within one rotation window; static RLN encoding is
+  unchanged so RLN state is unaffected.
+- No changes to RLN circuits, proofs, or rate-limiting semantics.

+ 237 - 0
openspec/changes/darkirc-mod/specs/chat-moderation/spec.md

@@ -0,0 +1,237 @@
+## Purpose
+
+Defines anonymous-communication-friendly, client-enforced moderation for
+darkirc public channels: channel registration and owner-signed action chains
+in the static DAG, a hardcoded policy model with owner-set defaults and local
+user overrides, admin hide actions in the rotating DAG, and the ChanServ
+command interface used by channel owners and admins.
+
+## ADDED Requirements
+
+### Requirement: Public channel registration
+
+A user SHALL be able to register a public `#` channel by publishing a
+registration event to the static DAG that names the channel and embeds an
+owner public key. Registration is first-come-first-served: when two valid
+registrations for the same name exist, every node MUST resolve the same winner
+using deterministic canonical ordering of the static DAG. Only public
+channels are supported in this version; channels requiring decryption
+(saltbox channels and direct messages) are out of scope.
+
+#### Scenario: Register a channel
+- **WHEN** a user publishes a signed registration for a channel that has no
+  valid registration
+- **THEN** every synced node resolves that user's owner key as the channel
+  owner
+
+#### Scenario: Registration race resolves identically everywhere
+- **WHEN** two registrations for the same channel name are published before
+  either node sees the other
+- **THEN** all nodes that eventually hold both events pick the same
+  registration as authoritative
+
+### Requirement: Owner action chain
+
+Ownership-changing and policy-defining actions SHALL form a per-channel chain:
+each action names the previous action's static event id and is signed by the
+current owner key. Resolution MUST yield exactly one authoritative chain per
+channel: the longest chain of correctly linked, correctly signed actions
+starting from the winning registration, with deterministic tie-breaking. A
+transfer of ownership makes subsequent actions valid only under the new key.
+
+#### Scenario: Transfer changes signing authority
+- **WHEN** an owner publishes a transfer to a new key and the new key later
+  signs a policy update
+- **THEN** all nodes accept the policy update and reject any further action
+  signed by the old key
+
+#### Scenario: Invalid signature cannot extend the chain
+- **WHEN** an action is signed by a key that is not the current owner
+- **THEN** the action is ignored during resolution and the previous chain tip
+  remains authoritative
+
+#### Scenario: Competing chains resolve deterministically
+- **WHEN** two valid chains exist for one channel (e.g. after key compromise)
+- **THEN** every node selects the same chain by longest-valid-chain rule with
+  canonical ordering as tie-break
+
+### Requirement: Hardcoded policy enum and default policy list
+
+Policies SHALL be identified by a `u8` enum from a hardcoded, shared registry
+(initially: posting allow-list, admin hide set, regex filter). The owner
+SHALL be able to publish, as a chain action, the channel's default policy
+list: for each policy, its id, opaque parameters, and a default enabled flag.
+The serialized format MUST allow future policy ids and parameter shapes
+without changing stored history.
+
+#### Scenario: Publish a default policy list
+- **WHEN** an owner publishes a policy list naming the admin hide policy with
+  a set of admin public keys, enabled by default
+- **THEN** synced nodes resolving the channel expose that policy with those
+  parameters as the channel default
+
+#### Scenario: Unknown policy id in a list
+- **WHEN** a resolved policy list contains an id the client does not know
+- **THEN** the client ignores that policy entry without failing resolution of
+  the rest of the list
+
+### Requirement: Admin hide actions in the rotating DAG
+
+A key named in an enabled admin hide policy's parameters SHALL be able to
+publish signed hide or unhide actions as rotating-DAG events referencing the
+event id of a target message. Clients MUST mark matching messages as hidden
+rather than removing them from storage; hidden state MUST be reversible in
+the UI (e.g. reveal control). Resolution MUST apply the last action per
+target in canonical rotating-DAG order across the retained window. Hide
+actions expire with the rotation window exactly as messages do; no permanent
+record of a hide is created.
+
+#### Scenario: Hide is applied on other nodes
+- **WHEN** an admin publishes a hide action for a message in a channel a user
+  has joined
+- **THEN** the user's client marks that message hidden and indicates that
+  hidden messages exist
+
+#### Scenario: Unhide reverses a hide
+- **WHEN** a later action by an authorized key unhides the same target
+- **THEN** the message is shown again for clients applying policy
+
+#### Scenario: Unauthorized hide ignored
+- **WHEN** a hide action is signed by a key not in the channel's admin hide
+  set, or the admin hide policy is disabled
+- **THEN** clients ignore the action
+
+### Requirement: Posting allow-list policy
+
+When the posting allow-list policy is enabled for a channel, clients SHALL
+render only messages that carry a valid signature by a key in the policy's
+parameter set. Compliant senders in such channels attach their signer public
+key and signature to the message. Enforcement is client-side: unsigned
+messages still propagate on the network but are hidden for clients applying
+the policy.
+
+#### Scenario: Signed message renders
+- **WHEN** a message in an allow-listed channel carries a valid signature
+  from an allowed key
+- **THEN** clients applying the policy render it
+
+#### Scenario: Unsigned message hidden
+- **WHEN** a message in an allow-listed channel lacks a signature or is
+  signed by a key outside the set
+- **THEN** clients applying the policy hide it
+
+### Requirement: Regex filter policy
+
+When the regex filter policy is enabled, clients SHALL hide messages whose
+decoded privmsg matches the filter's parameter rules. Rules are regular
+expressions evaluated against the privmsg, and MAY match the nick and/or the
+message content. Filter parameters are opaque to the wire format; invalid or
+non-compilable rules MUST be ignored during resolution without failing
+evaluation of the remaining rules.
+
+#### Scenario: Matching message hidden
+- **WHEN** a message whose nick or content matches an enabled filter's regex
+  rules arrives in a joined channel
+- **THEN** clients applying the policy hide it
+
+#### Scenario: Invalid rule ignored
+- **WHEN** a filter's parameters contain a rule that fails to compile
+- **THEN** clients skip that rule and evaluate the remaining rules
+
+#### Scenario: Encrypted messages filtered after decryption
+- **WHEN** an encrypted message matching the rules is decrypted locally
+- **THEN** the filter applies to the decoded plaintext identically to
+  plaintext-channel messages
+
+### Requirement: Local policy overrides
+
+A user SHALL be able to override the enabled flag of any policy in a
+channel's default list locally, per channel, without publishing anything.
+The initial app UI exposes enable/disable of provided policies only; adding
+user-defined policies is out of scope for this version.
+
+#### Scenario: Override a default
+- **WHEN** a channel's policy is enabled by owner default and the user
+  disables it locally
+- **THEN** that user's client applies the policy as disabled while other
+  users remain unaffected
+
+#### Scenario: Policy overlay from the channel label
+- **WHEN** the user taps the channel-name label shown at the top of a
+  channel's chat screen
+- **THEN** the app opens an overlay for that channel listing the owner's
+  current default policies with their default states, and toggling a policy
+  in the overlay changes the user's local override and re-filters the
+  channel view accordingly
+
+### Requirement: Owner-signed pins with encrypted snapshots
+
+A channel owner SHALL be able to pin a message by publishing a chain action
+that embeds a snapshot of the message content and references its event id.
+For a channel with a shared key, the snapshot MUST be encrypted under that
+channel key so the static DAG never carries its plaintext. Pins MUST remain
+renderable after the original message has rotated out of the window.
+
+#### Scenario: Pin outlives rotation
+- **WHEN** a pinned message's DAG window has expired
+- **THEN** clients can still render the pinned snapshot from the static DAG
+
+#### Scenario: Encrypted channel pin carries no plaintext
+- **WHEN** an owner pins a message in a saltbox-encrypted channel
+- **THEN** the static-DAG event contains only ciphertext decryptable by
+  channel members
+
+### Requirement: ChanServ command interface
+
+The `irc2` stack SHALL provide a ChanServ service addressed by IRC private
+message, mirroring the existing NickServ pattern, with commands for
+registration, info, ownership transfer, policy management, pinning, and
+hiding. Policy commands SHALL address policies by their registry name (e.g.
+`FILTER`), parsed to the hardcoded policy id; unknown names are rejected.
+Owner and admin actions MUST be authenticated by verifying the
+signing key configured locally; the service MUST refuse actions for which no
+matching key is configured.
+
+#### Scenario: Register via ChanServ
+- **WHEN** a user with an owner key configured sends the registration command
+  for an unregistered channel
+- **THEN** the registration event is signed with that key and published to
+  the static DAG
+
+#### Scenario: Policy addressed by name
+- **WHEN** an owner issues a policy command using a policy's name (e.g.
+  `FILTER`) and valid parameters
+- **THEN** the command applies to that policy's entry in the channel's
+  default list
+
+#### Scenario: Unknown policy name rejected
+- **WHEN** a policy command names a policy the client does not know
+- **THEN** ChanServ replies with an error and the policy list is unchanged
+
+#### Scenario: Action without key refused
+- **WHEN** a user issues an owner or admin command without the corresponding
+  key configured
+- **THEN** ChanServ replies with an error and publishes nothing
+
+### Requirement: Signing keys are node-local secrets
+
+Owner and admin schnorr keypairs SHALL be provisioned in node configuration
+(desktop TOML config; app settings store) and MUST NOT be transmitted or
+published; only public keys appear in DAG events. Signatures apply to action
+and message content only.
+
+#### Scenario: Secret never leaves the node
+- **WHEN** any owner or admin action is published
+- **THEN** the corresponding event contains only the public key and signature
+
+### Requirement: RLN semantics unchanged
+
+Moderation MUST NOT alter RLN rate-limiting or anonymity: hide actions and
+signed messages on RLN-enabled networks are ordinary rotating events subject
+to the same proof requirements as chat messages, and no policy data links an
+RLN identity to a signing key.
+
+#### Scenario: Hide action is rate-limited
+- **WHEN** an admin publishes a hide action on an RLN-enabled network
+- **THEN** the event is admitted under the same RLN proof rules as any other
+  rotating event

+ 109 - 0
openspec/changes/darkirc-mod/specs/event-graph/app-payloads/spec.md

@@ -0,0 +1,109 @@
+## Purpose
+
+Defines how the event graph carries application-defined payloads: a content
+type tag byte that separates RLN payloads from application payloads in
+the static DAG, and a leading tag byte that discriminates darkirc rotating-DAG
+content types without trial deserialization, while leaving RLN semantics
+unchanged.
+
+## ADDED Requirements
+
+### Requirement: Static-DAG content discrimination by tag byte
+
+Static-DAG event content SHALL be discriminated by its first byte. Content
+whose encoding begins with an RLN payload (first byte `0x00` or `0x01`,
+matching the two RLN node variant encodings) MUST be processed by the existing
+RLN admission pipeline with unchanged semantics. Any other first byte identifies
+an application payload.
+
+#### Scenario: RLN registration still admitted
+- **WHEN** a node receives a static event containing an RLN registration or
+  slash in the existing encoding
+- **THEN** the event is verified, committed, and relayed exactly as before this
+  change, and the RLN identity state is updated accordingly
+
+#### Scenario: Application payload never touches RLN state
+- **WHEN** a node receives a static event whose content tag identifies an
+  application payload
+- **THEN** the event is admitted or rejected by application-payload rules and
+  the RLN identity tree, identity state, and historical roots are not modified
+
+### Requirement: Application payload admission to the static DAG
+
+A static event carrying an application payload SHALL be admitted when it passes
+the existing structural validation for static events (non-empty content,
+content hash matching the header, well-formed parents, parents present in the
+static DAG) and its content length is within a defined bound. Admission MUST
+NOT depend on RLN being enabled or disabled: both modes apply the same
+structural rules. Semantic validity (e.g. signatures, chain links) is resolved
+by the consuming application after admission, not at admission time.
+
+#### Scenario: Admitted on an RLN-enabled node
+- **WHEN** a structurally valid, correctly tagged application payload arrives
+  at a node with RLN enabled
+- **THEN** the event is stored in the static DAG and relayed to peers
+
+#### Scenario: Admitted on an RLN-disabled node
+- **WHEN** the same payload arrives at a node with RLN disabled
+- **THEN** the event is stored and relayed under the same structural rules
+
+#### Scenario: Oversized payload rejected
+- **WHEN** an application payload exceeds the defined content bound
+- **THEN** the event is rejected and not relayed
+
+### Requirement: RLN state rebuild skips application payloads
+
+Any rebuild or audit of RLN state from persisted static-DAG events MUST skip
+application payloads deterministically, producing the same identity tree as a
+node that never saw them.
+
+#### Scenario: Rebuild after mixed history
+- **WHEN** a node rebuilds RLN state from a static DAG containing both RLN
+  events and application payloads
+- **THEN** the resulting identity tree and historical roots are identical to a
+  rebuild from the RLN events alone
+
+### Requirement: Unknown or malformed content is skipped without penalty
+
+Content that a node cannot interpret — an unknown rotating tag, or an
+application payload that fails structural parsing — MUST be skipped without
+striking, banning, or crashing, and without affecting other events in the
+same batch or relay path.
+
+#### Scenario: Unknown rotating tag skipped
+- **WHEN** a rotating event's first byte is a tag the client does not know
+- **THEN** the event is skipped and the peer suffers no penalty
+
+#### Scenario: Malformed application static payload skipped
+- **WHEN** an app-tagged static event fails structural parsing
+- **THEN** the event is skipped and the peer suffers no penalty
+
+### Requirement: Rotating-DAG darkirc content tag byte
+
+All darkirc content in rotating DAGs SHALL begin with a tag byte identifying
+the content type; no untagged form exists. The tag set MUST include Privmsg
+(with optional signer key and signature fields) and hide action. Relay paths
+MUST dispatch on the tag byte instead of attempting deserialization of each
+known type in turn.
+
+#### Scenario: Dispatch by tag
+- **WHEN** a client relays a rotating event whose first byte is a known tag
+- **THEN** the content is decoded as the type named by the tag without
+  attempting other decodings
+
+#### Scenario: Unknown tag skipped
+- **WHEN** a rotating event's first byte is a tag the client does not know
+- **THEN** the event is skipped without error propagation
+
+### Requirement: Tag byte must not leak encryption target
+
+The tag byte MUST NOT distinguish an encrypted channel message from an
+encrypted direct message. Identifying the encryption target of an encrypted
+payload remains a local decryption attempt, so observers of the DAG learn only
+the content type, never the message category.
+
+#### Scenario: Encrypted payloads share one tag
+- **WHEN** two encrypted messages are published, one to a channel and one as a
+  direct message
+- **THEN** their event content begins with the same tag byte and an observer
+  cannot distinguish their category from the DAG

+ 137 - 0
openspec/changes/darkirc-mod/tasks.md

@@ -0,0 +1,137 @@
+## 1. Static-DAG app payload plumbing (event_graph)
+
+- [ ] 1.1 Define the static content tag registry (first byte `0x00`/`0x01` =
+      RLN payloads, `0x02..` = app payloads) and a dispatch helper in
+      `src/event_graph/`, and add a unit test asserting `RLNNode` encodings
+      only ever produce first bytes `0x00`/`0x01` (guards the tag collision
+      contract in design D1)
+- [ ] 1.2 Extend `handle_static_put` to admit app-tagged events via the same
+      structural checks plus a content size bound, in both RLN modes, relaying
+      them onward; verify with unit tests covering admit (both modes),
+      oversize reject, malformed skip, and that RLN-event handling is
+      unchanged (existing RLN tests stay green)
+- [ ] 1.3 Relax `static_sync`/`EventRep` blob alignment so app-tagged static
+      events may carry empty blobs, re-applying structural checks at sync;
+      verify with a two-node sync test where node B pulls an app-tagged event
+      from node A
+- [ ] 1.4 Make RLN state rebuild/audit (`rebuild_rln_state_from_static` and
+      the startup audit) skip app-tagged events deterministically; verify via
+      a test that rebuilds over mixed RLN+app history and asserts an identity
+      tree identical to the RLN-only rebuild
+
+## 2. Channel chain types and resolver (irc2)
+
+- [ ] 2.1 Define `ChannelAction` (`Register`, `Transfer`, `PolicyList`, `Pin`,
+      `Unpin`) with prev-link to the previous action's static event id and
+      schnorr signature over channel+payload+prev, plus the `PolicyId` u8 enum
+      and `{policy, params, enabled}` entries; include the ChanServ name
+      mapping (`ALLOWLIST`/`ADMINHIDE`/`FILTER` ↔ 0/1/2) with unknown-name
+      rejection; verify serialization round-trip and name-parse unit tests
+      pass
+- [ ] 2.2 Implement the chain resolver (winning registration by canonical
+      order, longest valid chain, canonical tie-break, transfer rekeys,
+      invalid-signature links ignored); verify with unit tests: register,
+      transfer-then-policy, stale-owner rejection, competing chains,
+      registration race, garbage events ignored
+- [ ] 2.3 Add a resolved-channel-state cache in `irc2` fed by a
+      `static_pub` subscription, exposing per-channel owner/policy/pins;
+      verify with a test that publishes chain events into a test EventGraph
+      and asserts the cache converges to the resolver output
+
+## 3. ChanServ and signing keys (desktop darkirc)
+
+- [ ] 3.1 Add schnorr owner/admin keypair config parsing to darkirc TOML
+      settings (secret base58; public derived), refusing invalid input with a
+      clear error; verify settings unit tests including `--gen` style keypair
+      generation output if following the existing chacha keypair precedent
+- [ ] 3.2 Implement ChanServ service (`REGISTER`, `INFO`, `TRANSFER`,
+      `POLICY LIST|SET|DEFAULT`, `PIN`, `UNPIN`, `HELP`) with NOTICE replies
+      and refusal when no matching key is configured; verify with irc2 unit
+      tests per command (success, wrong/missing key, unknown channel)
+- [ ] 3.3 Extend `INFO` output with resolved owner, chain tip, default policy
+      list, and pins from the cache in 2.3; verify via a ChanServ test against
+      a seeded static DAG
+- [ ] 3.4 Add the ChanServ integration test: two-node live network, register
+      on node A, `INFO` on node B resolves the same owner; `TRANSFER` followed
+      by a policy update signed by the new key is accepted on both
+
+## 4. Hide actions in the rotating DAG
+
+- [ ] 4.1 Define the `HideAction` rotating content type (tag `0x02`:
+      channel, target event id, hidden flag, actor pk, schnorr sig) and the
+      rotating content tag dispatch (tag-first decode, unknown tag skip) in
+      the irc2 relay path; verify with unit tests for each tag path plus
+      unknown-tag skip
+- [ ] 4.2 Implement hidden-set resolution (valid actions by keys in the
+      currently enabled `AdminHide` set, last-wins per target in canonical
+      rotating order, across retained windows) as part of the resolved-state
+      cache; verify with unit tests: hide, unhide, unauthorized ignored,
+      disabled-policy ignored, cross-window expiry
+- [ ] 4.3 Add ChanServ `HIDE`/`UNHIDE` commands (admin key required) and the
+      send path building the rotating event, including the RLN signal flow
+      when RLN is enabled; verify with a two-node test: hide on A marks the
+      message hidden on B, unhide restores it
+- [ ] 4.4 Apply hidden marking in the relay path (event-id check before
+      msg_id conversion) so hidden messages are stored but flagged, not
+      dropped; verify with an irc2 relay test asserting the message is
+      retained and flagged
+
+## 5. Tagged Privmsg and policy evaluators
+
+- [ ] 5.1 Retag all darkirc rotating content with the leading tag byte
+      (Privmsg `0x00` with optional signer pk + schnorr sig over serialized
+      core fields, computed before channel/DM encryption; no untagged form,
+      hard break per design D2) with send-side signing when the user's key is
+      in an enabled `AllowList`; verify serialization round-trip and
+      sign/verify unit tests, including that encrypted sends carry sig fields
+      inside the ciphertext
+- [ ] 5.2 Implement built-in policy evaluators (`AllowList` signature check,
+      `AdminHide` authorization already in 4.2, `Filter` regex matching
+      over the decoded privmsg nick and/or content) with unknown-id entries
+      and uncompilable regex rules ignored; adding the `regex` dependency to
+      `bin/darkirc/Cargo.toml` is a review-flagged supply-chain step; verify
+      with per-policy evaluator unit tests (signed/unsigned, regex match on
+      nick, regex match on msg content, invalid regex skipped, unknown
+      policy id)
+- [ ] 5.3 Verify robust dispatch: unknown tags and malformed tagged content
+      are skipped without error propagation or peer penalty (unit test
+      covering both rotating and static paths, per the app-payloads spec)
+
+## 6. App integration
+
+- [ ] 6.1 Port tag-byte dispatch and the resolved-policy cache into
+      `bin/app/src/plugin/darkirc.rs` relay (Privmsg, HideAction,
+      hidden marking before msg_id conversion); verify with plugin-level tests
+      mirroring 4.4/5.2
+- [ ] 6.2 Add the policy override table (per-channel, per-policy rows
+      overriding the owner default flag) with schema-level tests asserting
+      override resolution (`default || override`) is consulted by the
+      evaluators
+- [ ] 6.3 Make the chat screen's channel-name label tappable: add a normal
+      button node over the existing channel label (label placement constants
+      in `bin/app/src/app/schema/chat.rs`, e.g. `CHANNEL_LABEL_X/Y`), using
+      the same button pattern as the chat screen's send/emoji buttons;
+      activation opens that channel's policy overlay; verify the button hit
+      area covers the label and fires for both mouse and touch
+- [ ] 6.4 Build the policy overlay scene node (following existing overlay/
+      layer patterns) listing the resolved default policy list with toggle
+      switches bound to the override table; toggling writes the override row
+      and re-filters the channel buffer as a pure view update (hidden
+      messages are marked, not dropped); verify overlay wiring against a
+      seeded resolved-policy cache including the unregistered-channel empty
+      state
+- [ ] 6.5 Add owner-key storage to the app settings store (secret only, public
+      derived, never logged); verify round-trip and no-leak assertions in
+      settings tests
+
+## 7. Integration and review gates
+
+- [ ] 7.1 Full workspace gates pass: `make` then `make test` then
+      `make clippy` all clean with `--all-features`
+- [ ] 7.2 End-to-end multi-node scenario passes: register → policy list →
+      posts → hide/unhide → pin → rotation expiry leaves pin renderable and
+      hide state expired (extend the irc2 integration harness)
+- [ ] 7.3 Human review of the RLN admission-path diff (`handle_static_put`,
+      `static_sync`, RLN rebuild) per repo policy, plus the
+      `@anon-security-review` pass on the change diff before marking ready to
+      archive

+ 2 - 0
openspec/changes/evgr-resume-bug/.openspec.yaml

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

+ 363 - 0
openspec/changes/evgr-resume-bug/design.md

@@ -0,0 +1,363 @@
+# Design: evgr-resume-bug
+
+## Context
+
+The rotating event graph (`src/event_graph/`) keeps one DAG per hourly slot
+(`hours_rotation: 1`) with a 24-slot retention window (`max_dags: 24`). Three
+mechanisms populate a node's DAG:
+
+1. **Initial sync** — `EventGraph::sync_impl` (via `dag_sync`): quorum-agreed
+   tips → `HeaderReq(our_tips)` → `header_dag_insert` →
+   `fetch_missing_events` (batched `EventReq` bodies). Strict: fails the round
+   if any requested body fails to commit.
+2. **Live gossip** — `ProtocolEventGraph::handle_event_put` (`src/event_graph/proto.rs`):
+   validates one arriving event, then resolves its unknown ancestry with
+   `fetch_parents`.
+3. **Paginated range** — `RangeReq`/`fetch_page_with_blobs`. Exists, is served
+   by peers, and is unused by `bin/app`.
+
+The mobile app (`bin/app/src/plugin/darkirc.rs`) drives these as follows:
+
+- The `dag_sync` plugin task performs the initial sync **once per process
+  lifetime**, then parks forever in a loop that only re-notifies the UI of
+  peer-count changes.
+- `catch_up_sync` walks the remaining retention-window slots once, then `break`s.
+  After it exits, **no task ever syncs a slot again** — including slots that
+  rotate in later (`dag_prune_task` rotates `current_genesis` hourly, but
+  nothing reconciles the new slot beyond gossip).
+- Screen off/on toggles `P2P_OUTBOUND_SLEEP`/`P2P_OUTBOUND_ACTIVE` only.
+
+The bug this change fixes: after a suspension, catch-up relies exclusively on
+`fetch_parents` walks, which are best-effort, single-peer, and cover only the
+lineages they descend. Anything not in a walked lineage is silently missing
+forever (no reconciliation exists), and the user sees permanent holes in
+conversation history.
+
+This is not mobile-only. The same permanent-gap symptom has been observed with
+laptop users who close the lid and later return: the OS freezes the whole
+process (no mobile-style sleep hook, and desktop builds have no
+`screen_changed` signal at all), sockets die, and on resume the client is in
+exactly the state described above — current tips eventually arrive via
+gossip, but mid-history branches are never fetched. Consequence for this
+design: an explicit wake signal is a useful *additional* trigger, but repair
+correctness cannot depend on one existing or firing. The peer-recovery and
+periodic triggers below are therefore mandatory parts of the mechanism, not
+hardening.
+
+## Forensic evidence
+
+All log excerpts below are from an Android logcat capture of the app
+(taken 2026-08-31), sanitized: nicknames, channel names, message bodies, and
+seed addresses are replaced with placeholders. Event ids are truncated
+blake3 prefixes retained where needed to show DAG topology. Local times are
+UTC+2; event timestamps are UTC.
+
+### A. The suspension and resume
+
+```
+08-31 16:42:03.942 [10685] I net::refinery: No connections for 747s. GreylistRefinery paused.
+08-31 16:42:03.957 [10685] D net::seedsync_session: SeedSyncSession::start_seed() [START]
+08-31 16:42:03.957 [10685] I net::connector::connect: [P2P] Connecting peer [<seed-0>] via route [<seed-0>]
+08-31 16:42:03.957 [10685] I net::connector::connect: [P2P] Connecting peer [<seed-1>] via route [<seed-1>]
+08-31 16:42:09.268 [10685] I net::seedsync_session: [P2P] Connected seed [<seed-0>]
+08-31 16:42:09.273 [10685] I net::seedsync_session: [P2P] Connected seed [<seed-1>]
+08-31 16:42:09.405 [10685] I net::seedsync_session: [P2P] Disconnecting from seed [<seed-0>]
+08-31 16:42:09.429 [10685] I net::seedsync_session: [P2P] Disconnecting from seed [<seed-1>]
+```
+
+747s without connections ⇒ last traffic at ~14:29:36 UTC. Notably, the entire
+rest of the capture contains **zero** `plugin::darkirc2` INFO lines — the
+plugin's `dag_sync` task logs `"Syncing newest event DAG..."`, `"Newest event
+DAG synced successfully"`, etc. at info level on every sync attempt. Their
+absence proves no sync path ran at resume; it had completed at cold start
+(before the capture window) and was parked.
+
+### B. The holey burst
+
+At 16:42:16 (7 seconds after the seeds reconnected — consistent with ~100
+sequential ancestry round-trips at ~70ms), ~110 events were committed in a
+56ms burst and relayed to the UI. Received layers:
+
+```
+165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182
+183 184 185 186 187 188 189 190 191 192
+----------------------------------------- GAP (17 layers missing) -----
+210 211 212
+--- GAP (8) --- 221 --- 222-223 missing --- 224 --- 225 missing ---
+226 227
+----------------------------------- GAP (18 layers missing) ----------
+246 --- 247 missing --- 248 249 250 ... 262
+```
+
+Message timestamps show the gap is real conversation time:
+
+```
+layer=192 t=1788186785188  ->  14:33:05 UTC   (received)
+layer=210 t=1788186941479  ->  14:35:41 UTC   (received)
+                                 ~156 seconds of conversation never arrived
+```
+
+From layer 263 onward, events arrive one at a time via live gossip
+(`16:42:17` … `16:58`), i.e. the network path itself was healthy after
+reconnect.
+
+The burst shape (single 56ms commit, layer-ascending) matches
+`fetch_parents` exactly: it buffers fetched events in a
+`BTreeMap<u64 /*layer*/, Vec<…>>` and inserts them flattened in layer order
+after the walk completes. One completed walk = one burst.
+
+### C. Parent-chain analysis (why the holes are where they are)
+
+Parsing `ev_id` + `parents[0]` from every relayed event and checking whether
+each parent was itself ever relayed:
+
+```
+t=1788186785060  ev=6210eaa4  parent=0bbb2fc6   ok (received)
+t=1788186785187  ev=ea7bc14a  parent=6210eaa4   ok (received)
+t=1788186785188  ev=550bdc68  parent=ea7bc14a   ok (received)   <- L192
+t=1788186941479  ev=4713d021  parent=5143546a   <<< NEVER RELAYED
+t=1788186941573  ev=bab13f11  parent=4713d021   ok
+t=1788186996457  ev=6545d7ff  parent=45dae6ba   <<< NEVER RELAYED
+t=1788187005626  ev=79f7506f  parent=b715e06f   <<< NEVER RELAYED
+t=1788187024199  ev=31d0461b  parent=44810e02   <<< NEVER RELAYED
+t=1788187180594  ev=21522ad9  parent=97b2d5d6   <<< NEVER RELAYED
+t=1788187203763  ev=baa051fd  parent=9ef0a12c   <<< NEVER RELAYED
+...
+```
+
+The received set is **closed under parents only via events that were either
+received or already local**; the "never relayed" bridging parents are
+events whose bodies must be in `main_tree` (otherwise the children could not
+have committed — see `dag_insert_inner`'s parent-body closure below), but
+which produced no relay line. They are foreign-channel/DM traffic: darkirc
+uses one shared DAG across all channels, and the app's `relay_events`
+silently drops undecryptable privmsgs. The walked lineage therefore weaves
+through other channels' events, and the missing conversation messages are
+events on branches **no completed walk ever descended**.
+
+### D. Ruled-out alternatives
+
+- **Hourly rotation prune**: boundary at 14:00 UTC; the whole incident
+  (14:29–14:42) is inside the 14:00–15:00 slot; `max_dags: 24` retention.
+  Also `handle_event_put`'s pre-genesis cut (`event.header.timestamp <
+  genesis_ts → continue`) never triggers inside a slot.
+- **Publisher eviction**: `Publisher::notify` uses `force_send`, which evicts
+  the *oldest* queued notification on overflow — but per-subscriber capacity
+  is 1024 (`PUBLISHER_QUEUE_CAPACITY`) vs a ~110-event burst, and the loop
+  was draining concurrently. Cannot produce 17 consecutive mid-burst drops.
+- **UI-side dedup/deserialization drops**: zero `"Skipping duplicate seen
+  message"` and zero `"Failed deserializing incoming Privmsg"` lines in the
+  capture. The target channel is plaintext, so a message present in
+  `main_tree` would have relayed. Hence the missing messages are genuinely
+  absent from the app's DAG.
+- **Local replay (`rescan`)**: `rescan_channel_history` iterates
+  `order_events()` over the local `main_tree` only; it cannot fetch anything.
+
+## Code path walkthrough
+
+### 1. The parked sync task (`bin/app/src/plugin/darkirc.rs`, `dag_sync`)
+
+```rust
+loop {
+    // ... wait for peers, run static_sync, then:
+    let latest_ts = self.event_graph.current_genesis.read().await.header.timestamp;
+    i!("Syncing newest event DAG ({latest_ts}) (attempt #{sync_attempt})");
+    let sync_result = self.sync_dag_slot(latest_ts, fast_mode).await;
+    match sync_result {
+        Ok(()) => { newest_synced = true; break }   // <-- leaves the loop forever
+        Err(e) => { e!("Failed syncing newest DAG ({e}), retrying..."); }
+    }
+}
+// ...
+loop {
+    // Parked forever: only notifies the UI of connection changes.
+    if let Err(err) = channel_sub.receive().await { continue }
+    let peers_count = self.p2p.peers_count();
+    self.notify_connect(peers_count, self.event_graph.is_synced()).await;
+}
+```
+
+### 2. Wake only toggles outbound slots (`bin/app/src/plugin/darkirc.rs`)
+
+```rust
+let screen_changed_task = ex.spawn(async move {
+    while let Ok(data) = screen_changed_recv.recv().await {
+        // ...
+        if screen_on {
+            self_.set_outbound_connections(P2P_OUTBOUND_ACTIVE).await;
+        } else {
+            self_.set_outbound_connections(P2P_OUTBOUND_SLEEP).await;
+        }
+    }
+});
+```
+
+No sync call anywhere on this path.
+
+### 3. `catch_up_sync` exits permanently (`bin/app/src/plugin/darkirc.rs`)
+
+```rust
+pending = still_pending;
+if pending.is_empty() {
+    i!("Background catch-up complete; all older DAGs synced");
+    break          // <- nothing ever syncs the *next* rotated-in slot
+}
+```
+
+### 4. The only post-sync catch-up: gossip ancestry walks
+(`src/event_graph/proto.rs`, `fetch_parents`)
+
+```rust
+// Only ever asks the single peer that sent the EventPut:
+if self.channel.send(&EventReq(requested.clone())).await.is_err() { return false }
+let Ok(rep) = self.ev_rep_sub.receive_with_timeout(timeout).await else {
+    self.channel.stop().await;
+    return false          // <- whole walk discarded, triggering event dropped
+};
+```
+
+Single-peer, drop-on-failure, and — per `MAX_PARENT_FETCH_DEPTH`'s own doc
+comment — explicitly not the intended mechanism for a node that is far
+behind: "A node that's 1000+ layers behind should be using `dag_sync` rather
+than relying on `EventPut` catch-up." The app never does.
+
+### 5. Why a naive re-run of `dag_sync` is insufficient (`sync_impl`)
+
+```rust
+let missing: HashSet<blake3::Hash> = accepted
+    .iter()
+    .filter(|h| !slot.main_tree.contains_key(h.as_bytes()).unwrap_or(true))
+    .cloned()
+    .collect();
+if missing.is_empty() {
+    return Ok(())          // <- early return when we already hold all tips
+}
+```
+
+At resume-before-gossip this is fine (our tips are stale, peers' tips are
+missing). But if reconciliation fires after gossip has already delivered the
+current tips, the early return skips the header/body phases and the gaps are
+never repaired. Meanwhile the serving side is well suited to repair:
+`fetch_headers_with_tips` returns **every header not reachable from the
+requester's tips** (branch events included), layer-sorted (which is a valid
+topological order because a parent's layer is always strictly lower than its
+child's), capped at `MAX_HEADER_REP_HEADERS` (4096), and `handle_header_req`
+whitelists revealed ids into `broadcasted_ids` so the follow-up body
+`EventReq`s are admitted.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Any event that existed at peers while the app was suspended becomes
+  fetchable again after resume, without a process restart.
+- Repair works even when triggered late (after gossip has delivered tips).
+- Repair also covers slots that rotate in while the app is long-lived
+  (covers the `catch_up_sync`-exits gap).
+- Observable: field logs state when a repair round ran and what it committed.
+
+**Non-Goals:**
+- Scrollback/pagination UI via `RangeReq` (separate change).
+- Changing `fetch_parents` multi-peer fallback / retry semantics.
+- Rotation/retention config changes; any p2p wire-protocol change.
+- Recovering events no reachable peer still serves (out of any node's
+  control once retention expires).
+
+## Decisions
+
+### D1: Repair primitive = header-sync + body-fetch, without the tip-quorum gate
+
+Add a lib-level `EventGraph::dag_repair(dag_ts)` (in `src/event_graph/mod.rs`)
+that reuses `sync_impl`'s machinery but:
+
+- Skips the "missing tips" early return (always issues `HeaderReq(our_tips)`
+  to all peers and inserts returned headers), because gap events are by
+  definition not reachable from our tips — `fetch_headers_with_tips`
+  excludes our tips' ancestors, so the response is exactly the unreached
+  set.
+- Runs `fetch_missing_events` afterward for bodies.
+- Treats per-event commit failures leniently: log and continue, return
+  `Ok` with a count (a single peer missing an RLN blob for one event must
+  not fail the whole repair round; the strict behavior stays for initial
+  sync where completeness is the contract).
+
+Alternative rejected: calling existing `dag_sync` from the app — blocked by
+the early return above. Alternative rejected: app-side `RangeReq` scan —
+would need a cursor policy over `time_index` and duplicate the
+header/body machinery; range sync is designed for ordered pagination, not
+arbitrary-branch reconciliation.
+
+### D2: Triggers — wake signal, peer recovery, periodic
+
+In the app plugin:
+
+- `screen_changed(screen_on=true)` and the `darkirc_start` slot schedule a
+  repair round (debounced) — where such signals exist (mobile).
+- A dedicated task watching `p2p.hosts().subscribe_channel()` schedules a
+  repair round when peers transition 0 → ≥1. This is the primary trigger for
+  suspend/resume on machines where no in-app wake signal exists or fires:
+  laptop lid-close/resume freezes the process wholesale, and the first
+  observable symptom inside the app is peers dropping to zero and later
+  reconnecting.
+- A periodic timer re-runs repair for the current slot every
+  `REPAIR_INTERVAL` (default: 15 min, constant — matching the file's
+  existing "TODO: these should be configurable" style) so a failed round is
+  retried and rotated-in slots are reconciled without any event at all.
+  This is also the backstop for resume shapes that produce no clean 0 → ≥1
+  edge (e.g. connections that die and return one at a time while others
+  stay up, or a resume racing the channel subscription).
+
+Debounce/coalescing: a single in-flight guard (see D3) collapses
+simultaneous triggers; triggers arriving during a round are latched, not
+dropped.
+
+Alternative rejected: re-arming the parked `dag_sync` loop by feeding it
+channel events — the parked loop's contract is notify-only; overloading it
+mixes initial-sync retry semantics with repair semantics. A separate
+`repair_sync` task mirrors `catch_up_sync`'s shape and keeps the state
+machines disjoint.
+
+### D3: Concurrency and `synced`-flag discipline
+
+- `dag_repair` must not run concurrently with an initial `dag_sync`/
+  `sync_selected` for the same slot or with another repair round: guard with
+  an app-side `AtomicBool`/mutex ("repair in flight"), since both paths
+  terminate in `dag_insert_with_blobs` which is idempotent for known events
+  but the strict variant's error contract differs from repair's lenient
+  one.
+- The `synced` flag is only ever set (never cleared) by the initial sync;
+  repair observes it (must not run before initial sync completes — same
+  gate `handle_event_put` uses) and must not flip it, so live gossip
+  ingestion is never blocked by a repair round.
+
+### D4: Observability
+
+- Info log on repair round start (slot id, trigger source) and completion
+  (headers gained, bodies committed, events skipped+why).
+- Existing warn/error logs on peer-side serving refusals
+  (`"declining to serve event ... - missing blob"`) remain the primary
+  diagnostic for unrecoverable events.
+
+## Risks / Trade-offs
+
+- **`src/event_graph/mod.rs` is a security-critical subsystem.** D1 adds a
+  read-heavy path reusing existing insert primitives; no validation,
+  RLN, or pruning logic is altered. Flagged for explicit review, and the
+  apply phase must run `make test` (event_graph tests are proof-dependent).
+- **Wake traffic spike**: a repair round after a long suspension transfers
+  all unreached headers/bodies for the current slot. Bounded by protocol
+  limits (`MAX_HEADER_REP_HEADERS` 4096, `MAX_EVENT_REQ_IDS` 128/batch,
+  `MAX_RANGE_PAGE_SIZE` 100). On mobile this is the same volume the initial
+  sync would have paid; debouncing prevents amplification from multiple
+  triggers.
+- **Truncation**: `fetch_headers_with_tips` keeps the lowest 4096 headers
+  when overflowing — a node very far behind may need multiple repair
+  rounds to converge (each round advances the frontier). Acceptable;
+  periodic retry converges.
+- **Quorum caveat inherited from tips collection**: repair itself doesn't
+  depend on the 2/3 tip quorum (that gate only feeds the early return we
+  skip), but branch events known to a *minority* of peers are only
+  recoverable while at least one such peer is connected and serving.
+- **Known unrecoverable case**: events whose blobs no serving peer retains
+  are skipped and logged, not fatally failed. The UI may still show gaps
+  for those; this change guarantees retry + visibility, not impossible
+  recovery.

+ 76 - 0
openspec/changes/evgr-resume-bug/proposal.md

@@ -0,0 +1,76 @@
+# Proposal: evgr-resume-bug
+
+## Why
+
+A mobile client (bin/app darkirc plugin) that suspends its P2P stack (screen off →
+`P2P_OUTBOUND_SLEEP`) and later resumes receives only a holey subset of the chat
+history created while it was away. Verified from a production logcat capture: after a
+~13 minute suspension, the app received a single ~110-event burst containing
+contiguous runs with permanent gaps (~156 seconds of conversation never arrived, plus
+several smaller gaps), and because no reconciliation mechanism ever runs again, those
+gaps persist for the lifetime of the process. Users experience silently missing
+messages in the middle of conversations. The same failure has also been observed
+with laptop users closing the lid and later returning: the process is frozen by
+OS suspend rather than a mobile sleep hook, so the defect is not mobile-specific —
+any long-lived client whose connectivity drops out for a period (screen sleep,
+lid close, OS suspend, network churn) can accumulate permanent gaps, and we
+cannot assume the machine stays running and connected for the duration of the
+session. This is not the hourly DAG rotation
+(`hours_rotation: 1`, `max_dags: 24`) — the entire incident occurred inside a single
+rotation slot; the root cause is that DAG sync runs exactly once per process
+lifetime, and post-resume catch-up relies solely on best-effort gossip ancestry
+walks (`fetch_parents`) which cover only the lineages they happen to descend.
+
+Full forensic evidence (sanitized log excerpts, parent-chain analysis, code path
+walkthrough) is captured in `design.md`.
+
+## What Changes
+
+- Add resume-triggered reconciliation: when connectivity is re-established after a
+  suspension (an explicit wake signal where one exists, or outbound peers
+  transitioning 0 → N), the app re-runs a DAG sync of the current rotation slot
+  (`EventGraph::dag_sync`, the existing quorum-tips → header sync → body fetch
+  path) so that any events missed during the outage are fetched.
+- Add a periodic background reconciliation loop that re-syncs the current slot at a
+  slow cadence, repairing gaps from failed best-effort walks even when no explicit
+  wake event is observed (e.g. laptop lid-close/resume where the process is frozen
+  with no in-app signal, or connection churn without screen state change).
+- Guard the resync so it cannot run concurrently with an in-flight initial sync or
+  with itself, and so it observes (does not reset) the `synced` flag semantics that
+  gate live `EventPut` ingestion.
+- Instrument the failure mode: when a `fetch_parents` walk fails or is truncated,
+  and when a resume resync commits previously-missing events, emit an info-level
+  log line so the repair is observable in the field.
+- Non-goals (explicitly out of scope, candidate follow-ups): scrollback/pagination
+  UI via `RangeReq`/`fetch_page`; changing `fetch_parents` multi-peer fallback or
+  its drop-on-failure semantics inside `src/event_graph/proto.rs` (security-critical
+  shared subsystem; would be its own change with review); any modification to the
+  rotation or retention configuration.
+
+## Capabilities
+
+### New Capabilities
+- `event-graph-resume-sync`: Behavior contract for repairing missed rotating-DAG
+  events after a connectivity interruption on long-lived clients: triggers
+  (wake / peer-count recovery / periodic), the reconciliation mechanism
+  (re-running a slot sync), concurrency and idempotence requirements, and
+  observability requirements for repairs.
+
+### Modified Capabilities
+<!-- None: no existing specs to modify (openspec/specs/ is empty). -->
+
+## Impact
+
+- `bin/app/src/plugin/darkirc.rs` — the `dag_sync` task lifecycle (currently
+  run-once-then-park), the `screen_changed` / `darkirc_start` handlers, and
+  `catch_up_sync`; new resume-reconciliation task and periodic loop.
+- `src/event_graph/mod.rs` — only if a public API seam is needed to re-enter
+  `sync_impl` safely (e.g. exposing whether a slot sync is in flight); the
+  existing `dag_sync`/`sync_selected` entry points are expected to suffice.
+  Any edit here is in a security-critical subsystem and gets explicit review.
+- No changes to consensus serialization, ZK circuits, RLN logic, the wasm host
+  ACL, or the p2p wire protocol.
+- Risk surface: resync traffic volume on wake (bounded by `MAX_*` page/request
+  limits already enforced by the protocol), and interaction between resync and
+  live gossip ingestion (both terminate in `dag_insert_with_blobs`, which is
+  already idempotent for known events).

+ 107 - 0
openspec/changes/evgr-resume-bug/specs/event-graph-resume-sync/spec.md

@@ -0,0 +1,107 @@
+## Purpose
+
+Guarantees that a long-lived event-graph client repairs missed rotating-DAG
+events after a connectivity interruption (device sleep, network churn, or a
+slot rotation) instead of silently accumulating permanent history gaps, and
+makes such repairs observable in logs.
+
+## ADDED Requirements
+
+### Requirement: Resume triggers a repair round
+
+When connectivity is re-established after an interruption, the client SHALL
+schedule a repair round for the current rotation slot. Triggers SHALL include
+at minimum: an explicit wake signal (screen-on / start), and a transition of
+connected outbound peers from zero to one or more.
+
+#### Scenario: Resume after suspension
+
+- **WHEN** the client's outbound connections were suspended (e.g. screen off)
+  and are reactivated while peers hold events the client does not have
+- **THEN** the client performs a repair round for the current slot and commits
+  the previously-missing events to its DAG
+
+#### Scenario: Peer recovery without wake signal
+
+- **WHEN** the client had zero connected peers and at least one peer connects
+- **THEN** a repair round for the current slot is scheduled
+
+#### Scenario: OS-level suspend on a machine with no wake signal
+
+- **WHEN** a desktop/laptop client's process is frozen by OS suspend (e.g. lid
+  close) and later resumed, so that peers reconnect without any in-app wake
+  signal firing
+- **THEN** a repair round for the current slot is still scheduled via peer
+  recovery or the periodic interval, since clients MUST NOT assume the machine
+  stays running and connected for the duration of the session
+
+### Requirement: Repair rounds run periodically
+
+The client SHALL re-run a repair round for the current rotation slot on a
+fixed interval, so that failed rounds are retried, slots that rotate in while
+the client is long-lived are reconciled, and gaps are repaired even when no
+wake or connection event is observed.
+
+#### Scenario: Rotated-in slot is reconciled
+
+- **WHEN** a new rotation slot becomes current and the periodic interval
+  elapses
+- **THEN** the client runs a repair round against the new slot
+
+### Requirement: Repair fetches events unreachable from local tips
+
+A repair round SHALL fetch headers for events that are not ancestors of the
+client's current tips (i.e. events on unreached branches), not only events
+newer than the client's tips, and SHALL fetch corresponding event bodies for
+headers it newly learns. A repair round SHALL NOT be skipped merely because
+the client already holds the current network tips.
+
+#### Scenario: Mid-history gap repaired after tips are current
+
+- **WHEN** the client already holds all current network tips but is missing
+  events on branches unreached by gossip ancestry walks, and a repair round
+  runs
+- **THEN** the missing branch events are fetched and committed
+
+#### Scenario: Events only servable by a minority of peers
+
+- **WHEN** a missing event is held by at least one connected, serving peer
+- **THEN** the repair round is able to fetch it (repair MUST NOT require a
+  quorum of peers to hold the event)
+
+### Requirement: Repair round failure handling
+
+A repair round SHALL be lenient toward per-event failures: events that cannot
+be committed (e.g. a serving peer lacks the event's required proof blob)
+SHALL be skipped with a log entry rather than aborting the entire round.
+Skipped events remain eligible for later repair rounds.
+
+#### Scenario: Peer lacks a blob for one event
+
+- **WHEN** a repair round fetches a batch of bodies and one event is unservable
+- **THEN** the other events in the round are committed, the unservable one is
+  logged, and the round is not reported as failed
+
+### Requirement: Repair does not disrupt initial sync or live ingestion
+
+A repair round SHALL NOT run concurrently with an initial DAG sync or with
+another repair round for the same slot. Repair SHALL NOT clear or toggle the
+synced state that gates live event ingestion, and SHALL NOT run before
+initial sync has completed.
+
+#### Scenario: Trigger while initial sync is in flight
+
+- **WHEN** a repair trigger fires while the initial sync is still running
+- **THEN** the repair round is deferred until the initial sync completes
+
+### Requirement: Repair observability
+
+Each repair round SHALL emit a log entry when it starts (including the slot
+and trigger source) and when it completes (including counts of headers
+gained, event bodies committed, and events skipped with reasons).
+
+#### Scenario: Successful repair is visible in logs
+
+- **WHEN** a repair round commits previously-missing events
+- **THEN** an operator inspecting logs can determine the trigger, the slot,
+  and how many events were repaired

+ 56 - 0
openspec/changes/evgr-resume-bug/tasks.md

@@ -0,0 +1,56 @@
+## 1. Lib: repair primitive (`src/event_graph/mod.rs`)
+
+- [ ] 1.1 Add `EventGraph::dag_repair(dag_ts)` reusing `sync_impl`'s
+       peer-query/body-fetch machinery but skipping the missing-tips early
+       return (always issue `HeaderReq(our_tips)` to all peers, insert
+       returned headers via `header_dag_insert`, then run
+       `fetch_missing_events`). Verify with a unit test in
+       `src/event_graph/tests.rs`: node A and B hold a DAG where B is
+       missing a mid-history branch (not an ancestor of B's tips) but holds
+       all tips; `dag_repair` commits the branch on B.
+- [ ] 1.2 Make per-event commit failures in the repair body-fetch phase
+       lenient (log + skip + count, return `Ok` with counts) instead of the
+       strict `DagSyncFailed` used by initial sync. Verify with a unit
+       test: one peer serves a header but no blob for one event; repair
+       commits the rest and reports the skip.
+- [ ] 1.3 Run `make test` (proofs + contracts must be prebuilt) and confirm
+       the full `event_graph` test suite passes, including existing
+       `dag_sync`/`fetch_missing_events` strict-path tests (behavior of
+       initial sync unchanged).
+
+## 2. App: repair task and triggers (`bin/app/src/plugin/darkirc.rs`)
+
+- [ ] 2.1 Add a `repair_sync` task mirroring `catch_up_sync`'s shape: waits
+       on an in-flight guard + trigger latch, requires
+       `event_graph.is_synced()`, calls `dag_repair` for
+       `current_genesis`, never touches the `synced` flag. Verify by code
+       review against design D3 and by a desktop debug run
+       (`make compile-dev`) showing the repair start/complete log lines.
+- [ ] 2.2 Wire triggers: `screen_changed(screen_on=true)`, `darkirc_start`
+       slot, a `subscribe_channel` watcher firing on peers 0→≥1, and a
+       periodic timer (`REPAIR_INTERVAL`, 15 min constant). Triggers during
+       an in-flight round are latched, not dropped. Verify with a debug
+       build log showing coalescing (one round despite three simultaneous
+       triggers) and periodic rounds firing idle.
+- [ ] 2.3 Emit observability per spec: round start (slot id + trigger
+       source) and completion (headers gained, bodies committed, skipped
+       count). Verify the log lines appear in a debug run.
+
+## 3. End-to-end verification
+
+- [ ] 3.1 Reproduce the incident shape locally (models both mobile screen-off
+       and laptop lid-close resume): run two desktop nodes +
+       seed, suspend node B's connections entirely (stop outbound), generate
+       traffic on a branch B will not receive via gossip, resume B, and
+       verify B's history converges to A's within one repair round
+       (diff the two nodes' `order_events()` output). Repeat with B's
+       process itself frozen (SIGSTOP) across the gap to model OS suspend
+       with no wake signal, verifying the peer-recovery/periodic triggers
+       fire the repair.
+- [ ] 3.2 Verify no regression on resume cost: with no gap (B current),
+       a repair round issues only the header exchange and fetches zero
+       bodies (log shows 0 committed), and live `EventPut` ingestion is
+       never blocked during a round (send traffic through B while a round
+       runs; messages relay to the UI).
+- [ ] 3.3 Compile checks: `make compile-dev` (desktop) and
+       `make compile-apk` (android) both succeed; `make clippy` clean.