fleetmesh-node 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,929 @@
1
+ """GossipMesh: the gossip/1.0 compartment.
2
+
3
+ A true epidemic fanout for group topics, independent of any single relay.
4
+ Membership in a topic is explicit: an admin-signed kind 30804 (addressable,
5
+ republish-supersedes) naming every member pubkey, not ambient trust. A node
6
+ only grafts into, or accepts a graft for, a topic it holds a current
7
+ membership event naming its own pubkey for - and only toward a peer it
8
+ already shares a peering grant with, so this invents no new authorization
9
+ and rides the budget/grant model everyone else already accounts against.
10
+
11
+ The payload gossiped is the NIP-59 seal+wrap this node would otherwise have
12
+ sent over the 1:1 transport-ladder path - one sealed envelope per recipient,
13
+ exactly as DMs ship today - so gossip never sees plaintext: it floods
14
+ already-encrypted bytes keyed by the wrap's own event id. 1:1 DMs are
15
+ untouched; this is purely an additional fanout substrate for group topics of
16
+ 3+ members, which is where a single relay going down costs everyone their
17
+ only path.
18
+
19
+ Loop prevention is strict: a seen-id store (sqlite, TTL + size bounded) keyed
20
+ (topic, event_id) is checked before flooding, and an id is never re-sent back
21
+ toward the peer it arrived from. graft/prune build and prune a bounded mesh
22
+ per topic, degree DEFAULT_MESH_DEGREE (gossipsub's D), maintained by tick().
23
+
24
+ ihave/iwant is the lazy half (gossipsub's "gossip" proper): every tick, each
25
+ topic's recent event ids are offered to a few members *outside* this node's
26
+ mesh, and a member that is missing one asks for it by id and is sent the
27
+ sealed envelope directly. That is what carries a publish across a mesh
28
+ that is partitioned - a member whose grafts all failed, a topic larger than
29
+ D where two cliques formed, a node that was down for the flood - without
30
+ any relay in the path. Envelopes to answer an iwant with come from a short
31
+ in-memory message cache; the durable seen store stays the only thing loop
32
+ prevention relies on.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import threading
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from typing import Any, Callable, Mapping, Optional, Union
40
+
41
+ from node import channels as ch_mod
42
+ from node.storage import grants as grants_mod
43
+ from node import metrics as mx_mod
44
+ from node.protocols.nostr import client as relay_mod
45
+ from fleetmesh_core.crypto import giftwrap
46
+ from fleetmesh_core.wire.interfaces import NostrEvent
47
+ from node.config.compartments import GossipConfig
48
+ from node.config.parameter import Parameter
49
+ from node.compartment import Command, Every, Handler, Need, Outbound, WireProtocol, Worker
50
+ from node.compartment import stores as st
51
+ from .envelope import (DEFAULT_MESH_DEGREE, GossipError, MEMBERSHIP_KIND, PIURI,
52
+ make_graft, make_ihave, make_iwant, make_prune, make_publish)
53
+ from .mesh import RosterStore, SeenStore
54
+
55
+ REGRAFT_INTERVAL_SECONDS: int = 300
56
+ SEEN_SWEEP_INTERVAL_SECONDS: int = 3600
57
+ # Lazy gossip (gossipsub's mcache / D_lazy / history window). Envelopes are
58
+ # kept just long enough to answer an iwant for an id offered in an ihave; ids
59
+ # are offered to a few non-mesh members per tick, never to the mesh itself,
60
+ # which already had the full envelope flooded to it.
61
+ MCACHE_SECONDS: int = 120
62
+ MCACHE_MAX_PER_TOPIC: int = 512
63
+ GOSSIP_LAZY_DEGREE: int = 6
64
+ IHAVE_MAX_IDS: int = 256
65
+ # The message cache across every topic. MCACHE_MAX_PER_TOPIC still caps one
66
+ # topic; this caps the sum, so a node carrying many busy topics holds a
67
+ # bounded number of envelopes. It is a cache for lazy gossip and nothing
68
+ # depends on a hit: an id that fell out is heard from another member.
69
+ MCACHE_MAX: int = MCACHE_MAX_PER_TOPIC * 16
70
+ # How many of each kind of gossip event the compartment keeps to look at. The
71
+ # totals are counted separately (GossipTotals), so this bounds the memory and
72
+ # not the numbers an operator reads.
73
+ RECENT: int = 256
74
+
75
+
76
+
77
+ @dataclass(slots=True)
78
+ class Roster:
79
+ """A kind 30804 membership event this node holds for a topic, and the
80
+ member set read from its tags. The event is kept as it arrived - it is
81
+ what graft names, what `topics` reports and what RosterStore persists."""
82
+ event: Mapping[str, Any]
83
+ members: set[str] = field(default_factory=set)
84
+
85
+
86
+ from .types import (
87
+ GossipTotals,
88
+ TopicView,
89
+ TopicCreationResult,
90
+ OriginateResult,
91
+ GossipStateReport,
92
+ )
93
+
94
+
95
+ def _recent(key: str, what: str) -> st.Store:
96
+ return st.Store(key=key, description='%s, newest last' % what, kind=st.RING, shape=tuple, bound=RECENT)
97
+
98
+
99
+ class GossipMesh(WireProtocol):
100
+ """The group-topic gossip/1.0 compartment: graft/prune mesh + flood publish."""
101
+
102
+ name: str = 'gossip'
103
+ piuris: tuple[str, ...] = (PIURI,)
104
+ optional: bool = True
105
+ enabled_by: Optional[str] = 'gossip'
106
+ # Gossip reads the grant store and the relay list, both context fields;
107
+ # it never reaches the peering or budget compartments.
108
+ requires: tuple[str, ...] = ()
109
+ reads: tuple[str, ...] = ('gossip',)
110
+ config_cls: Any = GossipConfig
111
+ HANDLERS = (
112
+ Handler('graft', run='wire_graft', doc='receive gossip mesh graft'),
113
+ Handler('prune', run='wire_prune', doc='receive gossip mesh prune'),
114
+ Handler('publish', run='wire_publish', doc='receive epidemic flood publish'),
115
+ Handler('ihave', run='wire_ihave', doc='receive lazy gossip ihave offer'),
116
+ Handler('iwant', run='wire_iwant', doc='receive lazy gossip iwant request'),
117
+ )
118
+ OUTBOUND = (
119
+ Outbound('graft', reply=('graft', 'prune'), doc='request to join a topic mesh'),
120
+ Outbound('prune', doc='leave a topic mesh or prune a peer'),
121
+ Outbound('publish', doc='epidemic flood of sealed message to topic mesh'),
122
+ Outbound('ihave', reply='iwant', doc='lazy gossip offer of recently seen message ids'),
123
+ Outbound('iwant', reply='publish', doc='request message payloads for offered ids'),
124
+ )
125
+ COMMANDS = (
126
+ Command(
127
+ key='show',
128
+ name='Gossip Status',
129
+ description='topics this node gossips, mesh size per topic, and seen-store size',
130
+ tags=('gossip', 'status'),
131
+ permission='gossip/show',
132
+ run='state',
133
+ reply='{topics, mesh, seen}',
134
+ ),
135
+ Command(
136
+ key='topics',
137
+ name='Gossip Topics',
138
+ description='every roster held: admin, members, mesh peers, expiry',
139
+ tags=('gossip', 'topics'),
140
+ permission='gossip/topics',
141
+ run='topics_cmd',
142
+ reply='[{topic, admin, members, mesh, expires, roster_id}]',
143
+ ),
144
+ Command(
145
+ key='create',
146
+ name='Create Topic',
147
+ description='sign a kind 30804 roster for <node_pk>:<name>, admit it, publish it to the relays',
148
+ tags=('gossip', 'create'),
149
+ parameters=(
150
+ Parameter(key='name', name='name', description='topic name', type=str, default='', required=True),
151
+ Parameter(key='members', name='members', description='initial member pubkeys', type=list, default=list, required=False),
152
+ Parameter(key='ttl_seconds', name='ttl_seconds', description='roster time-to-live in seconds', type=int, default=30 * 86400, required=False),
153
+ Parameter(key='no_relay', name='no_relay', description='do not publish to relays', type=bool, default=False, required=False),
154
+ ),
155
+ permission='gossip/create',
156
+ run='create_cmd',
157
+ reply='{topic, event, relays}',
158
+ ),
159
+ Command(
160
+ key='admit',
161
+ name='Admit Topic',
162
+ description='admit a kind 30804 roster another admin signed that names this node',
163
+ tags=('gossip', 'admit'),
164
+ parameters=(
165
+ Parameter(key='event', name='event', description='kind 30804 roster event', type=dict, default=dict, required=True),
166
+ ),
167
+ permission='gossip/admit',
168
+ run='admit_cmd',
169
+ reply='{topic}',
170
+ ),
171
+ Command(
172
+ key='leave',
173
+ name='Leave Topic',
174
+ description='drop a roster and prune every mesh peer on it',
175
+ tags=('gossip', 'leave'),
176
+ parameters=(
177
+ Parameter(key='topic', name='topic', description='topic to leave', type=str, default='', required=True),
178
+ ),
179
+ permission='gossip/leave',
180
+ run='leave_cmd',
181
+ reply='{topic, pruned}',
182
+ ),
183
+ Command(
184
+ key='publish',
185
+ name='Publish Message',
186
+ description='flood a sealed NIP-59 wrap into a topic mesh',
187
+ tags=('gossip', 'publish'),
188
+ parameters=(
189
+ Parameter(key='topic', name='topic', description='target topic id', type=str, default='', required=True),
190
+ Parameter(key='envelope', name='envelope', description='sealed NIP-59 wrap event', type=dict, default=dict, required=True),
191
+ ),
192
+ permission='gossip/publish',
193
+ run='publish_cmd',
194
+ reply='{topic, event_id, sent_to, mesh}',
195
+ ),
196
+ )
197
+
198
+ # What it keeps. Each of the six history lists below used to be a plain
199
+ # list appended once per gossip message and never trimmed - memory that
200
+ # grew with whatever peers sent - while nothing in the node read more
201
+ # than its length. Now: a ring of the recent ones, and the length kept
202
+ # as a counter in `totals`, so `state` reports the same numbers.
203
+ STORES = (
204
+ # REFUSE: dropping a roster would silently leave a topic. create() and
205
+ # admit() refuse at the configured max_topics first, with a reason.
206
+ st.Store(key='members', description='the roster held for each topic', kind=st.TABLE,
207
+ shape=Roster, bound=GossipConfig.TOPIC_CEILING, evict=st.REFUSE, keyed_by='topic'),
208
+ # Derived from members and rebuilt by tick's regraft, so OLDEST is a
209
+ # safe second line: a topic dropped here is regrafted.
210
+ st.Store(key='peers', description="the pubkeys in this node's mesh, per topic", kind=st.TABLE,
211
+ shape=set, bound=GossipConfig.TOPIC_CEILING, keyed_by='topic'),
212
+ st.Store(key='grafted_at', description='when each mesh peer was grafted, per topic', kind=st.TABLE,
213
+ shape=int, bound=GossipConfig.TOPIC_CEILING * GossipConfig.MESH_DEGREE_CEILING,
214
+ keyed_by='topic and peer'),
215
+ st.Store(key='mcache', description='sealed envelopes kept to answer an iwant, with when they were cached',
216
+ kind=st.TABLE, shape=tuple, bound=MCACHE_MAX, keyed_by='topic and event'),
217
+ st.Store(key='totals', description='gossip events this node took part in, since it started',
218
+ kind=st.COUNTERS, shape=GossipTotals),
219
+ _recent('published', 'events this node originated, as (topic, event id)'),
220
+ _recent('delivered', 'events delivered from a mesh peer, as (topic, event id, from)'),
221
+ _recent('offered', 'ihave offers sent, as (topic, peer, ids)'),
222
+ _recent('wanted', 'iwant requests sent, as (topic, peer, ids)'),
223
+ _recent('served', 'iwant answers sent, as (topic, event id, to)'),
224
+ _recent('auto_admitted', 'rosters admitted from a relay, as (topic, roster id)'),
225
+ )
226
+ CHANNELS = (
227
+ ch_mod.Channel('event_published', 'A gossip event was originated and flooded to the topic mesh',
228
+ level=ch_mod.INFO, say='published gossip event {event_id} to topic {topic}',
229
+ tags=('gossip', 'publish')),
230
+ ch_mod.Channel('event_delivered', 'A gossip event was delivered from a mesh peer',
231
+ level=ch_mod.INFO, say='delivered gossip event {event_id} on topic {topic} from {peer}',
232
+ tags=('gossip', 'deliver')),
233
+ ch_mod.Channel('graft_established', "A peer was grafted into this node's topic mesh",
234
+ level=ch_mod.INFO, say='grafted peer {peer} on topic {topic}',
235
+ tags=('gossip', 'mesh')),
236
+ ch_mod.Channel('prune_executed', "A peer was pruned from this node's topic mesh",
237
+ level=ch_mod.NOTE, say='pruned peer {peer} on topic {topic} (reason: {reason})',
238
+ tags=('gossip', 'mesh')),
239
+ )
240
+ METRICS = (
241
+ mx_mod.Metric(name='gossip_events_published', description='Events originated and flooded to topic mesh.',
242
+ kind=mx_mod.COUNTER, counts='event_published', tags=('gossip', 'publish')),
243
+ mx_mod.Metric(name='gossip_events_delivered', description='Events received from mesh peers and delivered.',
244
+ kind=mx_mod.COUNTER, counts='event_delivered', tags=('gossip', 'deliver')),
245
+ mx_mod.Metric(name='gossip_peers_grafted', description='Peers added to local gossip mesh.',
246
+ kind=mx_mod.COUNTER, counts='graft_established', tags=('gossip', 'mesh')),
247
+ mx_mod.Metric(name='gossip_peers_pruned', description='Peers removed from local gossip mesh.',
248
+ kind=mx_mod.COUNTER, counts='prune_executed', tags=('gossip', 'mesh')),
249
+ )
250
+ NEEDS = (
251
+ Need('keystore', 'ks'),
252
+ Need('transport', 'transport'),
253
+ Need('grant_store', 'stores.grants'),
254
+ Need('cfg', block=GossipConfig),
255
+ Need('seen_path', path='gossip_seen.sqlite'),
256
+ Need('roster_path', path='gossip_rosters.sqlite'),
257
+ Need('relays', 'relays'),
258
+ )
259
+ EVERY = (Every(seconds=SEEN_SWEEP_INTERVAL_SECONDS, run='expire_seen'),)
260
+ WORKERS = (
261
+ Worker(run='_listen', name='gossip-rosters', when='relays'),
262
+ )
263
+
264
+ def __init__(
265
+ self,
266
+ keystore: Any,
267
+ transport: Any,
268
+ grant_store: grants_mod.GrantStore,
269
+ clock: Optional[Callable[[], int]] = None,
270
+ node: Optional[Any] = None,
271
+ mesh_degree: int = DEFAULT_MESH_DEGREE,
272
+ seen_ttl_seconds: int = 24 * 3600,
273
+ max_topics: int = 64,
274
+ seen_path: str = ':memory:',
275
+ roster_path: Optional[str] = None,
276
+ relays: Optional[list[str]] = None,
277
+ cfg: Optional[Union[GossipConfig, Mapping[str, Any]]] = None,
278
+ ) -> None:
279
+ super().__init__(node, clock)
280
+ self.ks: Any = keystore
281
+ self.transport: Any = transport
282
+ self.grants: grants_mod.GrantStore = grant_store
283
+ if cfg is not None:
284
+ if isinstance(cfg, GossipConfig):
285
+ mesh_degree = cfg.mesh_degree
286
+ seen_ttl_seconds = cfg.seen_ttl_seconds
287
+ max_topics = cfg.max_topics
288
+ elif isinstance(cfg, Mapping):
289
+ mesh_degree = int(cfg.get('mesh_degree', mesh_degree))
290
+ seen_ttl_seconds = int(cfg.get('seen_ttl_seconds', seen_ttl_seconds))
291
+ max_topics = int(cfg.get('max_topics', max_topics))
292
+ self.mesh_degree: int = mesh_degree
293
+ self.seen_ttl_seconds: int = seen_ttl_seconds
294
+ self.max_topics: int = max_topics
295
+ self.seen: SeenStore = SeenStore(seen_path, ttl_seconds=seen_ttl_seconds)
296
+ self.rosters: RosterStore = RosterStore(roster_path or seen_path)
297
+ self.relays: list[str] = list(relays or []) # not a store: the configured relays
298
+ self._lock: threading.RLock = threading.RLock()
299
+ self._last_seen_sweep: int = 0
300
+ self._closed: bool = False
301
+ self._seen_count: int = 0
302
+ self._stop: threading.Event = getattr(self, '_stop_event', None) or threading.Event()
303
+ self._wake: threading.Event = threading.Event() # set by tick when the granted set moves
304
+ self._watching: set[str] = set() # not a store: a copy of grants.peers(), replaced whole
305
+ # The stores were built and bound by the base (see STORES).
306
+ self.members: st.Table[str, Roster]
307
+ self.peers: st.Table[str, set[str]]
308
+ self.grafted_at: st.Table[tuple[str, str], int]
309
+ self.mcache: st.Table[tuple[str, str], tuple[Mapping[str, Any], int]]
310
+ self.totals: st.Counters
311
+ self.published: st.Ring[tuple[str, str]]
312
+ self.delivered: st.Ring[tuple[str, str, str]]
313
+ self.offered: st.Ring[tuple[str, str, int]]
314
+ self.wanted: st.Ring[tuple[str, str, int]]
315
+ self.served: st.Ring[tuple[str, str, str]]
316
+ self.auto_admitted: st.Ring[tuple[str, str]]
317
+ self._reload_rosters()
318
+
319
+ def _record(self, ring: 'st.Ring[Any]', entry: tuple[Any, ...]) -> None:
320
+ """One gossip event: kept in its ring, and counted in `totals`."""
321
+ ring.append(entry)
322
+ self.totals.incr(ring.store.key)
323
+ if ring.store.key == 'published' and len(entry) >= 2:
324
+ self.say('event_published', topic=entry[0], event_id=entry[1])
325
+ elif ring.store.key == 'delivered' and len(entry) >= 3:
326
+ self.say('event_delivered', topic=entry[0], event_id=entry[1], peer=entry[2])
327
+
328
+ # ---- learning rosters from the relays -------------------------------------
329
+ def _listen(self) -> None:
330
+ while not self._stop.is_set():
331
+ admins = sorted(self.grants.peers())
332
+ self._watching = set(admins) # not a store: a copy of grants.peers(), replaced whole
333
+ self._wake.clear()
334
+ if not admins:
335
+ self._wait_either(30)
336
+ continue
337
+ # A short socket timeout is how the loop notices _wake between frames.
338
+ r = relay_mod.Relay(self.relays[0], timeout=1.0)
339
+ try:
340
+ # NIP-01 indexes single-letter tags only, so `member` cannot be
341
+ # filtered at the relay; admit() drops rosters not naming us.
342
+ sid = r.connect().subscribe([{'kinds': [MEMBERSHIP_KIND], 'authors': admins}])
343
+ for ev in r.events(sid, until=None):
344
+ if self._stop.is_set() or self._wake.is_set():
345
+ break
346
+ self.on_roster(ev)
347
+ if self._wake.is_set():
348
+ continue # re-subscribe now with the new author set
349
+ except Exception as e:
350
+ self.refusals.refuse('subscription', 'could not be held open',
351
+ subject=self.relays[0], detail={'error': str(e)})
352
+ finally:
353
+ r.close()
354
+ self._wait_either(5)
355
+
356
+ def _wait_either(self, seconds: float) -> None:
357
+ """Sleep until `seconds` pass, the node stops, or the granted set moves.
358
+
359
+ Waits on `_wake`, which stop() sets too, in slices of a second so a
360
+ stop that reaches only `_stop` is still seen within one. It polled
361
+ every 0.2 s on the wall clock (audit 2026-10-15, E8).
362
+ """
363
+ deadline = time.monotonic() + seconds
364
+ while not self._stop.is_set() and not self._wake.is_set():
365
+ left = deadline - time.monotonic()
366
+ if left <= 0:
367
+ return
368
+ self._wake.wait(min(1.0, left))
369
+
370
+ def on_roster(self, event: Mapping[str, Any]) -> Optional[str]:
371
+ """A roster read from a relay: admit it if it names this node and is
372
+ from a granted peer; the topic admitted, or None (refusal recorded)."""
373
+ if not isinstance(event, Mapping) or event.get('kind') != MEMBERSHIP_KIND:
374
+ return None
375
+ event = dict(event)
376
+ admin = event.get('pubkey', '')
377
+ if not self._has_grant(admin):
378
+ self.refusals.refuse('membership', 'roster from a peer with no grant', subject=admin)
379
+ return None
380
+ try:
381
+ topic = self.admit(event)
382
+ except GossipError:
383
+ return None # admit() recorded why, or it was not for us
384
+ self._record(self.auto_admitted, (topic, event.get('id', '')))
385
+ return topic
386
+
387
+ def _reload_rosters(self) -> None:
388
+ """Rosters held before the last stop. One that has expired meanwhile is
389
+ dropped here rather than refused on every tick."""
390
+ for topic, event in self.rosters.all().items():
391
+ members = [t[1] for t in event.get('tags', []) if len(t) > 1 and t[0] == 'member']
392
+ if self.check_membership(topic, event, members) is None:
393
+ if not self.members.put(topic, Roster(event, set(members))):
394
+ # More rosters on disk than the topic ceiling - written
395
+ # by a build with a higher one. Kept on disk, not held.
396
+ self.refusals.refuse('membership', 'over the topic ceiling; not reloaded', subject=topic)
397
+ else:
398
+ self.rosters.delete(topic)
399
+
400
+ # ---- membership -----------------------------------------------------------
401
+ def admit_membership(self, topic: str, event: Mapping[str, Any], member_pubkeys: list[str]) -> None:
402
+ """Record a current admin-signed kind 30804 membership event for a topic.
403
+
404
+ A republish of the same `d` (topic) supersedes the one held, matching
405
+ the addressable-event convention every other control event here uses.
406
+ The roster is read from the event's own `member` tags; the caller's
407
+ `member_pubkeys` is accepted only when it matches them, so a caller
408
+ cannot admit a roster the admin did not sign.
409
+ """
410
+ why = self.check_membership(topic, event, member_pubkeys)
411
+ if why is not None:
412
+ self.refusals.refuse('membership', why, subject=topic)
413
+ raise GossipError(why)
414
+ with self._lock:
415
+ held = self.members.get(topic)
416
+ if held and int(held.event.get('created_at', 0)) > int(event.get('created_at', 0)):
417
+ self.refusals.refuse('membership', 'older than the roster held', subject=topic)
418
+ raise GossipError('older than the roster held')
419
+ self.members[topic] = Roster(event, set(member_pubkeys))
420
+ self.rosters.put(topic, event)
421
+
422
+ # ---- operator verbs: the control socket's side of the compartment ----------
423
+ def topic_id(self, name: str) -> str:
424
+ """The coordinate of a topic this node administers: `<node_pk>:<name>`."""
425
+ if not name or ':' in name or len(name) > 64:
426
+ raise GossipError('a topic name is 1-64 characters with no colon')
427
+ return self.ks.node_pk + ':' + name
428
+
429
+ def make_roster(self, name: str, members: list[str], ttl_seconds: int = 30 * 86400) -> NostrEvent:
430
+ """Sign a kind 30804 roster for a topic this node administers.
431
+
432
+ This node is always on its own roster: an admin that is not a member
433
+ would hold a topic it can never graft into. Signing is the slow pure-
434
+ Python curve, so this is an operator action, not something on a tick.
435
+ """
436
+ from fleetmesh_core.storage import store
437
+ topic = self.topic_id(name)
438
+ roster = sorted({self.ks.node_pk, *members})
439
+ if len(roster) < 2:
440
+ raise GossipError('a roster names at least one member besides this node')
441
+ if ttl_seconds <= 0:
442
+ raise GossipError('ttl_seconds is positive')
443
+ now = self.clock()
444
+ tags = [['d', topic]] + [['member', m] for m in roster] + [['expiration', str(now + int(ttl_seconds))]]
445
+ return store.make_signed_event(self.ks.node_sk, now, MEMBERSHIP_KIND, tags, '')
446
+
447
+ def create_topic(self, name: str, members: list[str], ttl_seconds: int = 30 * 86400,
448
+ publish_to_relays: bool = True) -> TopicCreationResult:
449
+ """Sign, admit, and (optionally) publish a roster. Returns {topic, event, relays}.
450
+
451
+ Publishing to the configured relays is how the other members learn the
452
+ roster before any of them has a gossip mesh to learn it over - the one
453
+ place a relay is in the path, and only as a bulletin board.
454
+ """
455
+ event = self.make_roster(name, members, ttl_seconds)
456
+ topic = self.topic_id(name)
457
+ if topic not in self.members and len(self.members) >= self.max_topics:
458
+ raise GossipError('this node holds its maximum of %d topics' % self.max_topics)
459
+ self.admit_membership(topic, event, sorted(self.members_of(event)))
460
+ out: TopicCreationResult = {'topic': topic, 'event': event, 'relays': {}}
461
+ if publish_to_relays and self.relays:
462
+ out['relays'] = {u: list(r) for u, r in relay_mod.publish_all(self.relays, event, 15.0).items()}
463
+ return out
464
+
465
+ @staticmethod
466
+ def members_of(event: Mapping[str, Any]) -> set[str]:
467
+ return {t[1] for t in event.get('tags', []) if len(t) > 1 and t[0] == 'member'}
468
+
469
+ def admit(self, event: Mapping[str, Any]) -> str:
470
+ """Admit a roster another admin signed (fetched from a relay, pasted by
471
+ the operator). The topic is the event's own `d`; returns it."""
472
+ d = [t[1] for t in event.get('tags', []) if len(t) > 1 and t[0] == 'd']
473
+ if len(d) != 1:
474
+ raise GossipError('roster has no single d tag')
475
+ if self.ks.node_pk not in self.members_of(event):
476
+ raise GossipError('this node is not named on that roster')
477
+ if d[0] not in self.members and len(self.members) >= self.max_topics:
478
+ raise GossipError('this node holds its maximum of %d topics' % self.max_topics)
479
+ self.admit_membership(d[0], event, sorted(self.members_of(event)))
480
+ return d[0]
481
+
482
+ def leave(self, topic: str) -> int:
483
+ """Drop a roster and prune every mesh peer on it. Returns peers pruned."""
484
+ with self._lock:
485
+ if topic not in self.members:
486
+ raise GossipError('not a member of %s' % topic)
487
+ peers = list(self.peers.get(topic, set()))
488
+ for p in peers:
489
+ self.prune(topic, p, reason='left')
490
+ with self._lock:
491
+ self.members.pop(topic, None)
492
+ self.peers.pop(topic, None)
493
+ self.rosters.delete(topic)
494
+ return len(peers)
495
+
496
+ def originate(self, topic: str, envelope: Mapping[str, Any]) -> OriginateResult:
497
+ """Flood an already-sealed NIP-59 wrap (kind 1059/13/21059) into a topic.
498
+
499
+ The compartment carries ciphertext only; a plaintext kind here is a
500
+ refusal, not a convenience, because the mesh is every member's peers.
501
+ """
502
+ if not self._is_member(topic, self.ks.node_pk):
503
+ raise GossipError('not a member of %s' % topic)
504
+ if envelope.get('kind') not in (13, 1059, 21059):
505
+ raise GossipError('only sealed NIP-59 envelopes (kind 13, 1059, 21059) travel on gossip')
506
+ event_id = envelope.get('id')
507
+ if not isinstance(event_id, str) or len(event_id) != 64:
508
+ raise GossipError('envelope has no 64-hex id')
509
+ if not giftwrap.verify_event(envelope):
510
+ raise GossipError('envelope signature does not verify')
511
+ sent = self.publish(topic, event_id, envelope)
512
+ return {'topic': topic, 'event_id': event_id, 'sent_to': sent,
513
+ 'mesh': len(self.peers.get(topic, set()))}
514
+
515
+ def check_membership(self, topic: str, event: Mapping[str, Any], member_pubkeys: list[str]) -> Optional[str]:
516
+ """Why this event is not a current kind 30804 roster for `topic`, or None.
517
+
518
+ Kind, signature, `d` coordinate scoped to the signing admin, a roster
519
+ that is what the tags say, and a NIP-40 expiration still ahead - in
520
+ that order, cheapest refusal first.
521
+ """
522
+ if event.get('kind') != MEMBERSHIP_KIND:
523
+ return 'not a kind %d' % MEMBERSHIP_KIND
524
+ tags = event.get('tags') or []
525
+ d = [t[1] for t in tags if len(t) > 1 and t[0] == 'd']
526
+ if d != [topic]:
527
+ return 'd tag does not name this topic'
528
+ admin = event.get('pubkey', '')
529
+ if not topic.startswith(admin + ':'):
530
+ return 'topic coordinate is not scoped to the signing admin'
531
+ members = {t[1] for t in tags if len(t) > 1 and t[0] == 'member'}
532
+ if not members or members != set(member_pubkeys):
533
+ return 'member tags do not match the roster offered'
534
+ exp = [t[1] for t in tags if len(t) > 1 and t[0] == 'expiration']
535
+ if len(exp) != 1 or not exp[0].isdigit():
536
+ return 'no single expiration tag'
537
+ if int(exp[0]) <= self.clock():
538
+ return 'roster has expired'
539
+ if not giftwrap.verify_event(event):
540
+ return 'signature does not verify'
541
+ return None
542
+
543
+ def _is_member(self, topic: str, pubkey: str) -> bool:
544
+ with self._lock:
545
+ row = self.members.get(topic)
546
+ return bool(row and pubkey in row.members)
547
+
548
+ def i_am_member(self, topic: str) -> bool:
549
+ return self._is_member(topic, self.ks.node_pk)
550
+
551
+ # ---- grant integration (no new authorization invented) ---------------------
552
+ def _has_grant(self, peer: str) -> bool:
553
+ """Only graft a peer this node already shares a peering grant with.
554
+
555
+ Reuses the existing accounting relationship rather than a second
556
+ authorization system: a peer with no grant in either direction has no
557
+ accounted channel for this node to meter gossip traffic against.
558
+ """
559
+ issued = self.grants.get(peer, grants_mod.ISSUED)
560
+ received = self.grants.get(peer, grants_mod.RECEIVED)
561
+ live = lambda r: bool(r) and r.get('status', 'live') == 'live'
562
+ return live(issued) or live(received)
563
+
564
+ # ---- graft / prune: mesh maintenance --------------------------------------
565
+ def handle_graft(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
566
+ topic = body.get('topic')
567
+ if not isinstance(topic, str) or not topic:
568
+ self.refusals.refuse('graft', 'no topic named', subject=peer)
569
+ return ('graft refused', peer, 'no topic named')
570
+ if not self._is_member(topic, self.ks.node_pk):
571
+ self.refusals.refuse('graft', 'no membership event for this topic', subject=peer, detail={'topic': topic})
572
+ return ('graft refused', peer, 'no membership event')
573
+ if not self._is_member(topic, peer):
574
+ self.refusals.refuse('graft', 'peer is not a named member', subject=peer, detail={'topic': topic})
575
+ return ('graft refused', peer, 'peer not a member')
576
+ if not self._has_grant(peer):
577
+ self.refusals.refuse('graft', 'no peering grant with this peer', subject=peer, detail={'topic': topic})
578
+ return ('graft refused', peer, 'no grant')
579
+ with self._lock:
580
+ current = self.peers.setdefault(topic, set())
581
+ if peer in current:
582
+ return ('graft already present', peer, topic)
583
+ if len(current) >= self.mesh_degree:
584
+ self.refusals.refuse('graft', 'mesh is already at target degree', subject=peer, detail={'topic': topic})
585
+ return ('graft refused', peer, 'mesh full')
586
+ current.add(peer)
587
+ self.grafted_at[(topic, peer)] = self.clock()
588
+ self.say('graft_established', topic=topic, peer=peer)
589
+ return ('grafted', peer, topic)
590
+
591
+ def handle_prune(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
592
+ topic = body.get('topic')
593
+ if not isinstance(topic, str):
594
+ return ('prune ignored', peer, 'no topic named')
595
+ with self._lock:
596
+ self.peers.get(topic, set()).discard(peer)
597
+ self.grafted_at.pop((topic, peer), None)
598
+ self.say('prune_executed', topic=topic, peer=peer, reason='remote_prune')
599
+ return ('pruned', peer, topic)
600
+
601
+ def graft(self, topic: str, peer: str) -> bool:
602
+ """This node asks `peer` to add it to their mesh for `topic`."""
603
+ row = self.members.get(topic)
604
+ if row is None:
605
+ self.refusals.refuse('graft', 'no membership event for this topic', subject=peer, detail={'topic': topic})
606
+ return False
607
+ msg = make_graft(self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk, peer, topic,
608
+ row.event.get('id', ''))
609
+ ok = self._send(peer, msg)
610
+ if ok:
611
+ with self._lock:
612
+ self.peers.setdefault(topic, set()).add(peer)
613
+ self.grafted_at[(topic, peer)] = self.clock()
614
+ self.say('graft_established', topic=topic, peer=peer)
615
+ return ok
616
+
617
+ def prune(self, topic: str, peer: str, reason: str = 'degree') -> bool:
618
+ msg = make_prune(self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk, peer, topic, reason=reason)
619
+ with self._lock:
620
+ self.peers.get(topic, set()).discard(peer)
621
+ self.grafted_at.pop((topic, peer), None)
622
+ ok = self._send(peer, msg)
623
+ self.say('prune_executed', topic=topic, peer=peer, reason=reason)
624
+ return ok
625
+
626
+ # ---- publish: the flood ----------------------------------------------------
627
+ def handle_publish(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
628
+ topic = body.get('topic')
629
+ event_id = body.get('event_id')
630
+ envelope = body.get('envelope')
631
+ if not isinstance(topic, str) or not isinstance(event_id, str) or not isinstance(envelope, dict):
632
+ self.refusals.refuse('publish', 'malformed body', subject=peer)
633
+ return ('publish refused', peer, 'malformed body')
634
+ if not self._is_member(topic, self.ks.node_pk):
635
+ self.refusals.refuse('publish', 'no membership event for this topic', subject=peer, detail={'topic': topic})
636
+ return ('publish refused', peer, 'not a member')
637
+ if not self.seen.mark(topic, event_id, self.clock()):
638
+ return ('publish dropped', peer, 'already seen')
639
+ self._record(self.delivered, (topic, event_id, peer))
640
+ self._cache(topic, event_id, envelope)
641
+ self._flood(topic, event_id, envelope, exclude={peer})
642
+ return ('publish delivered', peer, event_id)
643
+
644
+ def publish(self, topic: str, event_id: str, envelope: Mapping[str, Any]) -> list[str]:
645
+ """Originate a publish into this topic's mesh. Returns peers flooded to."""
646
+ if not self.seen.mark(topic, event_id, self.clock()):
647
+ return []
648
+ self._record(self.published, (topic, event_id))
649
+ self._cache(topic, event_id, envelope)
650
+ return self._flood(topic, event_id, envelope, exclude=set())
651
+
652
+ # ---- ihave / iwant: lazy gossip to members outside the mesh ----------------
653
+ def _cache(self, topic: str, event_id: str, envelope: Mapping[str, Any]) -> None:
654
+ with self._lock:
655
+ self.mcache[(topic, event_id)] = (envelope, self.clock())
656
+ mine = [k for k in self.mcache if k[0] == topic]
657
+ if len(mine) > MCACHE_MAX_PER_TOPIC:
658
+ mine.sort(key=lambda k: self.mcache[k][1])
659
+ for k in mine[:len(mine) - MCACHE_MAX_PER_TOPIC]:
660
+ del self.mcache[k]
661
+
662
+ def _sweep_mcache(self, now: int) -> int:
663
+ with self._lock:
664
+ old = [k for k, (_, at) in self.mcache.items() if now - at > MCACHE_SECONDS]
665
+ for k in old:
666
+ del self.mcache[k]
667
+ return len(old)
668
+
669
+ def _recent_ids(self, topic: str) -> list[str]:
670
+ with self._lock:
671
+ ids = [(at, k[1]) for k, (_, at) in self.mcache.items() if k[0] == topic]
672
+ ids.sort(reverse=True)
673
+ return [i for _, i in ids[:IHAVE_MAX_IDS]]
674
+
675
+ def _me(self) -> str:
676
+ return self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk
677
+
678
+ def emit_gossip(self, topic: str) -> list[tuple[Any, ...]]:
679
+ """Offer this topic's recent ids to up to GOSSIP_LAZY_DEGREE members
680
+ that are *not* in this node's mesh (the mesh already got the flood).
681
+ Only members with a grant: an ihave is a promise to answer iwant."""
682
+ ids = self._recent_ids(topic)
683
+ if not ids:
684
+ return []
685
+ with self._lock:
686
+ mesh = self.peers.get(topic, set())
687
+ row = self.members.get(topic)
688
+ if row is None:
689
+ return []
690
+ outside = [p for p in row.members if p != self.ks.node_pk and p not in mesh]
691
+ did: list[tuple[Any, ...]] = []
692
+ for peer in outside:
693
+ if len(did) >= GOSSIP_LAZY_DEGREE:
694
+ break
695
+ if not self._has_grant(peer):
696
+ continue
697
+ if self._send(peer, make_ihave(self._me(), peer, topic, ids)):
698
+ self._record(self.offered, (topic, peer, len(ids)))
699
+ did.append(('ihave', topic, peer))
700
+ return did
701
+
702
+ def handle_ihave(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
703
+ topic = body.get('topic')
704
+ ids = body.get('ids')
705
+ if not isinstance(topic, str) or not isinstance(ids, list):
706
+ self.refusals.refuse('ihave', 'malformed body', subject=peer)
707
+ return ('ihave refused', peer, 'malformed body')
708
+ if not self._is_member(topic, self.ks.node_pk) or not self._is_member(topic, peer):
709
+ self.refusals.refuse('ihave', 'not both members of this topic', subject=peer, detail={'topic': topic})
710
+ return ('ihave refused', peer, 'not a member')
711
+ missing = [i for i in ids[:IHAVE_MAX_IDS] if isinstance(i, str) and not self.seen.seen(topic, i)]
712
+ if not missing:
713
+ return ('ihave nothing missing', peer, topic)
714
+ if self._send(peer, make_iwant(self._me(), peer, topic, missing)):
715
+ self._record(self.wanted, (topic, peer, len(missing)))
716
+ return ('iwant sent', peer, len(missing))
717
+ return ('iwant unsent', peer, len(missing))
718
+
719
+ def handle_iwant(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
720
+ topic = body.get('topic')
721
+ ids = body.get('ids')
722
+ if not isinstance(topic, str) or not isinstance(ids, list):
723
+ self.refusals.refuse('iwant', 'malformed body', subject=peer)
724
+ return ('iwant refused', peer, 'malformed body')
725
+ if not self._is_member(topic, self.ks.node_pk) or not self._is_member(topic, peer):
726
+ self.refusals.refuse('iwant', 'not both members of this topic', subject=peer, detail={'topic': topic})
727
+ return ('iwant refused', peer, 'not a member')
728
+ sent = 0
729
+ for event_id in ids[:IHAVE_MAX_IDS]:
730
+ if not isinstance(event_id, str):
731
+ continue
732
+ with self._lock:
733
+ hit = self.mcache.get((topic, event_id))
734
+ if hit is None:
735
+ continue # fell out of the cache: the asker will hear it from someone else
736
+ if self._send(peer, make_publish(self._me(), peer, topic, event_id, hit[0])):
737
+ self._record(self.served, (topic, event_id, peer))
738
+ sent += 1
739
+ return ('iwant served', peer, sent)
740
+
741
+ def _flood(self, topic: str, event_id: str, envelope: Mapping[str, Any], exclude: set[str]) -> list[str]:
742
+ """Send to every mesh peer for `topic` except `exclude` - which always
743
+ contains the peer this event arrived from, so it is never re-flooded
744
+ back toward its source. This is the strict loop-prevention rule."""
745
+ with self._lock:
746
+ targets = list(self.peers.get(topic, set()) - exclude)
747
+ sent: list[str] = []
748
+ for peer in targets:
749
+ msg = make_publish(self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk, peer,
750
+ topic, event_id, envelope)
751
+ if self._send(peer, msg):
752
+ sent.append(peer)
753
+ return sent
754
+
755
+ # ---- transport (rides the existing transport ladder) -----------------------
756
+ def _send(self, peer: str, message: Mapping[str, Any]) -> bool:
757
+ # TransportRouter.send(message, to_pubkey) -> {rung: (ok, why)}; the
758
+ # ladder picks the rung. Sent means at least one rung took it.
759
+ try:
760
+ res = self.wire_client.send(peer, message)
761
+ if isinstance(res, dict) and res and not any(ok for ok, _ in res.values()):
762
+ self.refusals.refuse('transport', 'no rung accepted', subject=peer,
763
+ detail={'why': [w for _, w in res.values()][:2]})
764
+ return False
765
+ return True
766
+ except Exception as e:
767
+ self.refusals.refuse('transport', 'send failed', subject=peer, detail={'error': str(e)})
768
+ return False
769
+
770
+ # ---- dispatch ---------------------------------------------------------------
771
+ def wire_graft(self, inbound: Any, sender: str) -> tuple[Any, ...]:
772
+ msg = getattr(inbound, 'message', inbound)
773
+ return self.handle_graft(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
774
+
775
+ def wire_prune(self, inbound: Any, sender: str) -> tuple[Any, ...]:
776
+ msg = getattr(inbound, 'message', inbound)
777
+ return self.handle_prune(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
778
+
779
+ def wire_publish(self, inbound: Any, sender: str) -> tuple[Any, ...]:
780
+ msg = getattr(inbound, 'message', inbound)
781
+ return self.handle_publish(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
782
+
783
+ def wire_ihave(self, inbound: Any, sender: str) -> tuple[Any, ...]:
784
+ msg = getattr(inbound, 'message', inbound)
785
+ return self.handle_ihave(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
786
+
787
+ def wire_iwant(self, inbound: Any, sender: str) -> tuple[Any, ...]:
788
+ msg = getattr(inbound, 'message', inbound)
789
+ return self.handle_iwant(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
790
+
791
+ # ---- the compartment contract -------------------------------------------------
792
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
793
+ did: list[tuple[Any, ...]] = []
794
+ if self.relays and set(self.grants.peers()) != self._watching:
795
+ self._wake.set() # a new grant: the roster subscription needs its author
796
+ with self._lock:
797
+ topics = list(self.members.keys())
798
+ for topic in topics:
799
+ if not self.i_am_member(topic):
800
+ continue
801
+ with self._lock:
802
+ current = self.peers.get(topic, set())
803
+ degree = len(current)
804
+ candidates = [p for p in self.members[topic].members
805
+ if p != self.ks.node_pk and p not in current]
806
+ if degree < self.mesh_degree:
807
+ for peer in candidates:
808
+ if len(current) >= self.mesh_degree:
809
+ break
810
+ if not self._has_grant(peer):
811
+ continue
812
+ if self.graft(topic, peer):
813
+ did.append(('regrafted', topic, peer))
814
+ current = self.peers.get(topic, set())
815
+ # Prune a peer no longer named in a superseding membership event.
816
+ with self._lock:
817
+ stale = [p for p in self.peers.get(topic, set()) if not self._is_member(topic, p)]
818
+ for peer in stale:
819
+ self.prune(topic, peer, reason='no longer a member')
820
+ did.append(('pruned stale', topic, peer))
821
+ did.extend(self.emit_gossip(topic))
822
+ self._sweep_mcache(now)
823
+ did += self.run_every(now)
824
+ return did
825
+
826
+ _tick_runs_every = True
827
+
828
+ def expire_seen(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
829
+ now = now if now is not None else self.clock()
830
+ removed = self.seen.expire(now)
831
+ self._last_seen_sweep = now
832
+ if removed:
833
+ return [('seen store swept', removed, None)]
834
+ return []
835
+
836
+ def state(self) -> GossipStateReport:
837
+ with self._lock:
838
+ mesh = {t: len(p) for t, p in self.peers.items()}
839
+ topics = sorted(self.members.keys())
840
+ totals = self.totals.as_dict()
841
+ return {
842
+ 'topics': topics,
843
+ 'mesh': mesh,
844
+ # The store is closed after stop(); health() still reads state.
845
+ 'seen': self._seen_count if getattr(self, '_closed', False) else self._count_seen(),
846
+ # Counted, not measured: each was len() of a list that grew
847
+ # forever. The rings beside them keep only the recent ones.
848
+ 'published': totals['published'],
849
+ 'auto_admitted': totals['auto_admitted'],
850
+ 'delivered': totals['delivered'],
851
+ 'offered': totals['offered'],
852
+ 'wanted': totals['wanted'],
853
+ 'served': totals['served'],
854
+ 'mcache': len(self.mcache),
855
+ 'refused': self.refusals.total(),
856
+ }
857
+
858
+ def trouble(self) -> list[str]:
859
+ out: list[str] = []
860
+ refused = self.refusals.within(self.TROUBLE_WINDOW, self.clock(), kind='graft')
861
+ if refused:
862
+ peers = sorted({(r.subject or '')[:16] for r in refused})
863
+ out.append('%d graft(s) refused in the last window from %s: no membership event, '
864
+ 'not a named member, no grant, or the mesh is already at target degree'
865
+ % (len(refused), ', '.join(peers)))
866
+ with self._lock:
867
+ thin = [t for t, p in self.peers.items() if len(p) < max(1, self.mesh_degree // 2)]
868
+ if thin:
869
+ out.append('%d topic(s) below half target mesh degree: %s'
870
+ % (len(thin), ', '.join(sorted(thin)[:5])))
871
+ return out
872
+
873
+ def _count_seen(self) -> int:
874
+ self._seen_count = self.seen.count()
875
+ return self._seen_count
876
+
877
+ def topics_cmd(self) -> list[TopicView]:
878
+ with self._lock:
879
+ return [{'topic': t, 'admin': row.event.get('pubkey'),
880
+ 'members': sorted(row.members),
881
+ 'mesh': sorted(self.peers.get(t, set())),
882
+ 'expires': int(next((x[1] for x in row.event.get('tags', [])
883
+ if x[0] == 'expiration'), 0)),
884
+ 'roster_id': row.event.get('id')}
885
+ for t, row in sorted(self.members.items())]
886
+
887
+ def create_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
888
+ try:
889
+ return self.create_topic(str(args['name']), list(args.get('members') or []),
890
+ int(args.get('ttl_seconds') or 30 * 86400),
891
+ publish_to_relays=not args.get('no_relay'))
892
+ except GossipError as e:
893
+ from node.app import socket as control_mod
894
+ raise control_mod.ControlError(str(e))
895
+
896
+ def admit_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
897
+ try:
898
+ return {'topic': self.admit(dict(args['event']))}
899
+ except GossipError as e:
900
+ from node.app import socket as control_mod
901
+ raise control_mod.ControlError(str(e))
902
+
903
+ def leave_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
904
+ try:
905
+ return {'topic': args['topic'], 'pruned': self.leave(str(args['topic']))}
906
+ except GossipError as e:
907
+ from node.app import socket as control_mod
908
+ raise control_mod.ControlError(str(e))
909
+
910
+ def publish_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
911
+ try:
912
+ return self.originate(str(args['topic']), dict(args['envelope']))
913
+ except GossipError as e:
914
+ from node.app import socket as control_mod
915
+ raise control_mod.ControlError(str(e))
916
+
917
+ def stop(self) -> Any:
918
+ self._closed = True
919
+ self._stop.set()
920
+ self._wake.set() # ends _wait_either now, not at its next slice
921
+ for s in (self.seen, self.rosters):
922
+ try:
923
+ s.close()
924
+ except Exception:
925
+ pass
926
+ return super().stop()
927
+
928
+
929
+ __all__ = ['GossipMesh', 'PIURI', 'MEMBERSHIP_KIND', 'MCACHE_SECONDS', 'GOSSIP_LAZY_DEGREE']