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,299 @@
1
+ """The Reporter subsystem itself: construction and restore, what survives a
2
+ restart, and the Compartment contract. Separate from the mixins because this is
3
+ where the state they all share is created; the mixins are the concerns that
4
+ act on it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import dataclasses
9
+ import threading
10
+ from typing import Any, Callable, Mapping, Optional, Sequence
11
+
12
+ from node.config.parameter import Parameter, ParamBlock
13
+ from node.channels import Channel, NOTE, TROUBLE, bus as eventbus_mod
14
+ from node.metrics import COUNTER, Metric
15
+ from fleetmesh_core.accounting import standing # noqa: E402
16
+ from fleetmesh_core.peering import complaint # noqa: E402
17
+ from fleetmesh_core.wire.interfaces import ComplaintNoticeBody, ComplaintRemedyBody, NostrEvent # noqa: E402
18
+ from node.storage import grants as grants_mod # noqa: E402
19
+ from node.compartment import stores as st # noqa: E402
20
+ from node.compartment import Command, Handler, Need, Outbound, WireProtocol # noqa: E402
21
+ from .constants import FINDING_WINDOWS, KEEP_LOG, KEEP_SIGNED, KEEP_THREADS, KEEP_UNPUBLISHABLE, PUBLISH_TRIES, REPORT_TTL
22
+ from .finding import Finding
23
+ from .findings import FindingsMixin
24
+ from .publish import PublishMixin
25
+ from .reading import ReadingMixin
26
+ from .types import ReporterStateReport
27
+
28
+ @dataclasses.dataclass(frozen=True)
29
+ class ReporterConfig(ParamBlock):
30
+ key = 'reporter'
31
+ name = 'reporter'
32
+ DERIVED: bool = False
33
+ PARAMS = (
34
+ Parameter(
35
+ key='report_ttl', name='Report TTL',
36
+ description='Time-to-live in seconds for signed reports',
37
+ type=int, default=REPORT_TTL, minimum=86400,
38
+ tags=('reporter', 'retention'),
39
+ ),
40
+ Parameter(
41
+ key='finding_windows', name='Finding Windows Horizon',
42
+ description='Window horizon for substantiated findings',
43
+ type=int, default=FINDING_WINDOWS, minimum=1,
44
+ tags=('reporter', 'history'),
45
+ ),
46
+ Parameter(
47
+ key='publish_tries', name='Publish Tries',
48
+ description='Maximum publish attempts before finding marked unpublishable',
49
+ type=int, default=PUBLISH_TRIES, minimum=1,
50
+ tags=('reporter', 'publish'),
51
+ ),
52
+ )
53
+ report_ttl: int = REPORT_TTL
54
+ finding_windows: int = FINDING_WINDOWS
55
+ publish_tries: int = PUBLISH_TRIES
56
+
57
+
58
+ class Reporter(FindingsMixin, PublishMixin, ReadingMixin, WireProtocol):
59
+ """Findings on their way to a report, as a compartment.
60
+
61
+ Its refusals are complaint and standing messages without the shape of their
62
+ type, which were kept in a list nothing rendered. Its trouble is the one an
63
+ operator most needs and would otherwise never see: a finding this node holds
64
+ and cannot publish, because the subject signed nothing that window and
65
+ active silence is unreportable by design.
66
+ """
67
+
68
+ name: str = 'reporter'
69
+ piuris: tuple[str, ...] = (complaint.PIURI, standing.PIURI)
70
+ reads: tuple[str, ...] = ('projection',)
71
+ config_cls = ReporterConfig
72
+ CHANNELS = (
73
+ Channel('report_published', 'A signed report was published to relays',
74
+ level=NOTE, say='published report {report_id} on peer {peer}',
75
+ tags=('reporter', 'publish')),
76
+ Channel('report_withdrawn', 'A finding or report was withdrawn by operator',
77
+ level=NOTE, say='withdrew report on peer {peer} window {window}',
78
+ tags=('reporter', 'withdraw')),
79
+ Channel('finding_unpublishable', 'An adverse finding could not be published to relays',
80
+ level=TROUBLE, say='finding on peer {peer} window {window} unpublishable: {reason}',
81
+ tags=('reporter', 'trouble')),
82
+ )
83
+ METRICS = (
84
+ Metric(name='reporter_reports_published', description='Reports published.',
85
+ kind=COUNTER, counts='report_published', tags=('reporter', 'publish')),
86
+ Metric(name='reporter_reports_withdrawn', description='Reports withdrawn.',
87
+ kind=COUNTER, counts='report_withdrawn', tags=('reporter', 'withdraw')),
88
+ Metric(name='reporter_findings_unpublishable', description='Findings unpublishable.',
89
+ kind=COUNTER, counts='finding_unpublishable', tags=('reporter', 'trouble')),
90
+ )
91
+ OUTBOUND = (
92
+ Outbound('notice', reply='remedy', doc='notify peer of adverse finding'),
93
+ Outbound('remedy', doc='submit remedy in response to complaint notice'),
94
+ Outbound('withdraw', doc='withdraw complaint finding'),
95
+ Outbound('ask', reply=('tell', 'decline'), doc='query peer standing'),
96
+ Outbound('tell', doc='answer standing query with peer streak'),
97
+ Outbound('decline', doc='decline standing query'),
98
+ )
99
+ HANDLERS = (
100
+ Handler('notice', run='handle_notice', doc='answer complaint notice'),
101
+ Handler('remedy', run='handle_remedy', doc='receive complaint remedy'),
102
+ Handler('withdraw', run='handle_withdraw', doc='receive complaint withdrawal'),
103
+ Handler('ask', run='handle_ask', doc='answer standing ask from ledger'),
104
+ Handler('*', run='handle_unknown', doc='ignore unknown complaint or standing kind by name'),
105
+ )
106
+ COMMANDS = (
107
+ Command(
108
+ key='show',
109
+ name='Reporter Status',
110
+ description='findings held, reports published, and what cannot be published at all',
111
+ tags=('reporter', 'status'),
112
+ permission='reporter/show',
113
+ run='state',
114
+ reply='{pending, published, withdrawn, unpublishable, noticed, malformed}',
115
+ ),
116
+ Command(
117
+ key='withdraw',
118
+ name='Withdraw Finding',
119
+ description="withdraw a finding this node made about one window, published or not; the operator's call",
120
+ tags=('reporter', 'withdraw'),
121
+ parameters=(
122
+ Parameter(key='peer', name='peer', description='peer pubkey or did', type=str, default='', required=True),
123
+ Parameter(key='window', name='window', description='window number', type=int, default=0, required=True),
124
+ Parameter(key='why', name='why', description='reason for withdrawal', type=str, default=None, required=False),
125
+ ),
126
+ permission='reporter/withdraw',
127
+ run='withdraw_cmd',
128
+ reply='{peer, window, was_published, report, why}',
129
+ ),
130
+ )
131
+
132
+ # What it keeps. The three lists of signed events and the log grew by one
133
+ # per report, score, label or complaint message for the life of the
134
+ # process, and `remedies_in` - written by any peer that sends a remedy -
135
+ # was trimmed only when a finding expired. `published` is still counted
136
+ # whole in `state`, from the ring's inserts. `pending` is not here: it is
137
+ # the queue `accounts` keeps on disk, for a durable store to declare.
138
+ STORES = (
139
+ st.Store(key='published', description='kind 30802 reports this node signed, newest last',
140
+ kind=st.RING, shape=dict, bound=KEEP_SIGNED),
141
+ st.Store(key='projected', description='kind 30385 relay scores this node signed, newest last',
142
+ kind=st.RING, shape=dict, bound=KEEP_SIGNED),
143
+ st.Store(key='labeled', description='kind 1985 labels this node signed, newest last',
144
+ kind=st.RING, shape=dict, bound=KEEP_SIGNED),
145
+ st.Store(key='unpublishable', description='findings it may not publish, as (peer, window, severity, reasons)',
146
+ kind=st.RING, shape=tuple, bound=KEEP_UNPUBLISHABLE),
147
+ st.Store(key='notices_in', description='complaint notices received, where this node is the subject',
148
+ kind=st.TABLE, shape=dict, bound=KEEP_THREADS, keyed_by='thid'),
149
+ st.Store(key='remedies_in', description='remedies received, where this node is the issuer',
150
+ kind=st.TABLE, shape=dict, bound=KEEP_THREADS, keyed_by='thid'),
151
+ st.Store(key='log', description='withdrawals against this node and tiers it restored, newest last',
152
+ kind=st.RING, shape=tuple, bound=KEEP_LOG),
153
+ )
154
+
155
+ published: st.Ring[NostrEvent]
156
+ projected: st.Ring[NostrEvent]
157
+ labeled: st.Ring[NostrEvent]
158
+ unpublishable: st.Ring[tuple[str, int, str, list[str]]]
159
+ notices_in: st.Table[str, ComplaintNoticeBody]
160
+ remedies_in: st.Table[str, ComplaintRemedyBody]
161
+ log: st.Ring[tuple[Any, ...]]
162
+
163
+ NEEDS = (
164
+ Need('keystore', 'ks'),
165
+ Need('transport'),
166
+ Need('grant_store', 'stores.grants'),
167
+ Need('relay_urls', 'relays'),
168
+ Need('ledger'),
169
+ Need('project', 'cfg', field='projection'),
170
+ Need('timeout'),
171
+ Need('accounts', 'stores.accounts'),
172
+ Need('regrant'),
173
+ Need('events'),
174
+ Need('discovery', late=True),
175
+ Need('budget', late=True),
176
+ )
177
+
178
+ def start(self) -> None:
179
+ if self.events is not None:
180
+ events = self.events
181
+ self.on_report_read.append(lambda *a: events.emit(eventbus_mod.TOPIC_REPORT_ADMITTED, *a))
182
+
183
+ def __init__(
184
+ self,
185
+ keystore: Any,
186
+ transport: Any,
187
+ grant_store: grants_mod.GrantStore,
188
+ relay_urls: Sequence[str],
189
+ ledger: Optional[standing.Ledger] = None,
190
+ clock: Optional[Callable[[], int]] = None,
191
+ timeout: float = 15.0,
192
+ node: Optional[Any] = None,
193
+ project: bool = True,
194
+ accounts: Optional[Any] = None,
195
+ regrant: Optional[Callable[[str, str, int], Any]] = None,
196
+ events: Optional[Any] = None,
197
+ discovery: Optional[Any] = None,
198
+ budget: Optional[Any] = None,
199
+ ) -> None:
200
+ super().__init__(node, clock)
201
+ # The node call that re-issues a grant when a tier is restored;
202
+ # suites that pass a fake node and nothing else get it off that.
203
+ self.regrant: Optional[Callable[[str, str, int], Any]] = regrant if regrant is not None else getattr(node, 'regrant', None)
204
+ self.ks: Any = keystore
205
+ self.accounts: Optional[Any] = accounts # where pending findings survive a restart; None keeps them in memory
206
+ self.project: bool = True if project is None else bool(project) # publish the kind 30385 relay score beside each report
207
+ self.projected: st.Ring[NostrEvent] # the kind 30385 events this node put on the relays
208
+ self.labeled: st.Ring[NostrEvent] # the kind 1985 NIP-32 labels this node put on the relays
209
+ self.transport: Any = transport
210
+ self.grants: grants_mod.GrantStore = grant_store
211
+ self.relays: list[str] = list(relay_urls) # not a store: the configured relays
212
+ self.ledger: standing.Ledger = ledger if ledger is not None else standing.Ledger()
213
+ self.events: Optional[Any] = events
214
+ self.discovery: Optional[Any] = discovery
215
+ self.budget: Optional[Any] = budget
216
+ self._forward_clock: Optional[Callable[[str, int], int]] = None
217
+ # After the ledger exists and not before it. This read the store into
218
+ # `self.ledger` four lines above the line that creates it, so a node
219
+ # started fresh was fine and a node restarted after it had read one
220
+ # report of anyone's died on the way up with AttributeError - a crash
221
+ # loop that only ever reached the nodes doing the most work. Two of a
222
+ # four-node mesh went down this way at once.
223
+ if accounts is not None:
224
+ for reporter, rid, bad in accounts.reports_seen():
225
+ self.ledger.record_report(reporter, rid, bad)
226
+ self.timeout: float = timeout
227
+ self._lock: threading.RLock = threading.RLock()
228
+ self.pending: list[Finding] = [] # not a store: findings on their way to a report - the queue accounts keeps on disk, for persist=DURABLE
229
+ self.published: st.Ring[NostrEvent] # kind 30802 events this node put on the relays
230
+ self.unpublishable: st.Ring[tuple[str, int, str, list[str]]] # findings it may not publish: no evidence a reader can check
231
+ self.notices_in: st.Table[str, ComplaintNoticeBody] # thid -> notice body received (we are the subject)
232
+ # Refusals go through the compartment channel. `rejected` stays as a
233
+ # view in the shape two suites and the health block already read.
234
+ self.remedies_in: st.Table[str, ComplaintRemedyBody] # thid -> remedy body received (we are the issuer)
235
+ self.log: st.Ring[tuple[Any, ...]]
236
+ self.on_report_read: list[Callable[[str, str, str, bool, bool], Any]] = [] # (reporter, target, id, admissible, contradicted)
237
+ if accounts is not None:
238
+ self.pending = [Finding.from_dict(b) for b in accounts.findings()] # not a store: loaded from accounts, for persist=DURABLE
239
+
240
+ @property
241
+ def forward_clock(self) -> Optional[Callable[[str, int], int]]:
242
+ if self._forward_clock is not None:
243
+ return self._forward_clock
244
+ if getattr(self, 'discovery', None) is not None:
245
+ return getattr(self.discovery, 'forward_clock', None)
246
+ return None
247
+
248
+ @forward_clock.setter
249
+ def forward_clock(self, fn: Optional[Callable[[str, int], int]]) -> None:
250
+ self._forward_clock = fn
251
+
252
+ # ---- what survives a restart -------------------------------------------------
253
+ def _persist(self, p: Finding) -> None:
254
+ """Write a pending finding down. Called at every change of its state:
255
+ opened, noticed, remedied, published, unpublishable, withdrawn.
256
+
257
+ Four nodes on three machines showed why. Two London nodes priced two
258
+ peers S2 at a window close, sent the notices, and were restarted eight
259
+ minutes later for an unrelated fix. The findings, their evidence and
260
+ the notice threads were memory only. The window passed and the reports
261
+ the notices had promised never came, and nothing said so.
262
+ """
263
+ if self.accounts is None:
264
+ return
265
+ self.accounts.put_finding(p.fid, p.peer, p.epoch, p.to_dict())
266
+
267
+ # ---- the compartment contract -------------------------------------------------
268
+ @property
269
+ def rejected(self) -> list[tuple[Optional[str], str, str]]:
270
+ return [(r.subject, r.kind, r.why) for r in self.refusals.last(200)]
271
+
272
+ def state(self) -> ReporterStateReport:
273
+ held = [p for p in self.pending if not p.done]
274
+ return {
275
+ 'pending': len(held),
276
+ 'published': self.published.inserts,
277
+ 'withdrawn': sum(1 for p in self.pending if p.withdrawn),
278
+ 'noticed': sum(1 for p in held if p.notice_at is not None),
279
+ 'unpublishable': len(self.unpublishable),
280
+ 'malformed': len(self.rejected),
281
+ }
282
+
283
+ def trouble(self) -> list[str]:
284
+ stuck = self.unpublishable
285
+ if not stuck:
286
+ return []
287
+ # The operator's own view of a rule the specification states and nothing
288
+ # else surfaces: a window of active silence cannot produce a kind 30802,
289
+ # because the subject signed nothing that window and no reader could
290
+ # check either half of the claim. The remedy is local and complete.
291
+ return [
292
+ '%d finding(s) can never be published - the subject signed nothing those windows. '
293
+ 'The grant is cut locally and standing/1.0 still carries the streak' % len(stuck)
294
+ ]
295
+
296
+ def withdraw_cmd(self, args: Mapping[str, Any]) -> Any:
297
+ return self.withdraw(str(args['peer']), int(args['window']), args.get('why'))
298
+
299
+
@@ -0,0 +1,74 @@
1
+ """A finding on its way to a report, as one record.
2
+
3
+ The reporter used to carry each pending finding as a dict of twenty-odd
4
+ string keys, mutated by four mixins and thawed from JSON on restart. A key
5
+ misspelt in one mixin read as `None` in the next and nothing said so. This is
6
+ the same record with its fields written down once; the JSON edge is
7
+ `to_dict` / `from_dict`, and everything between the store and the wire reads
8
+ attributes.
9
+
10
+ The shape is the one `_open` built and `_thaw` read back: JSON made the
11
+ tuples lists, and the code reads evidence entries, the worst limit and
12
+ session findings positionally, so `from_dict` gives them their shape back.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import asdict, dataclass, field, fields
17
+ from typing import Any, Mapping, Optional, cast
18
+ from fleetmesh_core.wire.interfaces import ComplaintRemedyBody, NostrEvent # noqa: E402
19
+
20
+ __all__ = ['Finding', 'LimitDetail']
21
+
22
+
23
+ from .types import LimitDetail, FindingDict
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Finding:
28
+ fid: str
29
+ peer: str
30
+ epoch: int
31
+ severity: str
32
+ # ('receipt', id, receipt) / ('envelope', id, env) / ('event', id) - headed by kind
33
+ evidence: list[tuple[Any, ...]]
34
+ # (metric, {'measured', 'limit', 'claimed', ...}) for a per-limit finding; None for silence
35
+ worst: Optional[tuple[str, LimitDetail]]
36
+ source: str # 'window' | 'session'
37
+ grant: str # envelope id of the grant that governed it
38
+ window_seconds: int
39
+ opened_at: int
40
+ notice_at: Optional[int] = None
41
+ thid: Optional[str] = None # the complaint thread the notice opened
42
+ remedy: Optional[ComplaintRemedyBody] = None # the subject's remedy body, if one came
43
+ report: Optional[NostrEvent] = None # the signed kind 30802, once published
44
+ withdrawn: bool = False
45
+ why: list[Any] = field(default_factory=list) # session findings, read positionally
46
+ measured: dict[str, float] = field(default_factory=dict) # metric -> what this node counted
47
+ state: Optional[str] = None
48
+ limits: dict[str, float] = field(default_factory=dict) # metric -> the grant's ceiling
49
+ evidence_partial: bool = False
50
+ tries: int = 0 # publish attempts that came back inadmissible
51
+ unpublishable: Optional[list[str]] = None # why the last attempt was refused
52
+
53
+ # ---- the JSON edge ---------------------------------------------------------
54
+ def to_dict(self) -> FindingDict:
55
+ return cast(FindingDict, asdict(self))
56
+
57
+ @classmethod
58
+ def from_dict(cls, body: Mapping[str, Any]) -> 'Finding':
59
+ """A finding read back from the store, or built by a test from a
60
+ plain mapping. Unknown keys are dropped; missing optional ones take
61
+ their defaults, so a record written by an older build still thaws."""
62
+ known = {f.name for f in fields(cls)}
63
+ p = {k: v for k, v in body.items() if k in known}
64
+ p['evidence'] = [tuple(e) for e in (p.get('evidence') or [])]
65
+ w = p.get('worst')
66
+ p['worst'] = (w[0], w[1]) if w else None
67
+ p['why'] = [tuple(x) if isinstance(x, list) else x for x in (p.get('why') or [])]
68
+ return cls(**p)
69
+
70
+ # ---- what the mixins ask ---------------------------------------------------
71
+ @property
72
+ def done(self) -> bool:
73
+ """Published or withdrawn: nothing further will happen to it."""
74
+ return self.report is not None or self.withdrawn
@@ -0,0 +1,171 @@
1
+ """Findings in: a window the budget evaluated, or a session the sync layer
2
+ ran. Each is a verdict of fleetmesh_core/'s arithmetic against one peer under one grant
3
+ this node issued, and what happens next is the specification's complaint
4
+ rule, executed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import secrets
9
+ from typing import Any, Mapping, Optional, cast
10
+ from fleetmesh_core.accounting import accounting, window # noqa: E402
11
+ from fleetmesh_core.crypto import giftwrap # noqa: E402
12
+ from fleetmesh_core.peering import complaint, reports # noqa: E402
13
+ from fleetmesh_core.storage import replication # noqa: E402
14
+ from node.storage import grants as grants_mod # noqa: E402
15
+ from node.protocols.nostr import client as relay # noqa: E402
16
+ from node.planes.peering.budget.evaluation import Evaluation, evaluation_like # noqa: E402
17
+ from .constants import LIMIT_METRICS
18
+ from .finding import Finding, LimitDetail
19
+ from .shape import ReporterShape
20
+
21
+ from fleetmesh_core.identity.did import did_nostr as _did
22
+
23
+
24
+ class FindingsMixin:
25
+ # ---- findings in --------------------------------------------------------
26
+ def on_evaluated(self: ReporterShape, rec: Evaluation | Mapping[str, Any]) -> Optional[Finding]:
27
+ """A window the budget closed. Adverse outcomes become pending reports."""
28
+ rec = evaluation_like(rec)
29
+ sev = rec.severity
30
+ if sev not in reports.ADVERSE:
31
+ return None
32
+ detail = rec.detail
33
+ worst: Optional[tuple[str, LimitDetail]] = None
34
+ for name, d in detail.items():
35
+ # per-limit entries carry a measurement; the silence entry carries none
36
+ if isinstance(d, dict) and d.get('severity') == sev and 'measured' in d:
37
+ worst = (str(name), cast(LimitDetail, d))
38
+ break
39
+ # The subject's own signed claim, carried whole. A head alone resolves to
40
+ # nothing: no relay serves a receipt.
41
+ #
42
+ # Only the gap-free, chain-verified prefix is carried. The budget
43
+ # stores a receipt that arrived after a lost link (docs/mechanisms.md
44
+ # 9g), but a verifier holds cited chains to fleetmesh_core.accounting.verify_chain,
45
+ # which is strict, so a report that attached the stored list whole
46
+ # was inadmissible whenever the wire had dropped one receipt. What is
47
+ # cut is noted on the pending record as evidence_partial.
48
+ signed = [r for r in rec.receipts if accounting.verify_receipt(r, rec.peer)]
49
+ admissible, partial = accounting.admissible_prefix(signed)
50
+ evidence = [('receipt', reports.evidence_id(r), r) for r in admissible]
51
+ raw = dict(rec.to_dict(), evidence_partial=partial)
52
+ return self._open(rec.peer, rec.epoch, sev, evidence, worst, 'window', raw)
53
+
54
+ def on_repriced(self: ReporterShape, peer: str, epoch: int, was: str, severity: str) -> Optional[list[tuple[str, int]]]:
55
+ """A window this node priced was read again and came out lower, because
56
+ the subject's receipt for it arrived after the grace had run.
57
+
58
+ The finding that window raised is no longer this node's finding. An
59
+ unpublished one is dropped before it can become a public record; a
60
+ published one is withdrawn, which is what complaint/1.0 has a withdraw
61
+ for and what this node already does when a remedy lands and the next
62
+ window is clean. Left alone it would stand for the whole of REPORT_TTL
63
+ as a permanent record of a delivery gap.
64
+
65
+ Only a correction to S0 withdraws. A reading that is milder but still
66
+ adverse leaves the report standing rather than replacing it with a
67
+ second one about the same window, which is the same strict direction
68
+ the budget takes when it declines to cancel a remedy it cannot rebuild.
69
+ """
70
+ if severity in reports.ADVERSE:
71
+ return None
72
+ with self._lock:
73
+ hit = [q for q in self.pending
74
+ if q.peer == peer and q.epoch == epoch and not q.withdrawn]
75
+ why = 'repriced %s after a receipt for that window arrived late' % was
76
+ out: list[tuple[str, int]] = []
77
+ for q in hit:
78
+ if q.report is None and not q.thid:
79
+ # Nothing was ever said about it: no notice, no report. There
80
+ # is no one to tell, so telling no one is the withdrawal - but
81
+ # the standing it cost is still this node's to give back.
82
+ with self._lock:
83
+ q.withdrawn = True
84
+ self._persist(q)
85
+ self._restore(q)
86
+ else:
87
+ # A receipt arrived late and the window was repriced: the
88
+ # verdict was wrong about a fact, so it takes its tier with it.
89
+ self.retract(q, why)
90
+ out.append((q.peer, q.epoch))
91
+ return out
92
+
93
+ def on_session(self: ReporterShape, peer: str, role: str, result: Mapping[str, Any]) -> Optional[Finding]:
94
+ """A sync session ended. Its S3 findings carry the events themselves."""
95
+ if role != 'initiator':
96
+ return None
97
+ worst = max((f[0] for f in result.get('findings', [])), default='S0', key=window.SEV_ORDER.index)
98
+ if worst not in reports.ADVERSE:
99
+ return None
100
+ # An envelope citation carries the signed hop; a public event needs
101
+ # only its id. Both are tuples headed by the kind.
102
+ evidence: list[tuple[Any, ...]] = []
103
+ for item in result.get('evidence', []):
104
+ ev, env = (item.get('event'), item.get('envelope')) if isinstance(item, dict) else (item, None)
105
+ # The envelope is the better citation: the peer signed the hop even
106
+ # when somebody else wrote the event, so it proves the peer sent this.
107
+ if env is not None and replication.signed_by(env, peer):
108
+ evidence.append(('envelope', reports.evidence_id(env), env))
109
+ elif ev is not None and giftwrap.verify_event(ev) and ev.get('pubkey') == peer:
110
+ evidence.append(('event', ev['id']))
111
+ relay.publish_all(self.relays, ev, self.timeout) # a public event needs no copy
112
+ row = self.grants.get(peer, grants_mod.ISSUED)
113
+ epoch = self.clock() // ((row['payload'].get('window') if row else None) or 3600)
114
+ return self._open(peer, epoch, worst, evidence, None, 'session', result)
115
+
116
+ def _open(
117
+ self: ReporterShape,
118
+ peer: str,
119
+ epoch: int,
120
+ severity: str,
121
+ evidence: list[Any],
122
+ worst: Optional[tuple[str, LimitDetail]],
123
+ source: str,
124
+ raw: Mapping[str, Any],
125
+ ) -> Optional[Finding]:
126
+ row = self.grants.get(peer, grants_mod.ISSUED)
127
+ if row is None:
128
+ return None
129
+ now = self.clock()
130
+ # A window is reported under the grant that governed it, which the
131
+ # budget names; the published report had named the current grant while
132
+ # its evidence was a receipt under the one before.
133
+ p = Finding(
134
+ fid=secrets.token_hex(8),
135
+ peer=peer, epoch=epoch, severity=severity, evidence=evidence, worst=worst,
136
+ source=source, grant=(raw.get('grant') if source == 'window' else None) or row['envelope_id'],
137
+ window_seconds=row['payload'].get('window', 3600),
138
+ opened_at=now,
139
+ why=[f for f in raw.get('findings', [])] if source == 'session' else [],
140
+ measured=raw.get('measured') or {}, state=raw.get('state'),
141
+ limits=dict((raw.get('limits') if source == 'window' else None) or row['payload'].get('limits', {})),
142
+ evidence_partial=bool(raw.get('evidence_partial', False)),
143
+ )
144
+ with self._lock:
145
+ self.pending.append(p)
146
+ self._persist(p)
147
+ if severity in complaint.C['notice_required_for']:
148
+ self._notify(p)
149
+ elif severity in complaint.C['immediate']:
150
+ self.publish(p, now) # may return None: see publish()
151
+ return p
152
+
153
+ # ---- the complaint, issuer side -------------------------------------------
154
+ def _notify(self: ReporterShape, p: Finding) -> None:
155
+ worst = p.worst
156
+ if worst is None:
157
+ # No per-limit finding: active silence. Name the metric that moved most.
158
+ moved = {k: v for k, v in p.measured.items() if k in LIMIT_METRICS}
159
+ metric = max(moved, key=lambda k: moved[k]) if moved else 'bytes_in'
160
+ detail: LimitDetail = {'measured': moved.get(metric, 0), 'limit': p.limits.get(metric, 0), 'claimed': None}
161
+ why = 'traffic measured and no receipt: active silence'
162
+ else:
163
+ metric, detail = worst
164
+ why = '; '.join(str(w) for w in p.why)[:200]
165
+ body = complaint.notice(p.severity, p.grant, p.epoch, metric, detail, self.clock(),
166
+ p.window_seconds, description=why)
167
+ # ref builds a typed body; the DIDComm envelope takes the plain mapping.
168
+ msg = complaint.message('notice', dict(body), self.ks.did, _did(p.peer), mid='complaint-%s' % secrets.token_hex(4))
169
+ self.wire_client.send(p.peer, msg, stored=True)
170
+ p.notice_at, p.thid = self.clock(), msg['id']
171
+ self._persist(p)