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,522 @@
1
+ """What this node tells the mesh it is and what it holds, as a compartment.
2
+
3
+ Two publications with one shape: a kind 30166 per endpoint for NIP-66 readers,
4
+ and a kind 30803 per shard for anything looking for data rather than for nodes.
5
+ Both are addressable, both are republished only when what they say changes, and
6
+ both can be refused - a discovery event that contradicts the descriptor, a
7
+ coverage claim a leaf may not make.
8
+
9
+ Those refusals went into the tick's return value and nowhere else. `coverage
10
+ claim refused` was printed once and then gone: an operator who had added a shard
11
+ to the config and never saw it claimed had the reason in a journal line that had
12
+ already scrolled, and nothing to ask.
13
+
14
+ The forward clock lives here because both of these are addressable events under
15
+ a stable `d`, and so is the report the reporter publishes, which borrows it.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Callable, Mapping, MutableMapping, Optional, Sequence
20
+
21
+ import dataclasses
22
+ from fleetmesh_core.peering import coverage
23
+ from fleetmesh_core.services import discovery as ref_discovery
24
+ from fleetmesh_core.storage import store as ref_store
25
+ from fleetmesh_core.wire.wire_types import HandoverLink
26
+ from node.compartment import stores as st
27
+ from node.channels import Channel, NOTE, TROUBLE
28
+ from node.metrics import COUNTER, Metric
29
+ from node.protocols.nostr import client as relay
30
+ from node.planes.store.sync import rules as handover
31
+ from node.planes.store.sync.rules import CoverageShardConfig
32
+ from node.interfaces import NostrEvent
33
+ from node.compartment import Command, Compartment, Every, Need
34
+ from node.config.parameter import Parameter, ParamBlock
35
+
36
+
37
+ from .types import DiscoveryStateReport
38
+
39
+
40
+ def _range_of(claim: NostrEvent) -> tuple[Optional[str], Optional[str]]:
41
+ """The (since, until) a kind 30803 declares, as tagged."""
42
+ tags = {t[0]: t[1] for t in claim['tags'] if len(t) > 1 and t[0] in ('since', 'until')}
43
+ return tags.get('since'), tags.get('until')
44
+
45
+
46
+ @dataclasses.dataclass(frozen=True)
47
+ class DiscoveryConfig(ParamBlock):
48
+ key = 'discovery'
49
+ name = 'discovery'
50
+ DERIVED: bool = False
51
+ PARAMS = (
52
+ Parameter(
53
+ key='coverage_interval', name='Coverage Publication Interval',
54
+ description='Seconds between coverage claim publications',
55
+ type=int, default=60, minimum=10,
56
+ tags=('discovery', 'coverage'),
57
+ ),
58
+ Parameter(
59
+ key='discovery_interval', name='Discovery Publication Interval',
60
+ description='Seconds between discovery endpoint publications',
61
+ type=int, default=300, minimum=30,
62
+ tags=('discovery', 'announcement'),
63
+ ),
64
+ )
65
+ coverage_interval: int = 60
66
+ discovery_interval: int = 300
67
+
68
+
69
+ class Discovery(Compartment):
70
+ requires: tuple[str, ...] = ('capacity',)
71
+
72
+ name: str = 'discovery'
73
+ reads: tuple[str, ...] = ('coverage', 'class')
74
+ config_cls = DiscoveryConfig
75
+ live: tuple[str, ...] = ('coverage',)
76
+ CHANNELS = (
77
+ Channel('coverage_narrowed', 'Coverage claim narrowed under capacity pressure', level=NOTE,
78
+ say='shard {shard} is no longer claimed while this node sheds: it is held through the mesh and the node is at its ceiling'),
79
+ Channel('coverage_downgraded', 'Coverage completeness downgraded to best-effort', level=NOTE,
80
+ say='shard {shard} is claimed best-effort, not asserted: some of it arrived through the mesh and this node cannot stand behind it'),
81
+ Channel('handover_dropped', 'Handover chain dropped for shard', level=TROUBLE,
82
+ say='shard {shard} handover from {from_peer} dropped: {why}'),
83
+ )
84
+ METRICS = (
85
+ Metric(name='discovery_coverage_narrowed', description='Times coverage claims were narrowed.',
86
+ kind=COUNTER, counts='coverage_narrowed', tags=('discovery', 'coverage')),
87
+ Metric(name='discovery_coverage_downgraded', description='Times coverage claims were downgraded.',
88
+ kind=COUNTER, counts='coverage_downgraded', tags=('discovery', 'coverage')),
89
+ Metric(name='discovery_handover_dropped', description='Times handover chains were dropped.',
90
+ kind=COUNTER, counts='handover_dropped', tags=('discovery', 'handover')),
91
+ )
92
+ EVERY = (
93
+ Every(seconds=60, run='publish_coverage', name='coverage'),
94
+ Every(seconds=300, run='publish_discovery', name='discovery'),
95
+ )
96
+ NEEDS = (
97
+ Need('keystore', 'ks'),
98
+ Need('store', 'stores.events'),
99
+ Need('face'),
100
+ Need('relays'),
101
+ Need('capacity'),
102
+ Need('timeout'),
103
+ Need('persist_cfg'),
104
+ Need('descriptor'),
105
+ Need('accounts', 'stores.accounts'),
106
+ Need('cfg'),
107
+ )
108
+ STORES = (
109
+ st.Store(
110
+ key='_discovery',
111
+ description='d -> what was last put on the relays',
112
+ bound=1024,
113
+ evict=st.LRU,
114
+ kind=st.TABLE,
115
+ keyed_by='d',
116
+ ),
117
+ st.Store(
118
+ key='_coverage',
119
+ description='shard -> (fingerprint, completeness, range) last published',
120
+ bound=1024,
121
+ evict=st.LRU,
122
+ kind=st.TABLE,
123
+ keyed_by='shard',
124
+ ),
125
+ st.Store(
126
+ key='_claims',
127
+ description='shard -> the signed kind 30803 last published',
128
+ bound=1024,
129
+ evict=st.LRU,
130
+ kind=st.TABLE,
131
+ keyed_by='shard',
132
+ ),
133
+ st.Store(
134
+ key='_handed',
135
+ description='shard -> pubkey of the peer whose handover the claim rests on',
136
+ bound=1024,
137
+ evict=st.LRU,
138
+ kind=st.TABLE,
139
+ keyed_by='shard',
140
+ ),
141
+ st.Store(
142
+ key='_narrowed',
143
+ description='shard -> whether it is currently held back under pressure',
144
+ bound=1024,
145
+ evict=st.LRU,
146
+ kind=st.TABLE,
147
+ keyed_by='shard',
148
+ ),
149
+ st.Store(
150
+ key='_downgraded',
151
+ description='shard -> the size at which it was last said to be best-effort',
152
+ bound=1024,
153
+ evict=st.LRU,
154
+ kind=st.TABLE,
155
+ keyed_by='shard',
156
+ ),
157
+ st.Store(
158
+ key='_forward',
159
+ description='addressable coordinate -> last created_at stamped',
160
+ bound=1024,
161
+ evict=st.LRU,
162
+ kind=st.TABLE,
163
+ keyed_by='coordinate',
164
+ ),
165
+ )
166
+ COMMANDS = (
167
+ Command(
168
+ key='show',
169
+ name='Discovery',
170
+ description='what this node has published about itself and about the shards it holds',
171
+ tags=('discovery', 'status'),
172
+ permission='discovery/show',
173
+ run='state',
174
+ reply='{discovery, coverage, handed, narrowed, downgraded}',
175
+ ),
176
+ )
177
+
178
+ def __init__(
179
+ self,
180
+ keystore: Any,
181
+ store: Any,
182
+ face: Any,
183
+ relays: Sequence[str],
184
+ capacity: Optional[Any] = None,
185
+ clock: Optional[Callable[[], int]] = None,
186
+ timeout: float = 15.0,
187
+ node: Optional[Any] = None,
188
+ persist_cfg: Optional[Callable[[], None]] = None,
189
+ descriptor: Optional[Callable[[], Optional[NostrEvent]]] = None,
190
+ accounts: Optional[Any] = None,
191
+ cfg: Optional[MutableMapping[str, Any]] = None,
192
+ publisher: Optional[Callable[[Sequence[str], Any, float], Any]] = None,
193
+ ) -> None:
194
+ super().__init__(node, clock)
195
+ # How a discovery or coverage event reaches the relays; given so a
196
+ # suite can record them without a socket.
197
+ self.publisher: Callable[[Sequence[str], Any, float], Any] = publisher or relay.publish_all
198
+ # Named from ctx in a node: the descriptor getter (built in start(),
199
+ # after this), the account store a handover chain is read from, and
200
+ # the live config dict a closed range is written into before
201
+ # persist_cfg saves it. A fake node in a suite supplies the same.
202
+ self.descriptor: Callable[[], Optional[NostrEvent]] = descriptor or (lambda: getattr(node, 'descriptor', None))
203
+ self.accounts: Optional[Any] = accounts if accounts is not None else getattr(node, 'accounts', None)
204
+ self._live_cfg: Optional[MutableMapping[str, Any]] = cfg if cfg is not None else getattr(node, 'cfg', None)
205
+ self.ks: Any = keystore
206
+ self.store: Any = store
207
+ self.face: Any = face
208
+ self.relays: list[str] = list(relays) # not a store: the configured relays
209
+ self.capacity: Optional[Any] = capacity
210
+ # How a changed coverage claim is written back; the node's writer
211
+ # when assembled, else the legacy reach into node.save_coverage.
212
+ self.persist_cfg: Callable[[], None] = persist_cfg or (
213
+ lambda: getattr(node, 'save_coverage', lambda: None)())
214
+ self.timeout: float = timeout
215
+ self._discovery: st.Table[str, str] # d -> what was last put on the relays, so a tick republishes only a change
216
+ self._coverage: st.Table[str, tuple[str, str, tuple[Optional[str], Optional[str]]]] # shard -> (fingerprint, completeness, range) last published
217
+ # shard -> the signed kind 30803 last published, which is what a handover copies its terms from
218
+ self._claims: st.Table[str, NostrEvent]
219
+ self._handed: st.Table[str, str] # shard -> pubkey of the peer whose handover the claim rests on
220
+ self._narrowed: st.Table[str, bool] # shard -> whether it is currently held back under pressure
221
+ self._downgraded: st.Table[str, int] # shard -> the size at which it was last said to be best-effort
222
+ self._forward: st.Table[str, int] # addressable coordinate -> last created_at stamped
223
+
224
+ _tick_runs_every = True
225
+
226
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
227
+ return self.publish_coverage(now) + self.publish_discovery(now)
228
+
229
+ # ---- the forward clock ----------------------------------------------------
230
+ def forward_clock(self, key: str, now: int) -> int:
231
+ """A timestamp strictly later than the last one used for this key.
232
+
233
+ Every addressable event a node republishes under a stable `d` - the
234
+ grant envelope, the coverage claim, the report - replaces the last one
235
+ at that coordinate, and NIP-01 breaks a created_at tie on the *lower*
236
+ id. Two written in the same second therefore leave the relay holding
237
+ whichever id happens to sort first, which is the older one about half
238
+ the time. Supersession only runs forwards, so the clock this node
239
+ stamps them with only runs forwards too.
240
+ """
241
+ last = self._forward.get(key)
242
+ if last is not None and now <= last:
243
+ now = last + 1
244
+ self._forward[key] = now
245
+ return now
246
+
247
+ # ---- the node's own face --------------------------------------------------
248
+ def publish_discovery(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
249
+ """The node's face described for NIP-66 readers: one kind 30166 per
250
+ ws or wss endpoint the descriptor declares. Bridge 1 in the
251
+ specification. It says what the face is and accepts and measures
252
+ nothing, so it changes only when the descriptor, the NIP-11 document
253
+ or the claimed shards do, and is republished only then."""
254
+ now_val = now if now is not None else self.clock()
255
+ did: list[tuple[Any, ...]] = []
256
+ descriptor = self.descriptor()
257
+ if not self.relays or descriptor is None or self.face is None:
258
+ return did
259
+ shards = [c['shard'] for c in self.config('coverage') or [] if isinstance(c, dict) and c.get('shard')]
260
+ for ev in ref_discovery.discovery(descriptor, self.face.nip11(), shards, now_val):
261
+ d = [t[1] for t in ev['tags'] if t[0] == 'd'][0]
262
+ fingerprint = ref_discovery.settled(ev)
263
+ if self._discovery.get(d) == fingerprint:
264
+ continue
265
+ problems = ref_discovery.check_discovery(ev, descriptor)
266
+ if problems:
267
+ self.refusals.refuse('discovery', 'contradicts the descriptor', subject=d,
268
+ detail={'problems': problems[:2]}, at=now_val)
269
+ did.append(('discovery refused', d, '; '.join(problems[:2])))
270
+ continue
271
+ at = self.forward_clock('discovery:' + d, now_val)
272
+ signed = ref_store.make_signed_event(self.ks.node_sk, at, ref_discovery.KIND,
273
+ ev['tags'], ev['content'])
274
+ self.publisher(self.relays, signed, self.timeout)
275
+ self._discovery[d] = fingerprint
276
+ did.append(('discovery', d, '%d nips' % len([t for t in ev['tags'] if t[0] == 'N'])))
277
+ return did
278
+
279
+ # ---- what this node holds -------------------------------------------------
280
+ def publish_coverage(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
281
+ """Tell the mesh which shards this node holds, as kind 30803.
282
+
283
+ Nothing published one of these before, so no peer could discover what
284
+ any node held: the whole of data discovery, as distinct from node
285
+ discovery, was missing.
286
+
287
+ `asserted` is a falsifiable claim - a peer may sample the shard, find a
288
+ hole, and file an S3 with the missing event id as evidence - and the
289
+ specification forbids asserting completeness over a shard held only
290
+ through mesh replication, because that is how third-party replication
291
+ launders unverified peer data into an authoritative record. So the
292
+ operator's `asserted` is a request and not a decision: it is honoured
293
+ only when every event in the shard arrived here directly, and downgraded
294
+ to `best-effort` otherwise, with the reason logged. A leaf publishes
295
+ neither, having nothing to claim.
296
+
297
+ The one door through that is a handover: the first-hand holder's signed
298
+ statement over the set, received on the sync thread that pulled it and
299
+ kept in the account store. With one held for the shard, and only while
300
+ what this node holds over the handed range still fingerprints to what
301
+ was handed, the claim goes out `asserted` over exactly that range with
302
+ the handover inline. The moment the set moves - a late event in a
303
+ closed range, a retention pass - the handover is stale, and a claim
304
+ that carried it would be a false one, so it is dropped and the claim
305
+ falls back to what this node can stand behind on its own.
306
+ """
307
+ now_val = now if now is not None else self.clock()
308
+ did: list[tuple[Any, ...]] = []
309
+ if self.config('class') == 'leaf' or not self.relays:
310
+ return did
311
+ for shard in self.config('coverage') or []:
312
+ filt = shard['filter']
313
+ since, until = shard.get('since', 0), shard.get('until', 2 ** 31)
314
+ ids = self.store.enumerate_range(filt, since, until, now_val)
315
+ if self.capacity is not None and self.capacity.narrowing() and self.store.any_replicated(ids):
316
+ # Under pressure a node stops advertising what it holds only
317
+ # second-hand. What it holds first-hand it keeps claiming,
318
+ # because that is the part it can stand behind. The claims
319
+ # already published lapse at their own expiration.
320
+ if self._narrowed.get(shard['shard']) is not True:
321
+ self._narrowed[shard['shard']] = True
322
+ self.say('coverage_narrowed', shard=shard['shard'])
323
+ self.refusals.refuse('coverage', 'held back while this node sheds',
324
+ subject=shard['shard'], at=now_val)
325
+ # Stopping the republication is not the same as taking the
326
+ # claim back. The one already on the relays is what readers
327
+ # act on, and it says this node holds the shard for as long
328
+ # as it sits there. The rung means to stop being asked for
329
+ # what this node can no longer serve, so the claim is
330
+ # withdrawn rather than left to stand unrefreshed.
331
+ did += self._withdraw_one(shard['shard'], now_val)
332
+ did.append(('coverage narrowed', shard['shard'], 'held through the mesh'))
333
+ continue
334
+ self._narrowed.pop(shard['shard'], None)
335
+ wanted = shard.get('completeness', 'best-effort')
336
+ completeness = wanted
337
+ handover: Optional[list[HandoverLink]] = None
338
+ if wanted == 'asserted' and self.store.any_replicated(ids):
339
+ handover, h_since, h_until, h_ids = self._handed_set(shard, filt, now_val)
340
+ if handover is not None:
341
+ since, until, ids = h_since, h_until, h_ids
342
+ if handover is None and wanted == 'asserted' and self.store.any_replicated(ids):
343
+ completeness = 'best-effort'
344
+ if self._downgraded.get(shard['shard']) != len(ids):
345
+ self._downgraded[shard['shard']] = len(ids)
346
+ self.say('coverage_downgraded', shard=shard['shard'])
347
+ self.refusals.refuse('coverage', 'asserted was downgraded to best-effort',
348
+ subject=shard['shard'], detail={'events': len(ids)}, at=now_val)
349
+ at = self.forward_clock('coverage:' + shard['shard'], now_val)
350
+ if handover is not None:
351
+ claim = coverage.coverage_claim(self.ks.node_pk, shard['shard'], filt, completeness,
352
+ ids, at, since=since, until=until, handover=handover)
353
+ else:
354
+ claim = coverage.coverage_claim(self.ks.node_pk, shard['shard'], filt, completeness,
355
+ ids, at, since=shard.get('since'), until=shard.get('until'))
356
+ problems = coverage.check_coverage(claim, self.config('class'))
357
+ if problems:
358
+ self.refusals.refuse('coverage', 'the claim is not one this node may make',
359
+ subject=shard['shard'], detail={'problems': problems[:2]}, at=now_val)
360
+ did.append(('coverage claim refused', shard['shard'], '; '.join(problems[:2])))
361
+ continue
362
+ signed = ref_store.make_signed_event(self.ks.node_sk, at, claim['kind'],
363
+ claim['tags'], claim['content'])
364
+ fingerprint = [t[1] for t in signed['tags'] if t[0] == 'fingerprint'][0]
365
+ self._claims[shard['shard']] = signed
366
+ if handover is not None:
367
+ self._handed[shard['shard']] = handover[-1]['from']
368
+ else:
369
+ self._handed.pop(shard['shard'], None)
370
+ # The range is part of what changed: close_ranges() bounds a shard
371
+ # whose set and completeness are exactly as before, and a handover
372
+ # signs for that closed set - a dedupe on the set alone kept the
373
+ # closed claim in memory and never sent it.
374
+ key = (fingerprint, completeness, _range_of(signed))
375
+ if self._coverage.get(shard['shard']) == key:
376
+ continue # nothing about the shard has changed
377
+ self.publisher(self.relays, signed, self.timeout)
378
+ self._coverage[shard['shard']] = key
379
+ did.append(('coverage', shard['shard'], completeness, '%d events' % len(ids))
380
+ + (('handed over by ' + handover[-1]['from'][:16],) if handover is not None else ()))
381
+ return did
382
+
383
+ def _handed_set(
384
+ self,
385
+ shard: CoverageShardConfig | Mapping[str, Any],
386
+ filt: Mapping[str, Any],
387
+ now: int,
388
+ ) -> tuple[Optional[list[HandoverLink]], Optional[int], Optional[int], list[str]]:
389
+ """(chain, since, until, ids) for a shard held under a handover, or
390
+ (None, None, None, []) when there is none or it no longer fits.
391
+
392
+ `handover.still_fits` decides; this drops what no longer does and says
393
+ why, because the operator asked for asserted and is about to get
394
+ best-effort without having changed anything.
395
+ """
396
+ if self.accounts is None:
397
+ return None, None, None, []
398
+ chain = self.accounts.handover(shard['shard'])
399
+ if not chain:
400
+ return None, None, None, []
401
+ art = chain[-1]
402
+ ids = self.store.enumerate_range(filt, art.get('since') or 0, art['until'], now)
403
+ why = handover.still_fits(chain, filt, ids)
404
+ if why is None:
405
+ return chain, art.get('since'), art['until'], ids
406
+ self.accounts.drop_handover(shard['shard'])
407
+ self.say('handover_dropped', shard=shard['shard'], from_peer=art['from'][:16], why=why)
408
+ self.refusals.refuse('coverage', 'handover dropped: ' + why, subject=shard['shard'],
409
+ detail={'from': art['from'], 'held': len(ids), 'handed': art['count']}, at=now)
410
+ return None, None, None, []
411
+
412
+ # ---- retiring --------------------------------------------------------------
413
+ def close_ranges(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
414
+ """Give every asserted shard a bounded range that ends now, and publish.
415
+
416
+ A handover signs for a closed set, so a shard whose `until` is open or
417
+ in the future cannot be handed to anybody. That is the right rule while
418
+ a node is running and the wrong state to retire in: the shards worth
419
+ passing on are exactly the ones nobody has ever bounded. Closing them
420
+ is the first step of a retirement and the only one that changes what
421
+ this node says about itself while it is still serving.
422
+
423
+ Written to the config, because the claim is republished from the config
424
+ on the next tick and a range closed only in memory would reopen.
425
+ """
426
+ now_val = now if now is not None else self.clock()
427
+ did: list[tuple[Any, ...]] = []
428
+ cov = [dict(c) for c in self.config('coverage') or [] if isinstance(c, dict)]
429
+ touched = False
430
+ for shard in cov:
431
+ if shard.get('completeness') != 'asserted':
432
+ continue
433
+ if shard.get('until') is not None and int(shard['until']) <= now_val:
434
+ continue
435
+ shard['until'] = now_val
436
+ touched = True
437
+ did.append(('range closed', shard['shard'], now_val))
438
+ if not touched:
439
+ return did
440
+ if self._live_cfg is not None:
441
+ self._live_cfg['coverage'] = cov
442
+ self.persist_cfg()
443
+ return did + self.publish_coverage(now_val)
444
+
445
+ def _withdraw_one(self, shard: str, now: int, lapses_in: int = 86400) -> list[tuple[Any, ...]]:
446
+ """Take one published claim back. Never raises: every caller is either
447
+ shedding or stopping, and neither may be stopped by a relay."""
448
+ claim = self._claims.get(shard)
449
+ if claim is None:
450
+ return []
451
+ try:
452
+ out = coverage.withdraw_coverage(claim, self.forward_clock('coverage:' + shard, now), lapses_in)
453
+ signed = ref_store.make_signed_event(self.ks.node_sk, out['created_at'], out['kind'],
454
+ out['tags'], out['content'])
455
+ self.publisher(self.relays, signed, self.timeout)
456
+ self._coverage.pop(shard, None)
457
+ self._claims[shard] = signed
458
+ return [('coverage withdrawn', shard, 'lapses in %ds' % lapses_in)]
459
+ except Exception as e:
460
+ return [('coverage withdrawal failed', shard, '%s: %s' % (type(e).__name__, e))]
461
+
462
+ def withdraw_coverage(self, now: Optional[int] = None, lapses_in: int = 86400) -> list[tuple[Any, ...]]:
463
+ """Take back every claim this node published, so none outlives it.
464
+
465
+ A kind 30803 that outlives its node is the black hole: readers see a
466
+ holder, pull, and time out, and every one of them records strain
467
+ against a node that is simply gone. The claim is addressable, so a
468
+ republication at the same `d` replaces it; the replacement says
469
+ best-effort, which promises nothing, and carries an expiration so it
470
+ lapses too.
471
+
472
+ Last of the retirement, after the handovers: a withdrawal before them
473
+ would take back the very claim a handover copies its terms from.
474
+ """
475
+ now_val = now if now is not None else self.clock()
476
+ did: list[tuple[Any, ...]] = []
477
+ for shard in sorted(self._claims):
478
+ did += self._withdraw_one(shard, now_val, lapses_in)
479
+ return did
480
+
481
+ def asserted_claims(self) -> list[NostrEvent]:
482
+ """Every published claim this node may hand over: asserted, with a
483
+ range that has closed. What `close_ranges` has just made of them."""
484
+ now = self.clock()
485
+ out: list[NostrEvent] = []
486
+ for _shard, claim in sorted(self._claims.items()):
487
+ if not coverage.reportable(claim):
488
+ continue
489
+ until = [t for t in claim['tags'] if t[0] == 'until']
490
+ if until and len(until[0]) > 1 and int(until[0][1]) <= now:
491
+ out.append(claim)
492
+ return out
493
+
494
+ def published_claim(self, shard: str) -> Optional[NostrEvent]:
495
+ """The signed kind 30803 this node last published for a shard, or None.
496
+ What a handover copies its terms from: a node hands over what it has
497
+ said in public and nothing else."""
498
+ return self._claims.get(shard)
499
+
500
+ # ---- being watched --------------------------------------------------------
501
+ def state(self) -> DiscoveryStateReport:
502
+ return {
503
+ 'discovery': sorted(self._discovery),
504
+ 'coverage': {s: c for s, (_, c, _r) in self._coverage.items()},
505
+ 'handed': dict(self._handed),
506
+ 'narrowed': sorted(s for s, v in self._narrowed.items() if v),
507
+ 'downgraded': sorted(self._downgraded),
508
+ }
509
+
510
+ def trouble(self) -> list[str]:
511
+ out: list[str] = []
512
+ narrowed = sorted(s for s, v in self._narrowed.items() if v)
513
+ if narrowed:
514
+ out.append('%d shard(s) are no longer claimed while this node sheds: %s. '
515
+ 'A peer looking for that data will not find this node'
516
+ % (len(narrowed), ', '.join(narrowed)))
517
+ refused = self.refusals.within(self.TROUBLE_WINDOW, self.clock(),
518
+ why='the claim is not one this node may make')
519
+ if refused:
520
+ out.append('%d coverage claim(s) refused: %s'
521
+ % (len(refused), ', '.join(sorted({(r.subject or '') for r in refused}))))
522
+ return out
@@ -0,0 +1,13 @@
1
+ """Types and records for the Discovery compartment."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TypedDict
5
+
6
+
7
+ class DiscoveryStateReport(TypedDict, total=False):
8
+ """The report Discovery.state() gives of itself."""
9
+ discovery: list[str]
10
+ coverage: dict[str, str]
11
+ handed: dict[str, str]
12
+ narrowed: list[str]
13
+ downgraded: list[str]
@@ -0,0 +1 @@
1
+ """The node's schema.org face: read-only JSON-LD projections over HTTP."""