Przeglądaj źródła

openspec: add swarm proposal

darkfi 3 tygodni temu
rodzic
commit
865de29008

+ 2 - 0
openspec/changes/swarm/.openspec.yaml

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

+ 425 - 0
openspec/changes/swarm/design.md

@@ -0,0 +1,425 @@
+# Design: swarm overlay for subnet discovery
+
+## Context
+
+Today a "network" is one `P2p` instance identified by `{magic_bytes,
+app_name, app_version}`. Magic bytes are checked at channel setup
+(`src/net/channel.rs`, raw frame read) before the version/verack handshake
+checks `app_name`. Hostlists, refinement, and datastores are per-`P2p`.
+Lilith spawns one `P2p` per configured network (`bin/lilith/src/main.rs`,
+`spawn_net()`), each needing its own listener, datastore, and config section.
+Apps construct their own `P2p` at startup from static seed lists.
+
+Extension points already public in `src/net` and sufficient for an overlay
+without core surgery:
+
+- `ProtocolRegistry::register(session_flags, constructor)` — attach custom
+  protocols per session type (`protocol_registry.rs`).
+- `#[macro_export] impl_p2p_message!` (`message.rs`) — define new wire
+  messages with metering; already used by `event_graph` and fud.
+- `GetAddrsMessage`/`AddrsMessage` gossip via `ProtocolAddress` establishes
+  the pattern ads should follow: relayed, unsigned, refinement-filtered.
+- `src/dht` exists for content-keyed lookup (Kademlia) and is used by fud;
+  it is the wrong tool for membership (structured lookup paths are linkable).
+
+See proposal.md for motivation. Constraints that shape this design: no
+changes to channel framing, magic-byte gating, or handshake semantics; the
+overlay must not become a cross-subnet correlation point; no stable node
+identity may cross subnets.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- One overlay seed list bootstraps discovery for all subnets, forever.
+- Subnets remain ordinary `P2p` networks: their own magic bytes, hostlist,
+  refinement, datastore — joined by direct dial after discovery.
+- Subnet spawn/stop at runtime, driven by `SubnetId`.
+- Anti-linkability as a designed property, not a config flag.
+- Lilith collapses to one listener + persistent ad store.
+- First-class transient (mobile) participants: cheap lookups, no relay
+  obligations, no on-disk state beyond an optional overlay hostlist cache,
+  invisible to third parties.
+
+**Non-Goals (design-level):**
+
+- No connection multiplexing (one connection carrying multiple subnets).
+- No DHT lookup for membership; gossip only.
+- No signatures on ads; authenticity is refinement's job.
+- No changes to `src/net` semantics — additive exports only.
+- No automatic serving of every joined subnet; serving is explicit per
+  subnet.
+
+## Decisions
+
+### D1. Thin overlay, not multiplexing or subnet-tagged address protocol
+
+Three alternatives were considered:
+
+- **Multiplexed overlay** (one connection, virtual streams per subnet):
+  breaks the 1:1 channel↔network invariant across `session/`+`channel.rs`,
+  and mixes subnet traffic on one wire — a traffic-analysis surface that
+  violates the anonymity constraints.
+- **Subnet-tagged `GetAddrs`/`Addrs`** (one global network carrying all
+  subnets): smallest diff, but merges all hostlists into one refinement
+  state, exposing cross-subnet membership in a node's address book and
+  connection churn.
+- **Thin overlay (chosen)**: `Swarm` owns one overlay `P2p` plus a
+  `HashMap<SubnetId, SubnetEntry>`; each `SubnetEntry` owns an ordinary
+  spawned `P2p`. Overlay only bootstraps; subnet health remains
+  self-maintaining via existing refinery.
+
+### D2. `SubnetId` = `blake3(canonical descriptor)`
+
+```
+descriptor := app_name || magic_bytes || version_constraint || secret?
+SubnetId   := blake3(descriptor)
+```
+
+- Apps pin known IDs (e.g. darkirc mainnet) — a pin is a spec of the
+  descriptor fields.
+- `secret` present → unguessable ID: a non-member cannot even name the
+  subnet, giving obscurity-based access control (rendezvous-string style).
+- `version_constraint` is part of the descriptor so a subnet's version
+  policy is fixed at creation; exact pin initially, ranges deferred.
+
+Alternative: human-readable subnet names — rejected: global names leak the
+set of private subnets into gossip and invite squatting.
+
+### D3. Ad format and propagation: unsigned gossip with TTL
+
+```
+SubnetAd { subnet_id, addrs: Vec<(Url, u64)>, ttl_secs }
+```
+
+- Propagated by flood/gossip identical in spirit to `AddrsMessage`; the peer
+  an ad is received from is not its author → origin ambiguity.
+- Unsigned, no node identity. Poisoning is bounded by (a) refinement — ads
+  land in the target subnet's greylist and dead addrs are dropped by
+  handshake checks, and (b) per-message metering + ban policy for floods.
+- Overlay ad stores keep entries until `ttl` expiry + refinery liveness
+  checks (lilith's existing whitelist-refinery pattern, retargeted at ads).
+- Ads are re-gossiped on a slow, jittered cadence (like refinery intervals),
+  never event-triggered on subnet start — see R3.
+
+Alternative: per-subnet signing keys. Gives poisoning resistance but tempts
+key reuse across subnets (linkability) and adds key management; deferred
+until refinement proves insufficient.
+
+### D4. New messages and `ProtocolSwarm`, all outside `src/net` core
+
+```
+SubnetAd                       (gossip, unsolicited)
+GetSubnets       → Subnets             (list known subnet_ids)
+GetSubnetAddrs{subnet_id} → SubnetAddrs{subnet_id, addrs}
+```
+
+Defined via `impl_p2p_message!` with metering configurations and
+`MAX_BYTES` estimates in the existing style. `ProtocolSwarm` is registered
+via `ProtocolRegistry` on outbound+inbound sessions of the overlay `P2p`
+only. `GetSubnets` responses are built from local ad-store state; queries
+reveal participation in the overlay but not in any particular subnet.
+
+### D5. Serving vs joining
+
+- **Joining** (default): pull `SubnetAddrs`, seed the subnet `P2p`'s greylist
+  (via `Hosts::insert`, grey), dial. No ad is emitted.
+- **Serving** (opt-in per subnet): requires inbound addrs for that subnet —
+  one tor/i2p onion per subnet is the recommended deployment so overlay
+  observers cannot correlate a shared endpoint across subnets. Emits ads on
+  the D3 cadence.
+
+### D6. Subnet lifecycle under `Swarm`
+
+- `Swarm::join(subnet_descriptor)` → resolve via overlay → spawn subnet
+  `P2p` with per-subnet `p2p_datastore`/`hostlist` paths derived from
+  `SubnetId` under a swarm-managed directory; register app protocols onto
+  that `P2p`'s registry before `start()`.
+- `Swarm::serve(subnet_descriptor, inbound_cfg)` → join + advertise.
+- `Swarm::leave(subnet_id)` → `P2p::stop()` + deregister; ads simply expire
+  via TTL (no "leave" message — a departure broadcast would create a
+  timing-correlation surface).
+- Dynamic spawn after startup is the main new runtime pattern; apps
+  currently build all networks before `start()`. Watch item: executor
+  shutdown ordering when many subnet `P2p`s stop concurrently.
+
+### D7. Lilith becomes an overlay seed
+
+One config section (`[overlay]`: accept addrs, datastore), no per-network
+sections. Runs the overlay `P2p` with `inbound_connections` high,
+`outbound_connections` 0 (unchanged posture: no outbound dialing), plus:
+persistent ad store (ads survive restarts until TTL), refinery-based ad
+expiry, and the `spawns` RPC retargeted at overlay stats (known subnets,
+ad counts). Existing per-network sections keep working during migration
+(lilith simply spawns those nets as before, alongside the overlay).
+
+### D8. Overlay node roles: persistent vs transient, declared not negotiated
+
+Desktop daemons and lilith run on always-on machines and carry the overlay;
+mobile apps (and any short-lived client) join the overlay only to look
+subnets up and leave. The distinction is declared through the existing
+`VersionMessage.features` vector (`src/net/message.rs`), which is on the
+wire today but sent empty: a persistent node advertises
+`("swarm-store", 1)`; a transient node sends no swarm feature. Role is
+self-declared and unauthenticated — it is a hint for policy and load, never
+a privilege.
+
+|                      | persistent                     | transient                        |
+|----------------------|--------------------------------|----------------------------------|
+| typical host         | desktop daemon, lilith         | mobile app doing a lookup        |
+| inbound addrs        | typical (often onion)          | none (`inbound_connections: 0`)  |
+| ad store             | disk-backed, TTL + refinery    | none (optional in-memory cache)  |
+| gossip relay         | yes                            | only while connected (brief)     |
+| subnet serving       | per-subnet opt-in (D5)         | never                            |
+| overlay outbound     | default slots                  | minimal (1–2), query then leave  |
+| datastore/hostlist   | persisted                      | overlay hostlist cache (TSV); no ad store |
+| heartbeat tuning     | default                        | longer intervals (battery, NAT)  |
+
+- **Uniform protocol behavior**: both roles answer `GetSubnets`/
+  `GetSubnetAddrs` from whatever local state exists while connected. Role
+  changes *what state exists*, never message handling — role-specific wire
+  behavior would fingerprint peers and split the anonymity set.
+- **Transients are invisible to third parties**: no inbound addrs and no ads
+  means a transient node never appears in any hostlist or ad store. Its
+  overlay peers see only a short-lived connection — the same exposure a
+  client of today's per-network seeds has.
+- **Transient hostlist cache**: a transient node persists its overlay
+  hostlist (peer addresses only, via the existing `net::Settings.hostlist`
+  TSV — zero new machinery) so later sessions dial cached overlay peers
+  first and fall back to configured seeds only on miss/failure. The cache
+  MUST NOT record queried or joined subnets — it contains overlay peer
+  addresses and nothing else. Local-device forensics trade-off: the cache
+  proves overlay participation but not subnet membership; privacy-maximal
+  deployments disable it (also weaker against stale-entry churn, handled by
+  normal greylist refinement).
+- **Lilith is just the canonical persistent node**; any persistent daemon
+  relays ads and can cold-start others, which strengthens the R4 mitigation.
+- **Load spreading without new messages**: persistent nodes are reachable
+  addrs in the overlay's own hostlist (they advertise inbound via the normal
+  address protocol), so a transient node that wants to avoid hammering seeds
+  dials overlay peers from `GetAddrs` and simply keeps the ones whose
+  handshake carries the `swarm-store` feature. A dedicated
+  feature-filtered query can be added later inside swarm's message set if
+  wasted dials prove costly; not needed initially.
+- Mobile constraints shape defaults, not the protocol: battery (longer
+  heartbeat via `NetworkProfile`, disconnect after lookup), NAT (no inbound,
+  no hole punching required), metered data (small `GetSubnetAddrs` replies
+  bounded by metering).
+
+Alternative considered: no declared role at all (purely emergent — transients
+are just nodes that leave quickly). Rejected: without the feature bit,
+persistent nodes cannot preferentially keep slots for ad-carrying peers, and
+transients cannot find store-keeping peers without trial dialing everyone.
+
+## API Sketch
+
+Illustrative signatures — names may shift during implementation; the shape
+is what apps program against. Everything mirrors the existing `P2p` idiom:
+async constructors returning `Result`, `Arc` pointers, `StoppableTask`
+lifecycle, `net::Settings` for transport-level config.
+
+### Core types
+
+```rust
+/// Declared overlay role (D8)
+pub enum SwarmRole {
+    /// Disk-backed ad store, gossip relay, may serve subnets
+    Persistent { datastore: PathBuf },
+    /// Lookup client; no ads, no inbound, optional overlay hostlist cache
+    /// (`None` leaves no on-device overlay trace)
+    Transient { hostlist: Option<PathBuf> },
+}
+
+/// Canonical subnet descriptor (D2)
+pub struct SubnetDescriptor { /* app_name, magic_bytes, version, secret? */ }
+
+impl SubnetDescriptor {
+    /// Pin a released network; shipped as constants in app code
+    pub const fn pinned(app_name: &str, magic_bytes: [u8; 4], version: &'static str) -> Self;
+
+    /// Secret-bearing descriptor for private subnets
+    pub fn private(app_name: &str, magic_bytes: [u8; 4], version: &str, secret: &[u8]) -> Self;
+
+    /// BLAKE3 of the canonical serialization
+    pub fn id(&self) -> SubnetId;
+}
+
+/// Handle to a joined or served subnet
+pub struct SubnetHandle { /* ... */ }
+
+impl SubnetHandle {
+    pub fn id(&self) -> SubnetId;
+    /// The subnet's own P2p instance, for app-level messaging
+    pub fn p2p(&self) -> P2pPtr;
+}
+
+pub struct Swarm { /* overlay P2p + subnet registry + ad store */ }
+pub type SwarmPtr = Arc<Swarm>;
+```
+
+### Usage: persistent daemon (desktop, e.g. darkirc)
+
+```rust
+// Overlay settings: one seed list, forever
+let overlay = net::Settings {
+    app_name: "swarm".into(),
+    magic_bytes: OVERLAY_MAGIC,
+    seeds: OVERLAY_SEEDS.into(),
+    inbound_connections: 64,
+    ..Default::default()
+};
+
+let role = SwarmRole::Persistent {
+    datastore: "~/.local/share/darkirc/swarm/ads".into(),
+};
+let swarm = Swarm::new(role, overlay, ex.clone()).await?;
+swarm.clone().start().await?;
+
+// Pinned descriptor shipped with the app (D2)
+const DARKIRC: SubnetDescriptor =
+    SubnetDescriptor::pinned("darkirc", [251, 229, 199, 181], "0.5.1");
+
+// Client-only participation (default)
+let subnet = swarm.join(&DARKIRC, darkirc_protocols).await?;
+
+// Or serve it, with this subnet's own onion (D5)
+let inbound = vec![Url::parse("tor://darkirc-7.onion:9440")?];
+let subnet = swarm.serve(&DARKIRC, inbound, darkirc_protocols).await?;
+
+// App messaging rides the subnet's ordinary P2p — unchanged app code
+let _ = subnet.p2p();
+
+// Later: silent leave (D6) — no departure message, ads expire by TTL
+swarm.leave(DARKIRC.id()).await?;
+```
+
+`darkirc_protocols` is the registration closure the swarm runs against the
+subnet `P2p`'s protocol registry *before* `start()` — the same
+`registry.register(session_flags, init)` hook `register_default_protocols`
+uses internally. Apps keep registering their protocols exactly as today;
+they just do it through the closure.
+
+### Usage: transient lookup (mobile)
+
+```rust
+let overlay = net::Settings {
+    app_name: "swarm".into(),
+    magic_bytes: OVERLAY_MAGIC,
+    seeds: OVERLAY_SEEDS.into(),
+    outbound_connections: 2,
+    inbound_connections: 0,
+    ..Default::default()
+};
+
+let role = SwarmRole::Transient {
+    // Cache overlay peers between sessions so later sessions dial cached
+    // peers first and only fall back to seeds. `None` for a device free
+    // of overlay traces.
+    hostlist: Some(cache_dir.join("overlay_hostlist.tsv")),
+};
+let swarm = Swarm::new(role, overlay, ex.clone()).await?;
+
+// Lookup only: resolve addresses, no subnet participation, then disconnect
+let addrs: Vec<Url> = swarm.lookup(&FUD_CHANNEL).await?;
+
+// Or join for the duration of the app session (recommended over
+// per-lookup connections — R7 battery-vs-mixing guidance)
+let subnet = swarm.join(&FUD_CHANNEL, fud_protocols).await?;
+```
+
+### Usage: lilith (the canonical persistent node)
+
+```rust
+let role = SwarmRole::Persistent { datastore: cfg.adstore };
+let swarm = Swarm::new(role, overlay_settings, ex).await?;
+swarm.clone().start().await?;
+// Nothing else. No per-subnet config, no join calls: ads arrive by
+// gossip, and the durable store + refinery (D7) make lilith the
+// cold-start anchor. Legacy per-network sections still spawn ordinary
+// P2p instances alongside, during migration.
+```
+
+### API-enforced invariants
+
+- `serve()` on a `Transient` swarm fails fast — the transient role has no
+  serving path (spec: swarm-overlay, node roles).
+- `join`/`serve` take a descriptor, never a raw id: a caller cannot join a
+  subnet it cannot describe, and the spawned `P2p` still enforces the
+  subnet's own magic bytes and `app_name` handshake independently of the
+  overlay.
+- Protocol registration happens only before subnet `start()` via the
+  closure — there is no window where a subnet accepts connections without
+  its app protocols attached.
+- `lookup()` answers from local state first (on a persistent node: the ad
+  store) and queries the overlay only on miss; on a transient node it may
+  reuse the session cache.
+- A transient swarm dials its cached overlay hostlist before configured
+  seeds; seeds are only the first-ever-run and fallback path.
+
+## Risks / Trade-offs
+
+- **R1 Overlay as correlation point** (new metadata surface) → per-subnet
+  addresses (D5), unsigned per-subnet ads with no node identity (D3),
+  gossip origin ambiguity (D3/D4), query design that reveals only overlay
+  participation (D4). Residual: a global adversary observing all overlay
+  traffic plus all subnet on/off timings can still correlate — documented
+  as a known limit; timing jitter is the only partial defense.
+- **R2 Ad poisoning / flood** → no signatures means ads are cheap to forge;
+  bounded by refinement liveness checks, metering thresholds, and ban
+  policy; per-subnet ad-store caps (like GREYLIST_MAX_LEN) bound memory.
+- **R3 Timing linkage of fresh serving nodes** → ads on jittered cadence
+  only, never event-driven; deployment guidance recommends pre-registered
+  onions.
+- **R4 Cold-start still depends on overlay seeds** → same trust profile as
+  today's per-network seeds, but strictly reduced: the seed sees overlay
+  participation only, never which subnets are joined (dials are direct and
+  subnet-scoped). Multiple overlay seeds can be listed, any serving node's
+  overlay connection also relays ads, and repeat sessions bootstrap from
+  the transient hostlist cache rather than seeds.
+- **R5 Private-subnet obscurity is not access control** → the secret names
+  the subnet; it does not encrypt subnet traffic. Documented; end-to-end
+  protections remain the apps' job (e.g. darkirc saltbox, event-graph RLN).
+- **R6 Tor/onion-per-subnet operational cost** → serving on clearnet tcp is
+  possible but exposes a shared endpoint; the design allows it, deployment
+  guidance should not recommend it.
+- **R7 Transient nodes are timing-fingerprintable** (connect → query →
+  leave) → while connected, transients send the same messages any node may
+  send, and third parties never observe them at all (no hostlist presence).
+  The connection-lifetime pattern itself is the residual fingerprint; cover
+  traffic is out of scope for battery-constrained devices. Documented
+  battery-vs-mixing tension: staying connected longer mixes better and costs
+  more — deployment guidance may suggest holding the overlay connection for
+  the app session rather than per lookup.
+- **R8 Transient load concentrates on overlay seeds** → transients spread
+  across persistent nodes via the overlay's own address gossip plus the
+  `swarm-store` handshake feature (D8, no new messages); after the first
+  session, the transient hostlist cache (D8) means seeds are fallback-only.
+  If concentration persists, add a feature-filtered query inside swarm's
+  message set.
+
+## Migration Plan
+
+1. Land `src/swarm` with the overlay protocol; lilith gains the overlay
+   section alongside legacy per-network sections. No app changes.
+2. Apps adopt `Swarm` optionally, keeping static seed lists as fallback;
+   pinned `SubnetId` constants added per app (values equal to existing
+   `{app_name, magic_bytes, version}` triples so current networks are
+   discoverable).
+3. Once overlay coverage is healthy, deprecate lilith's per-network
+   sections (warn, then refuse across a release boundary).
+4. Rollback: overlay is additive; apps revert to static seeds, lilith drops
+   the overlay section. Ads and `SubnetId` paths are all namespaced and
+   removable.
+
+## Open Questions
+
+- Exact `ttl_secs` default and per-subnet ad-store cap values (tune during
+  implementation against refinery intervals).
+- Whether `GetSubnets` responses should be rate-limited per peer beyond
+  generic metering (decide when metering thresholds are set).
+- Whether darkfid's consensus networks adopt `Swarm` at all, or only
+  latency-tolerant apps do (darkirc, taud, fud) — an adoption-policy
+  question, not a protocol one.
+- Whether persistent nodes should cap the share of inbound slots given to
+  transient (feature-less) overlay peers, and at what ratio — deployment
+  tuning once real transient traffic exists.

