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
node/app/control.py ADDED
@@ -0,0 +1,670 @@
1
+ """The operator's face of the node: the control-socket dispatcher, the
2
+ actions only the daemon can take, and the shims that forward to compartments.
3
+
4
+ A mixin, not a module of functions, because every method here reads and
5
+ writes the node's own state (`self.grants`, `self.peering`, `self.cfg`) and
6
+ tests and the CLI call them as NodeApp methods. NodeApp in core.py inherits
7
+ it; nothing else does.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import collections
12
+ import threading
13
+ from typing import TYPE_CHECKING, Any, Callable, Mapping, MutableMapping, Optional, TypedDict, cast
14
+
15
+ from ..interfaces import CommandSpec
16
+
17
+ from fleetmesh_core.accounting import accounting, standing, window
18
+ from fleetmesh_core.identity import identity
19
+ from fleetmesh_core.services import lifecycle
20
+ from fleetmesh_core.storage import store as store_mod
21
+ from fleetmesh_core.wire.interfaces import GrantPayload, NostrEvent, StandingTellBody
22
+ from .. import (channels as channels_mod,
23
+ release as release_mod, transport as transport_mod)
24
+ from ..channels.bus import BusEnvelope, BusState
25
+ from . import socket as control_mod
26
+ from ..storage import grants as grants_mod
27
+ from ..protocols.nostr import client as relay, descriptor # noqa: E402
28
+ from ..channels.journal import line as _line, short as _short
29
+ from ..compartment.command import CommandError
30
+ from ..compartment import lifecycle as compartment_lifecycle
31
+ from .commands import NODE_COMMANDS_BY_KEY
32
+ from .config import CONTROL, save_config
33
+ from .views import GrantView, WindowView, _live_peers, grant_view, windows_view
34
+
35
+ if TYPE_CHECKING:
36
+ from .. import compartment as compartment_mod, keystore
37
+ from ..protocols.nostr import peers as peers_mod
38
+ from ..planes.peering import budget as budget_mod, peering as peering_wire, reporter as reporter_mod
39
+ from ..planes.store import sync as sync_wire
40
+ from ..storage import accounts as accounts_mod
41
+ from ..planes.identity.discovery import compartment as discovery_wire
42
+ from ..planes.identity.presence import compartment as presence_mod
43
+ from ..planes.operations.scheduler import compartment as scheduler_mod
44
+
45
+
46
+ MAX_PULL_TIMEOUT = 600 # an operator-driven pull holds a connection slot and a sync session this long at most
47
+
48
+
49
+ VersionResult = TypedDict('VersionResult', {
50
+ 'protocol': str,
51
+ 'fleetmesh': str,
52
+ 'digest': str,
53
+ 'release': Mapping[str, Optional[str]],
54
+ 'did': str,
55
+ 'pubkey': str,
56
+ 'class': str,
57
+ 'commands': list[str],
58
+ })
59
+
60
+
61
+ class CommandDetail(CommandSpec, total=False):
62
+ name: str
63
+
64
+
65
+ class RepublishResult(TypedDict):
66
+ descriptor: str
67
+ relays: Mapping[str, tuple[bool, str]]
68
+
69
+
70
+ class EventsResult(BusState):
71
+ topics: list[str]
72
+ events: list[BusEnvelope]
73
+
74
+
75
+ class StandingResult(TypedDict):
76
+ answer: str
77
+ body: Optional[Mapping[str, Any]]
78
+
79
+
80
+ class ReportItem(TypedDict):
81
+ reporter: str
82
+ severity: Optional[str]
83
+ admissible: bool
84
+ reasons: list[str]
85
+ weight: float
86
+ id: str
87
+ created_at: int
88
+
89
+
90
+ class ReportsResult(TypedDict):
91
+ view: str
92
+ reports: list[ReportItem]
93
+
94
+
95
+ class PeerGrantSummary(TypedDict):
96
+ tier: Optional[str]
97
+ status: str
98
+
99
+
100
+ class PeerPendingSummary(TypedDict):
101
+ tier: Optional[str]
102
+ from_epoch: int
103
+
104
+
105
+ class PeerEvaluationSummary(TypedDict):
106
+ epoch: int
107
+ severity: Optional[str]
108
+ state: str
109
+
110
+
111
+ class PeerViewRow(TypedDict):
112
+ peer: str
113
+ issued: Optional[PeerGrantSummary]
114
+ received: Optional[PeerGrantSummary]
115
+ pending: Optional[PeerPendingSummary]
116
+ last_evaluated: Optional[PeerEvaluationSummary]
117
+ standing: Optional[StandingTellBody]
118
+ zeroed_until: Optional[int]
119
+ pulls: list[str]
120
+ descriptor: Optional[str]
121
+ shared: list[str]
122
+ not_shared: list[str]
123
+ digest: Optional[str]
124
+ notices: list[str]
125
+
126
+
127
+ class RefusalResult(TypedDict):
128
+ subsystem: str | None
129
+ at: int
130
+ kind: str
131
+ subject: str | None
132
+ why: str
133
+ detail: Mapping[str, Any] | str
134
+
135
+
136
+ class SubsystemStateResult(TypedDict):
137
+ name: str
138
+ state: str
139
+
140
+
141
+ class RetireSteps(TypedDict, total=False):
142
+ reason: str
143
+ at: int
144
+ did: list[list[Any]]
145
+ peers: list[str]
146
+ handed: int
147
+ withdrawn: int
148
+ revoked: bool
149
+
150
+
151
+ class TerminateResult(TypedDict):
152
+ peer: str
153
+ reason: str
154
+ told: Mapping[str, Any]
155
+ pulls_removed: int
156
+
157
+
158
+ def _arg_int(cmd: str, args: Mapping[str, Any], name: str, default: int) -> int:
159
+ v = args.get(name)
160
+ if v is None:
161
+ return default
162
+ if isinstance(v, bool) or not isinstance(v, (int, float, str)):
163
+ raise control_mod.ControlError('%s: %s must be an integer' % (cmd, name))
164
+ try:
165
+ out = int(v)
166
+ except (TypeError, ValueError):
167
+ raise control_mod.ControlError('%s: %s must be an integer, not %r' % (cmd, name, str(v)[:32]))
168
+ if out < 0:
169
+ raise control_mod.ControlError('%s: %s must not be negative' % (cmd, name))
170
+ return out
171
+
172
+
173
+ def _arg_float(cmd: str, args: Mapping[str, Any], name: str, default: float) -> float:
174
+ v = args.get(name)
175
+ if v is None:
176
+ return default
177
+ if isinstance(v, bool) or not isinstance(v, (int, float, str)):
178
+ raise control_mod.ControlError('%s: %s must be a number' % (cmd, name))
179
+ try:
180
+ out = float(v)
181
+ except (TypeError, ValueError):
182
+ raise control_mod.ControlError('%s: %s must be a number, not %r' % (cmd, name, str(v)[:32]))
183
+ if out != out or out in (float('inf'), float('-inf')):
184
+ raise control_mod.ControlError('%s: %s must be finite' % (cmd, name))
185
+ return out
186
+
187
+
188
+ def _arg_hex(cmd: str, args: Mapping[str, Any], name: str) -> str:
189
+ """A 64-hex pubkey, lower-cased. Everything downstream keys tables on it."""
190
+ v = args.get(name)
191
+ if not isinstance(v, str) or len(v) != 64:
192
+ raise control_mod.ControlError('%s: %s must be a 64-hex pubkey' % (cmd, name))
193
+ try:
194
+ bytes.fromhex(v)
195
+ except ValueError:
196
+ raise control_mod.ControlError('%s: %s must be a 64-hex pubkey' % (cmd, name))
197
+ return v.lower()
198
+
199
+
200
+ def _topics_arg(raw: Any) -> set[str] | None:
201
+ if raw is None or raw == []:
202
+ return None
203
+ if isinstance(raw, str):
204
+ raw = [t for t in raw.split(',') if t]
205
+ if not isinstance(raw, list) or not all(isinstance(t, str) for t in raw):
206
+ raise control_mod.ControlError('topics is a list of topic names; `events` lists them')
207
+ return set(raw)
208
+
209
+
210
+ class ControlMixin(object):
211
+ # What the mixin reads off the node it is mixed into. Declarations only,
212
+ # for the checker; NodeApp.__init__ and start() assign every one of them.
213
+ if TYPE_CHECKING:
214
+ dir: str
215
+ cfg: MutableMapping[str, Any]
216
+ clock: Callable[[], int]
217
+ relays: list[str]
218
+ ks: keystore.Keystore
219
+ grants: grants_mod.GrantStore
220
+ accounts: accounts_mod.AccountStore
221
+ peers: peers_mod.Peers
222
+ transport: transport_mod.TransportRouter
223
+ ledger: standing.Ledger
224
+ timeout: float
225
+ _stop: threading.Event
226
+ _built: bool
227
+ subsystems: compartment_mod.Registry
228
+ events: channels_mod.NodeEventBus
229
+ peering: peering_wire.Peering
230
+ budget: budget_mod.Budget
231
+ sync: sync_wire.SyncPeer
232
+ reporter: reporter_mod.Reporter
233
+ discovery: discovery_wire.Discovery
234
+ presence: presence_mod.Presence
235
+ scheduler: scheduler_mod.Scheduler
236
+
237
+ def _descriptor(self) -> NostrEvent: ...
238
+ def tick(self, now: int | None = None) -> list[tuple[Any, ...]]: ...
239
+ def status(self) -> Mapping[str, Any]: ...
240
+ def health(self) -> Mapping[str, Any]: ...
241
+
242
+ def control_request(self, cmd: str, args: Mapping[str, Any],
243
+ actor: Any = None) -> Any:
244
+ """Answer one control request, recording who asked.
245
+
246
+ `actor` is built at the socket boundary (node/control.py:actor_of),
247
+ because SO_PEERCRED can only be read off a live connection and by the
248
+ time a command runs the socket is gone. It is held in a ContextVar
249
+ (node/config/change.py:ACTING) for the length of this request, so a
250
+ config change raised during the command is attributed to the person
251
+ who caused it - and only to them. An attribute on the node was shared
252
+ by every connection thread, so two concurrent requests could swap
253
+ authors.
254
+
255
+ Nothing here checks an ability. `Command.permission` and
256
+ `Parameter.requires` are declared and unenforced on purpose: this
257
+ socket is chmod 0o600, so everyone who can reach it is already the
258
+ owner, and a check against that is a control which protects nothing
259
+ while reading as though it does. Enforcement belongs with a principal
260
+ that is not already the owner - a UCAN delegation over the wire, or a
261
+ socket opened to several unix users - and the markers come off then.
262
+ """
263
+ from ..config import change as change_mod # noqa: PLC0415
264
+ with change_mod.acting_as(actor):
265
+ try:
266
+ return self._answer_request(cmd, args)
267
+ except CommandError as e:
268
+ # The caller's mistake, checked against the declaration: a
269
+ # refusal they read, kept out of the ring that counts bugs.
270
+ raise control_mod.ControlError(str(e))
271
+
272
+ @property
273
+ def acting(self) -> Any:
274
+ """The actor of the request running on this thread, or None."""
275
+ from ..config import change as change_mod # noqa: PLC0415
276
+ return change_mod.acting()
277
+
278
+ def _answer_request(self, cmd: str, args: Mapping[str, Any]) -> Any:
279
+ """One request over the control socket, dispatched on CONTROL. Reads
280
+ answer from the daemon's live state; actions are the things only the
281
+ daemon can do. The config file is the way to change what the node is
282
+ told to do."""
283
+ table = self.commands()
284
+ if cmd not in table:
285
+ raise control_mod.ControlError('unknown command %r; `commands` lists the %d this socket answers'
286
+ % (str(cmd)[:32], len(table)))
287
+ missing = [k for k, v in table[cmd]['args'].items() if v == 'required' and k not in args]
288
+ if missing:
289
+ raise control_mod.ControlError('%s needs %s' % (cmd, ', '.join(missing)))
290
+ cmd_obj = NODE_COMMANDS_BY_KEY.get(cmd)
291
+ if cmd_obj is not None:
292
+ return cmd_obj.execute(self, args)
293
+ if self._built and cmd in self.subsystems.commands():
294
+ return self.subsystems.dispatch(cmd, dict(args))
295
+ raise control_mod.ControlError('%s is in the table and not in the dispatcher' % cmd)
296
+
297
+ # ---- node command handlers -----------------------------------------------------
298
+ def cmd_version(self) -> VersionResult:
299
+ return {'protocol': control_mod.PROTOCOL, 'fleetmesh': descriptor.VERSION, 'digest': descriptor.DIGEST,
300
+ 'release': release_mod.running().as_dict(),
301
+ 'did': self.ks.did, 'pubkey': self.ks.node_pk, 'class': self.cfg['class'],
302
+ 'commands': list(self.commands())}
303
+
304
+ def cmd_commands(self) -> list[CommandDetail]:
305
+ return [dict(name=name, **spec) for name, spec in self.commands().items()]
306
+
307
+ def cmd_subsystems(self) -> Mapping[str, Mapping[str, Any]]:
308
+ return self.subsystems.report()
309
+
310
+ def cmd_lifecycle(self) -> Mapping[str, compartment_lifecycle.LifecycleRecord]:
311
+ return self.subsystems.lifecycle()
312
+
313
+ def cmd_tick(self) -> list[list[Any]]:
314
+ return [list(d) for d in self.tick()]
315
+
316
+ def cmd_republish(self) -> RepublishResult:
317
+ desc = self._descriptor()
318
+ return {'descriptor': desc['id'],
319
+ 'relays': relay.publish_all(self.relays, desc, self.timeout) if self.relays else {}}
320
+
321
+ def cmd_grant(self, args: Mapping[str, Any]) -> GrantView:
322
+ return grant_view(self.grants, _arg_hex('grant', args, 'peer'))
323
+
324
+ def cmd_windows(self, args: Mapping[str, Any]) -> list[WindowView]:
325
+ return windows_view(self.accounts, _arg_hex('windows', args, 'peer'), _arg_int('windows', args, 'last', 0))
326
+
327
+ def cmd_refusals(self, args: Mapping[str, Any]) -> list[RefusalResult]:
328
+ return [{'subsystem': n, 'at': r.at, 'kind': r.kind,
329
+ 'subject': (r.subject or '')[:16] or None, 'why': r.why, 'detail': r.detail}
330
+ for n, r in self.subsystems.refusals(_arg_int('refusals', args, 'last', 5))]
331
+
332
+ def cmd_resume(self, args: Mapping[str, Any]) -> SubsystemStateResult:
333
+ name = str(args.get('name') or '')
334
+ actor = self.acting
335
+ who = getattr(actor, 'operator', '') or 'the operator'
336
+ try:
337
+ state = self.subsystems.resume(name, why='resumed by %s' % who)
338
+ except KeyError as e:
339
+ raise control_mod.ControlError(str(e.args[0] if e.args else e))
340
+ return {'name': name, 'state': state}
341
+
342
+ def cmd_restart(self, args: Mapping[str, Any]) -> SubsystemStateResult:
343
+ name = str(args.get('name') or '')
344
+ who = getattr(self.acting, 'operator', '') or 'the operator'
345
+ try:
346
+ state = self.subsystems.restart(name, why='by %s' % who)
347
+ except (KeyError, ValueError) as e:
348
+ raise control_mod.ControlError(str(e.args[0] if e.args else e))
349
+ return {'name': name, 'state': state}
350
+
351
+ def cmd_events(self, args: Mapping[str, Any]) -> EventsResult:
352
+ topics = _topics_arg(args.get('topics'))
353
+ tap = self.events.tap
354
+ assert tap is not None
355
+ evs = tap.since(_arg_int('events', args, 'since', 0), topics, _arg_int('events', args, 'last', 0))
356
+ st = tap.state()
357
+ return {
358
+ 'seq': st['seq'],
359
+ 'ring': st['ring'],
360
+ 'ring_max': st['ring_max'],
361
+ 'subscribers': st['subscribers'],
362
+ 'oldest': st['oldest'],
363
+ 'topics': sorted(channels_mod.WIRE),
364
+ 'events': evs,
365
+ }
366
+
367
+ def cmd_subscribe(self, args: Mapping[str, Any]) -> control_mod.Stream:
368
+ topics = _topics_arg(args.get('topics'))
369
+ tap = self.events.tap
370
+ assert tap is not None
371
+ sub = tap.subscribe(topics)
372
+ backlog = collections.deque(tap.since(_arg_int('subscribe', args, 'since', 0), topics)) if args.get('since') is not None else collections.deque()
373
+
374
+ def pull(timeout: float) -> Optional[BusEnvelope]:
375
+ if backlog:
376
+ return backlog.popleft()
377
+ return sub.next(timeout)
378
+ return control_mod.Stream({'seq': tap.seq, 'topics': sorted(topics) if topics else None,
379
+ 'replaying': len(backlog)}, pull, lambda: tap.unsubscribe(sub))
380
+
381
+ def cmd_pull(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
382
+ peer, shard, filt = _arg_hex('pull', args, 'peer'), args.get('shard'), args.get('filter')
383
+ if not isinstance(shard, str) or not isinstance(filt, dict):
384
+ raise control_mod.ControlError('pull takes shard as a string and filter as an object')
385
+ timeout = _arg_float('pull', args, 'timeout', 60.0)
386
+ if not 0 < timeout <= MAX_PULL_TIMEOUT:
387
+ raise control_mod.ControlError('pull timeout must be in (0, %d] seconds' % MAX_PULL_TIMEOUT)
388
+ why = self.budget.throttled(peer)
389
+ if why is not None and not args.get('force'):
390
+ raise control_mod.ControlError('the grant does not cover a session now: %s (--force pulls anyway, and the window prices it)' % why)
391
+ return self.pull(peer, shard, filt, timeout=timeout)
392
+
393
+ def cmd_terminate(self, args: Mapping[str, Any]) -> TerminateResult:
394
+ return self.terminate(_arg_hex('terminate', args, 'peer'), str(args.get('reason') or 'operator')[:200])
395
+
396
+ def cmd_standing(self, args: Mapping[str, Any]) -> StandingResult:
397
+ kind, body = self.reporter.ask_standing(_arg_hex('standing', args, 'peer'), _arg_hex('standing', args, 'target'))
398
+ if kind is None:
399
+ raise control_mod.ControlError('no answer from the peer')
400
+ return {'answer': kind, 'body': body}
401
+
402
+ def cmd_reports(self, args: Mapping[str, Any]) -> ReportsResult:
403
+ target = _arg_hex('reports', args, 'target')
404
+ view, weighed = self.reporter.view(target)
405
+ found = self.reporter.reports_about(target)
406
+ return {'view': view, 'reports': [{'reporter': r['reporter'], 'severity': r['severity'],
407
+ 'admissible': r['admissible'], 'reasons': r['reasons'],
408
+ 'weight': self.reporter.weight_of(r['reporter']),
409
+ 'id': r['event']['id'], 'created_at': r['event']['created_at']}
410
+ for r in found]}
411
+
412
+ def cmd_retire(self, args: Mapping[str, Any]) -> RetireSteps:
413
+ out = self.retire(str(args.get('reason') or 'operator')[:200], bool(args.get('revoke')),
414
+ _arg_int('retire', args, 'lapses_in', 86400))
415
+ self._stop.set() # the tick loop ends; `run` calls stop, which says farewell
416
+ return out
417
+
418
+ def commands(self) -> collections.OrderedDict[str, CommandSpec]:
419
+ """Every command this socket answers: the node's own, and one per
420
+ command each compartment declares.
421
+
422
+ The compartments' were unreachable. `control_request` refused anything
423
+ not in CONTROL before it ever looked at the registry, so the branch that
424
+ dispatched `peering.show` could not be reached and no subsystem command
425
+ had ever been answered. A dispatch table that documents itself has to be
426
+ the same table the guard reads.
427
+ """
428
+ built = getattr(self, '_built', False)
429
+ sub_key = tuple(s.name for s in self.subsystems) if built else ()
430
+ cached = getattr(self, '_commands_cache', None)
431
+ if cached is not None and cached[0] == built and cached[1] == sub_key:
432
+ return collections.OrderedDict(cached[2])
433
+ table = collections.OrderedDict(CONTROL)
434
+ if built:
435
+ for name, spec in self.subsystems.commands().items():
436
+ table[name] = CommandSpec(args=spec.get('args', {}), does=spec.get('does', ''),
437
+ reply=spec.get('reply', ''))
438
+ self._commands_cache = (built, sub_key, table)
439
+ return collections.OrderedDict(table)
440
+
441
+ def retire(self, reason: str = 'operator', revoke: bool = False,
442
+ lapses_in: int = 86400) -> RetireSteps:
443
+ """Leave the mesh in order, rather than by going quiet.
444
+
445
+ `farewell` closed one of the four ways a node's departure cost its
446
+ peers something: the accounting. Everything under the accounting was
447
+ still left behind. The coverage claims kept pointing at a node that was
448
+ gone, so every reader that believed one pulled and timed out; the
449
+ shards this node held first-hand lost their authority, because nobody
450
+ else may assert what it only replicated; and each of those peers
451
+ recorded strain against a node that had done nothing but stop.
452
+
453
+ Four steps, in this order, and the order is the whole of it:
454
+
455
+ announce a heartbeat saying this is a departure, so the silence
456
+ that follows is not priced as stress by anyone listening.
457
+ close every asserted shard gets a range ending now. A handover
458
+ signs for a closed set, so an open range cannot be passed
459
+ on, and the shards worth passing on are exactly the ones
460
+ nobody ever bounded.
461
+ hand over the statement goes to every peer holding a live grant,
462
+ stored. The peer keeps it only if it fingerprints to what
463
+ the peer holds, so being wrong about who has the data
464
+ costs nothing.
465
+ withdraw every claim is republished best-effort with an expiration.
466
+ After the handovers, never before: a withdrawal takes back
467
+ the very claim a handover copies its terms from.
468
+ farewell what `stop` already does, and it goes last because a
469
+ receipt is the last thing this node has to say.
470
+
471
+ This is a retirement and not a restart. A restart MUST NOT run it: the
472
+ claims would come back on the next tick with their ranges closed and
473
+ their authority given away, which is a fleet upgrade paying the price
474
+ this exists to avoid. `meshnode retire` is a separate command for that
475
+ reason, and `run` never calls it.
476
+
477
+ Reversible up to the last step. A node that retires and is started
478
+ again publishes its claims afresh; what it does not get back is the
479
+ `until` it closed, which is a line in the config, and the handovers it
480
+ signed, which are permanent by design. `revoke` is the one step that
481
+ is not reversible at all, so it is off unless asked for.
482
+
483
+ Nothing here may raise. A node that cannot retire cleanly must still
484
+ stop.
485
+ """
486
+ now = self.clock()
487
+ did: list[tuple[Any, ...]] = []
488
+ steps: RetireSteps = {'reason': reason, 'at': now}
489
+ try:
490
+ # First, and before anything is given away: say this is a
491
+ # departure. Under FLP nothing else distinguishes a node that went
492
+ # from one that went quiet, so a peer holding a schedule against
493
+ # this one would otherwise record a partition it was about to be
494
+ # told about.
495
+ did += self.presence.announce_retiring(now)
496
+ except Exception as e:
497
+ did.append(('announcement failed', '%s: %s' % (type(e).__name__, e)))
498
+ try:
499
+ did += self.discovery.close_ranges(now)
500
+ except Exception as e:
501
+ did.append(('close failed', '%s: %s' % (type(e).__name__, e)))
502
+ peers_held = [p for p in _live_peers(self.grants)
503
+ if (row := self.grants.get(p, grants_mod.ISSUED)) and row.get('status') == 'live']
504
+ try:
505
+ did += self.sync.hand_over_all(peers_held, now)
506
+ except Exception as e:
507
+ did.append(('handover failed', '%s: %s' % (type(e).__name__, e)))
508
+ try:
509
+ did += self.discovery.withdraw_coverage(now, lapses_in=lapses_in)
510
+ except Exception as e:
511
+ did.append(('withdrawal failed', '%s: %s' % (type(e).__name__, e)))
512
+ if revoke:
513
+ try:
514
+ ev = lifecycle.revocation(self.ks.node_pk, reason, now)
515
+ signed = store_mod.make_signed_event(self.ks.node_sk, now, ev['kind'], ev['tags'], ev['content'])
516
+ relay.publish_all(self.relays, signed, self.timeout)
517
+ did.append(('revoked', signed['id'], reason))
518
+ except Exception as e:
519
+ did.append(('revocation failed', '%s: %s' % (type(e).__name__, e)))
520
+ for d in did:
521
+ _line(' ' + ' '.join(_short(str(x)) for x in d))
522
+ steps['did'] = [list(d) for d in did]
523
+ steps['peers'] = peers_held
524
+ steps['handed'] = sum(1 for d in did if d[0] == 'handed over')
525
+ steps['withdrawn'] = sum(1 for d in did if d[0] == 'coverage withdrawn')
526
+ steps['revoked'] = bool(revoke and any(d[0] == 'revoked' for d in did))
527
+ return steps
528
+
529
+ def save_coverage(self) -> None:
530
+ """Write the config back after a compartment changed what this node
531
+ claims. One place, because a running node owns its directory and the
532
+ file is what the next tick reads. Compartments reach it as
533
+ `ctx.persist_cfg`; the node reaches it directly."""
534
+ save_config(self.dir, {k: v for k, v in self.cfg.items() if not k.startswith('_')})
535
+
536
+ def terminate(self, peer_pk: str, reason: str = 'operator') -> TerminateResult:
537
+ """End a peering: zero both grants here, tell the peer, and stop
538
+ pulling from it. The trust decision, if any, is left where it is."""
539
+ rows = [self.grants.get(peer_pk, d) for d in (grants_mod.ISSUED, grants_mod.RECEIVED)]
540
+ if not any(r is not None and r['status'] == 'live' for r in rows):
541
+ raise control_mod.ControlError('no live peering with %s' % peer_pk[:16])
542
+ msg = self.peering.host.terminate(peer_pk, reason=reason)
543
+ self.grants.zero(peer_pk, self.clock())
544
+ sent: Mapping[str, Any] = {}
545
+ try:
546
+ sent = self.transport.send(msg, peer_pk, stored=True)
547
+ except transport_mod.TransportError as e:
548
+ sent = {'error': str(e)}
549
+ before = len(self.cfg.get('peers', []))
550
+ self.cfg['peers'] = [p for p in self.cfg.get('peers', []) if p.get('pubkey') != peer_pk]
551
+ if len(self.cfg['peers']) != before:
552
+ self.save_coverage()
553
+ _line(' terminated the peering with', _short(peer_pk), '-', reason)
554
+ return {'peer': peer_pk, 'reason': reason, 'told': sent, 'pulls_removed': before - len(self.cfg['peers'])}
555
+
556
+ def peer_view(self) -> list[PeerViewRow]:
557
+ """Every peer this node holds a grant with, as an operator reads it."""
558
+ out: list[PeerViewRow] = []
559
+ ours = set(identity.PROTOCOLS)
560
+ for pk in sorted({g['peer'] for g in self.grants.list()}):
561
+ desc = self.peers.descriptor(pk)
562
+ theirs = identity.protocols_of(desc) if desc else set()
563
+ shared = identity.shared_protocols(theirs, ours) if desc else set()
564
+ issued = self.grants.get(pk, grants_mod.ISSUED)
565
+ received = self.grants.get(pk, grants_mod.RECEIVED)
566
+ pend = self.grants.pending(pk)
567
+ evals = [r for r in self.budget.evaluations if r.peer == pk]
568
+ out.append({'peer': pk,
569
+ 'issued': {'tier': issued['payload'].get('tier'), 'status': issued['status']} if issued else None,
570
+ 'received': {'tier': received['payload'].get('tier'), 'status': received['status']} if received else None,
571
+ 'pending': {'tier': pend['payload'].get('tier'), 'from_epoch': pend['decided_epoch'] + 1} if pend else None,
572
+ 'last_evaluated': ({'epoch': evals[-1].epoch, 'severity': evals[-1].severity,
573
+ 'state': evals[-1].state} if evals else None),
574
+ 'standing': self.ledger.tell(pk),
575
+ 'zeroed_until': self.budget.zero_until.get(pk),
576
+ 'pulls': [p['shard'] for p in self.cfg.get('peers', []) if p.get('pubkey') == pk],
577
+ 'descriptor': desc.get('id') if desc else None,
578
+ # What the two nodes can run with each other, and the
579
+ # schema the peer declared when it differs from ours.
580
+ 'shared': sorted(p[len(identity.FLEETMESH):] for p in shared),
581
+ 'not_shared': sorted(p[len(identity.FLEETMESH):] for p in theirs - shared) if desc else [],
582
+ 'digest': (self.peering.host.digest_mismatch.get(pk) or '')[:16] or None,
583
+ 'notices': identity.notices(desc, digest=descriptor.DIGEST) if desc else []})
584
+ return out
585
+
586
+ # ---- actions -------------------------------------------------------------------
587
+ # A shard filter is a NIP-01 filter and routinely carries tag keys ('#t');
588
+ # node.interfaces.NostrFilter names no tag keys, so the plain mapping is
589
+ # the accurate type until that TypedDict grows them.
590
+ def propose(self, peer_pk: str, shard: str, filt: Mapping[str, Any], mode: str = 'both', timeout: float = 30.0) -> tuple[str, Mapping[str, Any]]:
591
+ want = [{'shard': shard, 'filter': dict(filt), 'mode': mode}]
592
+ return self.peering.propose(peer_pk, want, timeout=timeout)
593
+
594
+ def pull(self, peer_pk: str, shard: str, filt: Mapping[str, Any], timeout: float = 30.0) -> Mapping[str, Any]:
595
+ return self.sync.pull(peer_pk, shard, dict(filt), timeout=timeout)
596
+
597
+ def regrant(self, peer_pk: str, tier: str, now: int | None = None) -> Optional[GrantPayload]:
598
+ """Re-issue a live grant at a tier the operator has just decided on.
599
+
600
+ A tier is only ever raised by a person, so the decision has to reach an
601
+ existing peering as well as the next one. This writes a new grant at the
602
+ new tier, publishes it as a fresh kind 30801 under the same blinded
603
+ identifier, and tells the subject with peering/1.0/amend - the same path
604
+ a window evaluation uses, because it is the same kind of change.
605
+ """
606
+ now = now if now is not None else self.clock()
607
+ row = self.grants.get(peer_pk, grants_mod.ISSUED)
608
+ if row is None:
609
+ return None
610
+ # A person deciding is the one thing allowed to skip the earning. The
611
+ # specification says an operator MAY seed a tier for a node it knows out
612
+ # of band, recorded as a signed event and reversible, so a regrant opens
613
+ # at the ceiling rather than at the opening fraction a stranger gets.
614
+ held = row['payload']
615
+ payload = cast(GrantPayload, dict(held, tier=tier,
616
+ limits={k: v for k, v in window.ceilings(tier).items() if k != 'dup_ratio'}))
617
+ # And it lands at the boundary, like every other decision. Seeding a
618
+ # tier mid-window published a new envelope while the subject was still
619
+ # sending receipts against the old one; every one of those was refused
620
+ # as naming a grant that did not govern that window, the window closed
621
+ # on a claim one receipt short of what was measured, and the divergence
622
+ # priced an honest peer S2. Seen on the mesh the first time a seed was
623
+ # re-applied after the ladder had overridden it.
624
+ decided = accounting.epoch(now, held.get('window') or 3600)
625
+ out = self.peering.decide(peer_pk, payload, 'operator set the tier to %s' % tier,
626
+ None, decided)
627
+ _line(' granted', _short(peer_pk), 'tier', tier,
628
+ '- in force from window', out['effective_from'])
629
+ return payload
630
+
631
+ # ---- the node's own ceiling ----------------------------------------------------
632
+ def _amend(self, peer_pk: str, payload: Mapping[str, Any], reason: str, cause: str, now: int) -> tuple[NostrEvent, GrantPayload]:
633
+ """Write and publish a grant, and tell the subject what changed it.
634
+
635
+ The one place a grant leaves this node outside a window evaluation.
636
+ `cause` is what separates a remedy from a shed from a person, and a
637
+ subject that could not tell them apart would price its issuer for
638
+ owning a small machine.
639
+ """
640
+ # The one path that still lands inside the running window, and it is a
641
+ # deliberate exception rather than the leftover the other two were. A
642
+ # shed is the node saying it is over its own ceiling now; deferring the
643
+ # lower limits to the next boundary would leave it carrying load it has
644
+ # already decided it cannot carry, for up to an hour. The cost is the
645
+ # one the other paths were changed to avoid - the subject's receipts for
646
+ # the rest of this window name the grant it still holds, and the window
647
+ # closes divergent - so a shed is worth an S2 against a peer that did
648
+ # nothing wrong only because the alternative is the node falling over.
649
+ # If that trade ever stops being worth it, this is the line to change.
650
+ out = self.peering.issue(peer_pk, payload, cause, reason,
651
+ effective_epoch=accounting.epoch(now, payload.get('window') or 3600),
652
+ now=now)
653
+ return out['envelope'], out['payload']
654
+
655
+ # ---- what the compartments do, reached from the node ---------------------------
656
+ # Each of these is the compartment's, and each is kept here because a caller
657
+ # outside the node - a test, the control socket - asks the node for it. They
658
+ # forward and do nothing else; the state and the refusals live in the
659
+ # compartment, which is the point.
660
+ def on_heartbeat(self, hb: NostrEvent) -> list[tuple[Any, ...]]:
661
+ return self.presence.on_heartbeat(hb)
662
+
663
+ def publish_discovery(self, now: int | None = None) -> list[tuple[str, ...]]:
664
+ return self.discovery.publish_discovery(now)
665
+
666
+ def publish_coverage(self, now: int | None = None) -> list[tuple[str, ...]]:
667
+ return self.discovery.publish_coverage(now)
668
+
669
+ def forward_clock(self, key: str, now: int) -> int:
670
+ return self.discovery.forward_clock(key, now)