+ 107 - 0
openspec/changes/swarm/proposal.md

@@ -0,0 +1,107 @@
+# Proposal: swarm overlay for subnet discovery
+
+## Why
+
+Every DarkFi P2P network (darkirc, taud, darkfid testnet, fud, ...) is an
+isolated `P2p` instance separated by magic bytes and `app_name`. As a
+consequence:
+
+- Each app ships and maintains its own per-network seed lists.
+- Lilith must run one listener, datastore, hostlist, and config section per
+  network, and must be reconfigured and restarted to serve a new network.
+- There is no way to discover a subnet dynamically: creating a private
+  darkirc room, a testnet fork, or a fud channel with its own membership
+  requires baking new magic bytes into configs on every participating node.
+- Nodes that serve multiple networks expose one inbound endpoint per network,
+  and the seed infrastructure becomes the de-facto directory of all networks.
+
+We introduce a thin gossip overlay ("swarm") for subnet rendezvous: a single
+overlay network through which nodes learn which subnets exist and which peers
+serve them, then join each subnet directly as an ordinary `P2p` network. One
+overlay seed list serves all networks forever; lilith is demoted from an
+N-network zookeeper to a single overlay seed with a persistent ad store.
+
+Privacy is a primary driver, not an afterthought: the overlay must not become
+a cross-subnet correlation point (hard invariant: no peer-address/metadata
+leakage). Ads are per-subnet, carry per-subnet addresses, and propagate by
+gossip so origins stay ambiguous, mirroring the properties of the existing
+address protocol.
+
+## What Changes
+
+- Add a new `src/swarm` subsystem: a `Swarm` that owns one overlay `P2p`
+  instance (fixed `app_name`, one magic-bytes constant) and dynamically
+  manages subnet `P2p` instances.
+- Define `SubnetId = blake3(canonical descriptor)`, where the descriptor
+  binds `{app_name, magic_bytes, version, optional secret}`. Known subnets
+  are pinned in app code; the optional secret yields unguessable private
+  subnets (obscurity-based access control).
+- New overlay wire messages (`SubnetAd`, `GetSubnets`/`Subnets`,
+  `GetSubnetAddrs`/`SubnetAddrs`) defined via the existing
+  `impl_p2p_message!` macro with metering configurations, gossiped by a new
+  `ProtocolSwarm` registered through the existing `ProtocolRegistry`.
+- Ads are unsigned, `{subnet_id, addrs, ttl}`, propagated by gossip/flood
+  (like `AddrsMessage`); poisoning is handled by the existing refinement and
+  ban machinery, not by signatures. No stable node key crosses subnets.
+- Subnet membership discovered on the overlay is seeded into each subnet's
+  greylist; per-subnet hostlists, refinement, and datastores remain unchanged
+  and self-maintaining.
+- Serving a subnet (advertising inbound addrs, e.g. one onion per subnet) is
+  opt-in per subnet; joining (query + dial) is the default.
+- Overlay node roles: **persistent** swarm nodes (desktop daemons, lilith)
+  keep disk-backed ad stores and relay gossip; **transient** swarm nodes
+  (e.g. mobile apps doing a lookup) run inbound-free overlay sessions,
+  emit no ads, and persist at most an overlay hostlist cache so repeat
+  sessions dial cached peers instead of always contacting seeds. The role
+  is declared via the existing `VersionMessage` features vector — no new
+  handshake semantics.
+- Lilith is reduced to a single overlay listener plus a persistent,
+  TTL-bounded ad store for cold-start; its config collapses from N network
+  sections to one overlay section and new subnets are learned without
+  operator action.
+- Minimal, non-breaking changes to `src/net` (exports/helpers only); no
+  changes to channel framing, magic-byte gating, version handshake
+  semantics, or the host ACL.
+
+Non-goals: multiplexing subnets over a single connection (rejected for
+traffic-analysis and architectural reasons); replacing gossip with structured
+DHT lookup for membership; subnet-scoped content DHT keys (possible later
+rider on the same overlay); changes to consensus, contracts, or ZK.
+
+## Capabilities
+
+### New Capabilities
+
+- `swarm-overlay`: the overlay rendezvous protocol — SubnetId derivation,
+  advertisement format, gossip propagation, query handling, ad-store TTL and
+  bounds, metering, node roles (persistent vs transient and their
+  obligations), and the anti-linkability requirements (per-subnet addresses,
+  origin ambiguity, no cross-subnet identity).
+- `subnet-lifecycle`: dynamic subnet management from the application
+  perspective — resolving a SubnetId to reachable peers, spawning/stopping
+  subnet `P2p` instances at runtime, seeding subnet greylists from overlay
+  ads, and the serving-vs-joining modes with their advertisement obligations.
+- `lilith-overlay-seed`: lilith redeployed as a single overlay seed — one
+  listener, persistent ad store with refinery-based expiry, single-section
+  config, and dynamic learning of new subnets.
+
+### Modified Capabilities
+
+None. There are no existing specs under `openspec/specs/` to modify; `src/net`
+core behavior is deliberately left unchanged.
+
+## Impact
+
+- **New code**: `src/swarm/` (overlay `P2p` ownership, `ProtocolSwarm`, ad
+  store, subnet registry/lifecycle) and its wire message definitions.
+- **`src/net`**: minor only — possibly exporting small `hosts` helpers and a
+  path-derivation helper for per-subnet datastores; no semantic changes to
+  sessions, hosts, channel, or transport layers.
+- **`bin/lilith`**: config format collapses to one overlay section; existing
+  per-network sections deprecated on a migration path.
+- **Apps** (`bin/darkirc`, `bin/tau`, `bin/darkfid`, `bin/fud`): opt-in
+  adoption — construct `Swarm` instead of (or in front of) individual `P2p`
+  instances; static seed lists remain as fallback during transition.
+- **Security review focus**: the overlay is a new metadata surface; the
+  anti-linkability requirements in `swarm-overlay` are binding and must pass
+  the anon-security-review gate before apply.

+ 84 - 0
openspec/changes/swarm/specs/lilith-overlay-seed/spec.md

@@ -0,0 +1,84 @@
+## Purpose
+
+Defines lilith redeployed as a single persistent overlay seed: one overlay
+listener and datastore, a durable advertisement store with refinery-based
+expiry, dynamic learning of new subnets without operator action, and
+continued support for legacy per-network sections during migration.
+
+## ADDED Requirements
+
+### Requirement: Single overlay seed configuration
+
+Lilith SHALL support an overlay configuration section that starts one
+overlay network instance with its own accept addresses, datastore, and
+hostlist paths. When the overlay section is present, lilith participates in
+the overlay as a persistent node: high inbound slot allowance, no outbound
+slot requirement, gossip relay, and the swarm-ad-store handshake feature.
+Per-network configuration sections SHALL NOT be required for the overlay to
+operate, and operating the overlay SHALL NOT require one listener, datastore
+section, or magic-bytes constant per served subnet.
+
+#### Scenario: Fresh subnet served without reconfiguration
+
+- **WHEN** participants begin advertising a subnet unknown to a running
+  lilith overlay seed
+- **THEN** lilith's advertisement store learns and serves the subnet with
+  no operator action and no restart
+
+#### Scenario: Overlay-only deployment
+
+- **WHEN** lilith is configured with only the overlay section
+- **THEN** it starts, accepts overlay connections, and serves subnet
+  discovery
+
+### Requirement: Durable advertisement store
+
+Lilith's overlay advertisement store SHALL persist to disk and survive
+restarts. Entries SHALL be served after restart until their TTL expires
+absent re-confirmation. Store persistence MUST NOT record any information
+about transient queriers (no logging of query sources into the store).
+
+#### Scenario: Restart preserves cold-start service
+
+- **WHEN** lilith restarts while a subnet's serving peers are offline
+- **THEN** the persisted advertisements are still available to cold-start
+  nodes that query afterward, within TTL bounds
+
+### Requirement: Advertisement refinery
+
+Lilith SHALL run a periodic refinery over its advertisement store,
+verifying reachability of advertised addresses; entries failing checks
+SHALL be downgraded or dropped. The refinery SHALL be rate-limited so it
+does not dial a burst of addresses simultaneously.
+
+#### Scenario: Dead advertisement expires early
+
+- **WHEN** an advertised address fails refinery liveness checks before its
+  TTL would expire
+- **THEN** lilith stops serving that address before TTL expiry
+
+### Requirement: Legacy per-network sections honored during migration
+
+While the migration period is in effect, lilith SHALL keep accepting and
+spawning per-network sections as independent network instances alongside
+the overlay, preserving current seed behavior for apps that have not
+adopted the swarm. Deprecation of per-network sections, when it comes,
+SHALL be staged (warning first, refusal at a later release boundary).
+
+#### Scenario: Mixed config runs both
+
+- **WHEN** lilith is configured with both the overlay section and legacy
+  per-network sections
+- **THEN** the overlay seed and the legacy per-network instances all run
+
+### Requirement: RPC reporting of overlay state
+
+Lilith's JSON-RPC SHALL expose overlay seed status: listener health,
+participating subnets, and advertisement store statistics. The reported
+data MUST NOT include addresses or identifiers of transient queriers.
+
+#### Scenario: Operator inspects seed
+
+- **WHEN** an operator calls the lilith status RPC
+- **THEN** listener health, known subnet identifiers, and advertisement
+  counts are returned, with no record of which peers queried which subnets

+ 138 - 0
openspec/changes/swarm/specs/subnet-lifecycle/spec.md

@@ -0,0 +1,138 @@
+## Purpose
+
+Defines how an application uses the swarm overlay to manage subnets
+dynamically: joining a subnet by resolving its identifier to reachable
+peers, serving a subnet with advertisement obligations, leaving a subnet,
+and keeping per-subnet state isolated — at runtime, without per-subnet seed
+configuration.
+
+## ADDED Requirements
+
+### Requirement: Subnet join via overlay resolution
+
+An application SHALL be able to join a subnet given only its descriptor: it
+derives the subnet identifier, resolves the identifier to advertised
+addresses over the overlay, seeds those addresses into the subnet's
+unverified (greylist) peer set, and dials peers as an ordinary network
+instance using the subnet's own magic bytes and application identity. The
+overlay connection used for resolution MUST NOT become a connection of the
+joined subnet. If the overlay yields no reachable addresses, joining SHALL
+fail with a resolvable error rather than hang.
+
+#### Scenario: Descriptor-only join
+
+- **WHEN** an application requests joining a subnet for which it holds only
+  the descriptor
+- **THEN** the subnet network instance starts and establishes peer
+  connections using addresses discovered over the overlay, without any
+  subnet-specific seed configuration
+
+#### Scenario: Unknown subnet
+
+- **WHEN** no advertised address exists or none is reachable for a requested
+  subnet
+- **THEN** the join attempt reports failure in bounded time and no subnet
+  network instance is left running
+
+### Requirement: Per-subnet state isolation
+
+Each subnet managed through the swarm SHALL have its own peer hostlists,
+refinery behavior, and on-disk datastore and hostlist files, namespaced by
+the subnet identifier. State of one subnet (hostlist entries, datastore,
+refinement outcomes) MUST NOT be shared with or leak into another subnet
+managed by the same node, and removing a subnet SHALL be possible without
+damaging other subnets' persisted state.
+
+#### Scenario: Independent refinement
+
+- **WHEN** an address is unreachable in subnet A but healthy in subnet B
+- **THEN** refinement in A downgrades it there while B's state for the same
+  address is unaffected
+
+#### Scenario: Namespaced persistence
+
+- **WHEN** two subnets persist hostlists and datastores on one node
+- **THEN** their files are stored under distinct paths derived from their
+  subnet identifiers
+
+### Requirement: Serving a subnet is opt-in with advertisement obligations
+
+An application SHALL be able to declare, per subnet, that it serves the
+subnet. Serving requires inbound addresses for that subnet; when active,
+the node SHALL gossip advertisements for the subnet on the jittered cadence
+defined by the overlay capability, and those advertisements SHALL carry
+only the inbound addresses assigned to that subnet. A node that merely
+joins a subnet (default) SHALL emit no advertisement for it. A transient
+overlay node SHALL NOT serve any subnet.
+
+#### Scenario: Opt-in serving advertises
+
+- **WHEN** an application marks a joined subnet as served with configured
+  inbound addresses
+- **THEN** other overlay participants can resolve the subnet to those
+  addresses, and no advertisement is emitted before the first cadence tick
+
+#### Scenario: Join-only stays silent
+
+- **WHEN** an application joins a subnet without declaring serving
+- **THEN** no advertisement naming that subnet is ever emitted by the node
+
+### Requirement: Subnet leave without departure broadcast
+
+Leaving a subnet SHALL stop the subnet's network instance, release its
+connections, and deregister it from the managing swarm. The leave MUST NOT
+emit any departure or withdrawal message on the overlay; the node's
+advertisements for that subnet simply cease and expire by TTL. Persisted
+per-subnet state MAY be retained for rejoining.
+
+#### Scenario: Silent leave
+
+- **WHEN** an application leaves a subnet it was serving
+- **THEN** no overlay message announces the departure, and other nodes'
+  advertisement stores still list its addresses until TTL expiry or failed
+  liveness checks remove them
+
+#### Scenario: Rejoin reuses state
+
+- **WHEN** a node rejoins a subnet it previously left and retained state for
+- **THEN** its subnet peer hostlist resumes from the persisted state
+
+### Requirement: Runtime subnet lifecycle
+
+A swarm-managed node SHALL support starting and stopping subnets at any time
+after the overlay is running, without restarting the overlay or other
+subnets. Concurrent shutdown of multiple subnets SHALL terminate cleanly
+without affecting surviving subnets.
+
+#### Scenario: Late subnet spawn
+
+- **WHEN** a new subnet join is requested while other subnets are already
+  running
+- **THEN** the new subnet starts without disruption to existing subnet
+  connections or the overlay
+
+#### Scenario: Concurrent teardown
+
+- **WHEN** several subnets are stopped at once
+- **THEN** all stop cleanly and remaining subnets continue operating
+
+### Requirement: Pinned and private subnets
+
+Applications SHALL be able to pin known subnet descriptors (e.g. released
+networks) so their identifiers are stable across releases, and SHALL be able
+to construct subnets from user-supplied descriptors, including
+secret-bearing descriptors for private subnets. Static per-subnet seed lists
+SHALL remain usable as a fallback or override for any pinned subnet during
+the migration period.
+
+#### Scenario: Pinned identifier matches released network
+
+- **WHEN** an app pins a released network's descriptor
+- **THEN** the derived identifier matches the one used by all other pinned
+  deployments, and the subnet is discoverable through the overlay
+
+#### Scenario: Static seeds still work
+
+- **WHEN** an app is configured with static seeds for a pinned subnet and
+  overlay discovery is unavailable
+- **THEN** the subnet can still be joined via the static seeds

+ 202 - 0
openspec/changes/swarm/specs/swarm-overlay/spec.md

@@ -0,0 +1,202 @@
+## Purpose
+
+Defines the swarm overlay rendezvous protocol: how subnet identifiers are
+derived, how subnet advertisements are formatted, gossiped, stored, and
+queried, the persistent vs transient participant roles, and the binding
+anti-linkability constraints that keep the overlay from becoming a
+cross-subnet correlation point.
+
+## ADDED Requirements
+
+### Requirement: Subnet identifier derivation
+
+A subnet identifier SHALL be the BLAKE3 hash of a canonical, deterministically
+serialized descriptor binding the application name, network magic bytes, and
+version constraint of the subnet. The descriptor MAY include a secret; when
+present, the resulting identifier SHALL be computationally indistinguishable
+from any other identifier to a party lacking the secret. Two different
+descriptors MUST NOT yield the same identifier.
+
+#### Scenario: Same descriptor, same identifier
+
+- **WHEN** two nodes derive an identifier from byte-identical descriptors
+- **THEN** both obtain the same BLAKE3 identifier
+
+#### Scenario: Private subnet is unguessable
+
+- **WHEN** a party without the secret attempts to name or enumerate a
+  secret-bearing subnet
+- **THEN** they cannot produce its identifier or distinguish it from the
+  identifier space of public subnets
+
+#### Scenario: Divergent descriptors collide
+
+- **WHEN** two descriptors differing in any bound field are hashed
+- **THEN** the derived identifiers differ
+
+### Requirement: Advertisement format and invariants
+
+A subnet advertisement SHALL carry exactly one subnet identifier, a list of
+`(address, last_seen)` pairs for peers serving that subnet, and a TTL in
+seconds. Advertisements MUST be unsigned and MUST NOT contain any node
+identity, key, or per-node nonce. Advertised addresses SHALL be restricted to
+publicly shareable transport schemes. An advertisement for one subnet MUST
+NOT embed addresses belonging to another subnet.
+
+#### Scenario: Advertisement is self-contained per subnet
+
+- **WHEN** a node gossips an advertisement for subnet S
+- **THEN** the advertisement contains only S's identifier and addresses
+  attributed to S, and no field links it to any other subnet or to a stable
+  node identity
+
+#### Scenario: Non-shareable address rejected
+
+- **WHEN** an advertisement contains an address whose scheme is not publicly
+  shareable (e.g. a proxy-internal scheme)
+- **THEN** receiving nodes discard that address rather than storing or
+  relaying it
+
+### Requirement: Gossip propagation with origin ambiguity
+
+Advertisements SHALL propagate by flooding/gossip between overlay peers. A
+node relaying an advertisement MUST relay it unmodified, such that the
+immediate sender of an advertisement is never evidence of its authorship.
+Nodes MUST NOT include provenance, hop counts tied to a node, or relay
+signatures in relayed advertisements. Re-gossip of a node's own served
+subnets SHALL occur on a slow, jittered cadence and MUST NOT be triggered
+immediately upon a subnet starting, an inbound listener appearing, or a new
+overlay connection being established.
+
+#### Scenario: Relay preserves ambiguity
+
+- **WHEN** a node receives an advertisement and relays it to its peers
+- **THEN** the relayed message is byte-equivalent in identifying fields and
+  provides no indicator of whether the relaying node authored it
+
+#### Scenario: No event-triggered advertisement
+
+- **WHEN** a node begins serving a subnet or gains a new overlay peer
+- **THEN** it does not immediately emit an advertisement for that subnet;
+  the next emission waits for the jittered cadence
+
+### Requirement: Subnet queries answered from local state
+
+The overlay SHALL provide a query for the set of known subnet identifiers and
+a query for the advertised addresses of a specific subnet. Both queries SHALL
+be answered from the answering node's local advertisement state at the time
+of the query, for every connected overlay peer regardless of that peer's
+declared role. Query exchanges MUST NOT require the querier to reveal which
+subnets it participates in beyond the subnet explicitly named in a
+per-subnet query.
+
+#### Scenario: Subnet list query
+
+- **WHEN** a node sends a subnet-list query to a connected overlay peer
+- **THEN** it receives the set of subnet identifiers currently known to that
+  peer's local advertisement state
+
+#### Scenario: Per-subnet address query
+
+- **WHEN** a node sends a per-subnet query for identifier S
+- **THEN** it receives addresses advertised for S from the answering peer's
+  local state, and the exchange names no other subnet
+
+### Requirement: Advertisement store with TTL and bounds
+
+A persistent overlay node SHALL maintain an advertisement store. Entries
+SHALL expire no later than their TTL after last confirmation. Persistent
+nodes SHALL verify reachability of advertised addresses on a periodic,
+liveness-check schedule and SHALL drop or downgrade entries that fail.
+The store SHALL enforce per-subnet and total entry caps so that
+advertisement floods cannot grow it unboundedly. Transient nodes SHALL NOT
+maintain a persistent store.
+
+#### Scenario: TTL expiry
+
+- **WHEN** an advertisement entry's TTL elapses without re-confirmation
+- **THEN** the store no longer returns that entry in query responses
+
+#### Scenario: Unreachable advertisement dropped
+
+- **WHEN** an advertised address repeatedly fails liveness checks
+- **THEN** the store stops serving it and it is eligible for removal
+
+#### Scenario: Flood bounded
+
+- **WHEN** an attacker floods advertisements exceeding the store caps
+- **THEN** store size stays within its configured bounds
+
+### Requirement: Overlay node roles — persistent and transient
+
+An overlay node SHALL declare itself persistent by advertising a
+swarm-ad-store feature in the connection handshake's features vector; a node
+not advertising it is transient. A transient node SHALL NOT accept inbound
+overlay connections, SHALL NOT emit subnet advertisements, and SHALL NOT
+maintain an advertisement store. A transient node MAY persist a cache of
+overlay peer addresses (an overlay hostlist) so later sessions dial cached
+overlay peers before configured seeds; such a cache MUST NOT record which
+subnets the node queried or joined. A persistent node SHALL maintain a
+TTL-bounded advertisement store and SHALL relay gossip. Both roles SHALL
+handle all overlay protocol messages identically while connected; role
+SHALL NOT alter message formats or per-message processing, only the local
+state available to answer from. No overlay node SHALL refuse
+protocol-correct messages solely because the sender is transient.
+
+#### Scenario: Mobile lookup session
+
+- **WHEN** a transient node connects to the overlay, resolves the addresses
+  of a subnet, and disconnects
+- **THEN** it has persisted at most its overlay hostlist cache (overlay
+  peer addresses only, no subnet identifiers), has emitted no
+  advertisements, retains no advertisement store, and appears in no other
+  node's hostlist or advertisement store
+
+#### Scenario: Cached bootstrap avoids seeds
+
+- **WHEN** a transient node starts a session with a populated overlay
+  hostlist cache while its configured seeds are unreachable
+- **THEN** it still establishes overlay connectivity by dialing cached
+  overlay peers
+
+#### Scenario: Persistent node cold-starts another
+
+- **WHEN** a fresh node connects to any persistent overlay node and queries
+  a subnet
+- **THEN** it can discover and dial serving peers without any per-subnet
+  seed configuration
+
+#### Scenario: Role does not split protocol behavior
+
+- **WHEN** the same query is sent to a persistent and to a transient node
+  that both hold the same local advertisement state
+- **THEN** both answer with the same message types and semantics
+
+### Requirement: Metered overlay messages
+
+All overlay protocol messages SHALL be subject to per-message-type metering
+with configured thresholds and penalties, such that a peer flooding any
+overlay message type is throttled or banned under the node's ban policy.
+Message size estimates SHALL be declared for every overlay message type.
+
+#### Scenario: Query flood throttled
+
+- **WHEN** a peer sends subnet queries beyond the metering threshold
+- **THEN** the node applies the metering penalty for that message type
+
+### Requirement: No cross-subnet linkability in overlay traffic
+
+The overlay protocol MUST NOT provide any mechanism that links a single
+node across subnets: advertisements for different subnets served by one
+node MUST NOT be correlatable by their content, and no overlay message
+SHALL carry a stable node identifier, signature key, or address reused
+across subnets. Deployment of a shared inbound endpoint across multiple
+served subnets SHALL be flagged as a known linkability hazard in node
+documentation.
+
+#### Scenario: Two ads from one operator stay unlinkable
+
+- **WHEN** one operator serves two subnets through distinct per-subnet
+  addresses and follows the jittered cadence
+- **THEN** an overlay observer cannot bind the two advertisements to each
+  other through any field of the overlay protocol itself

+ 136 - 0
openspec/changes/swarm/tasks.md

@@ -0,0 +1,136 @@
+# Tasks: swarm overlay for subnet discovery
+
+## 1. Module foundation
+
+- [ ] 1.1 Create `src/swarm/` module skeleton (`mod.rs`, `settings.rs` with
+  `SwarmSettings` for overlay + role configuration), export it from the
+  `darkfi` crate, and verify `make` compiles with `make clippy` clean.
+
+## 2. SubnetId derivation (D2, spec: swarm-overlay)
+
+- [ ] 2.1 Implement `SubnetDescriptor` (app_name, magic_bytes,
+  version_constraint, optional secret) with canonical serialization and
+  BLAKE3 `SubnetId` derivation; verify unit tests pass: identical
+  descriptors yield equal ids, any field divergence yields different ids,
+  secret-bearing ids are the same length and format as public ones.
+- [ ] 2.2 Add a pinned-descriptor constructor from an existing
+  `{app_name, magic_bytes, version}` network triple; verify a golden-value
+  unit test fixes the darkirc triple's id so all deployments agree.
+
+## 3. Minimal `src/net` additions (security zone: additive only)
+
+- [ ] 3.1 Plumb a features list from `net::Settings` into the
+  `VersionMessage` handshake (currently hardcoded to `vec![]` in
+  `protocol_version.rs`); verify a version-exchange test round-trips the
+  feature and that the diff contains no other `src/net` changes (flag this
+  diff explicitly in the change notes for review).
+- [ ] 3.2 Verify existing net behavior is unchanged:
+  `cargo test -p darkfi --release --all-features net` passes and
+  `make clippy` is clean.
+
+## 4. Overlay wire messages (D4)
+
+- [ ] 4.1 Define `SubnetAd`, `GetSubnets`, `Subnets`, `GetSubnetAddrs`,
+  `SubnetAddrs` via `impl_p2p_message!` with `MAX_BYTES` estimates and
+  metering configurations in the existing style; verify serialization
+  roundtrip and size-estimate unit tests pass.
+
+## 5. Advertisement store (D3, spec: swarm-overlay)
+
+- [ ] 5.1 Implement the ad store: per-subnet and total entry caps, TTL
+  expiry, shareable-scheme filtering, disk persistence, and no recording of
+  querier data; verify unit tests pass for TTL expiry, cap enforcement,
+  restart persistence, and non-shareable-scheme rejection.
+- [ ] 5.2 Implement the ad refinery task: rate-limited liveness checks of
+  advertised addresses with drop/downgrade on failure; verify an integration
+  test shows a dead advertised address is dropped before TTL expiry and
+  checks are not issued in a simultaneous burst.
+
+## 6. ProtocolSwarm: gossip and queries (D4, D8)
+
+- [ ] 6.1 Implement `ProtocolSwarm` gossip handling: validate `SubnetAd`
+  (one subnet, shareable schemes), insert into the ad store, relay
+  unmodified with no provenance or hop fields; register via
+  `ProtocolRegistry` on the overlay's outbound and inbound sessions; verify
+  a test asserts relayed ads are unchanged and non-shareable addresses are
+  dropped rather than relayed.
+- [ ] 6.2 Implement `GetSubnets`/`GetSubnetAddrs` query handling answered
+  from local state for every connected peer; verify a test shows a
+  persistent and a transient node with identical local state answer with
+  the same message types and semantics.
+- [ ] 6.3 Implement the jittered ad re-gossip cadence with no
+  event-triggered emission; verify tests show no ad is sent on subnet
+  start, listener startup, or new overlay connection, and that the cadence
+  tick does emit.
+
+## 7. Swarm lifecycle and roles (D1, D5, D6, D8)
+
+- [ ] 7.1 Implement `Swarm` owning the overlay `P2p` plus the subnet
+  registry, with persistent and transient constructors (persistent:
+  `swarm-store` handshake feature via task 3.1, high inbound; transient:
+  zero inbound, minimal outbound, optional overlay hostlist cache and
+  nothing else on disk); verify a transient integration test writes only
+  the overlay hostlist cache and emits no ads.
+- [ ] 7.2 Implement `join(descriptor)`: overlay resolution, greylist
+  seeding of resolved addrs, subnet `P2p` spawn with `SubnetId`-namespaced
+  datastore/hostlist paths, app protocol registration before start; verify
+  a descriptor-only join succeeds against a local overlay seed with no
+  subnet seed configuration, and unknown subnets fail in bounded time.
+- [ ] 7.3 Implement `serve()`: per-subnet opt-in requiring that subnet's
+  inbound addrs, advertising only those addrs on the 6.3 cadence; verify
+  tests show served-subnet ads carry only that subnet's addresses and
+  join-only mode never emits an ad.
+- [ ] 7.4 Implement `leave()`: stop and deregister the subnet `P2p` with no
+  departure message and state retained for rejoin; verify tests assert no
+  overlay message is emitted on leave and rejoin resumes from the persisted
+  hostlist.
+- [ ] 7.5 Verify runtime lifecycle tests pass: late subnet spawn does not
+  disturb running subnets or the overlay, and concurrent teardown of
+  several subnets terminates cleanly.
+- [ ] 7.6 Implement transient cache-first bootstrap: the overlay hostlist
+  cache is persisted between sessions and dialed before configured seeds,
+  with cache contents limited to overlay peer addresses; verify an
+  integration test where a second session connects through cached peers
+  with all seeds unreachable, and a test asserting the cache contains no
+  subnet identifiers or query records.
+
+## 8. Multi-node integration tests (spec scenarios)
+
+- [ ] 8.1 Cold-start chain test over local transports: overlay seed →
+  persistent node → transient node discovers and joins a subnet with no
+  per-subnet seeds anywhere; verify the transient node connects into the
+  subnet and leaves no trace in any hostlist or ad store.
+- [ ] 8.2 Abuse tests: an ad flood exceeds store caps and triggers
+  metering/ban penalties, and poisoned (unreachable) addrs are dropped by
+  refinement; verify both behaviors in one integration test.
+- [ ] 8.3 Linkability structure test: one node serving two subnets through
+  distinct addresses emits ads that share no field linking them; verify by
+  structural inspection of all emitted messages in a two-subnet test.
+
+## 9. Lilith overlay seed (D7, spec: lilith-overlay-seed)
+
+- [ ] 9.1 Add the `[overlay]` config section with parsing, overlay-only
+  mode, and mixed overlay + legacy per-network operation; verify config
+  parsing unit tests (overlay-only, mixed, malformed) and a mixed-config
+  integration test where both the overlay seed and legacy nets run.
+- [ ] 9.2 Wire the durable ad store and refinery into lilith; verify a
+  restart test shows persisted ads survive a stop/start cycle within TTL
+  bounds and no querier data is stored.
+- [ ] 9.3 Retarget lilith's RPC: listener health, participating subnet ids,
+  ad store stats, with no per-querier data; verify a JSON-RPC test asserts
+  the reported fields and the absence of query-source information.
+
+## 10. Pilot adoption, docs, gates
+
+- [ ] 10.1 darkirc pilot behind a config flag: pinned descriptor via 2.2,
+  `Swarm`-based networking with static-seed fallback preserved; verify
+  tests exercise both the overlay path and the fallback path.
+- [ ] 10.2 Write deployment guidance: onion-per-subnet recommendation,
+  shared-endpoint linkability hazard (required by the no-cross-subnet
+  linkability spec requirement), and transient battery-vs-mixing
+  recommendation to hold the overlay connection for the app session.
+- [ ] 10.3 Full gates green: `make`, `make clippy`, `make test`, and
+  `make fmt` all complete without errors.
+- [ ] 10.4 Invoke `@anon-security-review` on the full diff and record the
+  verdict on the change; a FAIL is blocking — resolve findings or escalate
+  before marking the change ready to apply.