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,537 @@
1
+ """The Budget subsystem itself: construction and restore, the meters, the
2
+ window arithmetic, what the sync layer reports, and the Compartment contract.
3
+ Separate from the mixins because this is where the state they all share is
4
+ created; the mixins are the concerns that act on it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import collections
9
+ import threading
10
+ import time
11
+ from typing import Any, Callable, Counter, Mapping, Optional
12
+
13
+ from node.interfaces import ReceiptRefusedRow
14
+
15
+ from fleetmesh_core.accounting import accounting, standing
16
+ from fleetmesh_core.wire.interfaces import ReceiptBody
17
+ from fleetmesh_core.wire.wire_types import GoverningRow
18
+ from node.compartment import stores as st
19
+ from node.storage import accounts as accounts_mod, grants as grants_mod
20
+ from node.compartment import Command, Handler, Need, Outbound, WireProtocol
21
+ import dataclasses
22
+ from node.config.parameter import Parameter, ParamBlock
23
+ from node.channels import Channel, NOTE, TROUBLE, bus as eventbus_mod
24
+ from node.metrics import COUNTER, Metric
25
+ from .chain import OutChain
26
+ from .evaluation import Evaluation, Repricing
27
+ from .meter import C, KEEP_FINDINGS, PIURI, SYNC, TOLERANCE, Meter, _observe
28
+ from .receipts import ReceiptsMixin
29
+ from .tick import TickMixin
30
+ from .windows import WindowsMixin
31
+ from .types import BudgetStateReport
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True)
35
+ class BudgetConfig(ParamBlock):
36
+ key = 'budget'
37
+ name = 'budget'
38
+ DERIVED: bool = False
39
+ PARAMS = (
40
+ Parameter(
41
+ key='tolerance', name='Divergence Tolerance',
42
+ description='Ratio by which measured usage may exceed limit before divergence',
43
+ type=float, default=TOLERANCE, minimum=0.0, maximum=1.0,
44
+ tags=('budget', 'tolerance'),
45
+ ),
46
+ Parameter(
47
+ key='keep_windows', name='Keep Windows',
48
+ description='Number of closed windows retained before pruning',
49
+ type=int, default=24 * 7, minimum=1,
50
+ tags=('budget', 'history'),
51
+ ),
52
+ Parameter(
53
+ key='keep_findings', name='Keep Findings',
54
+ description='Maximum adverse findings kept in memory',
55
+ type=int, default=KEEP_FINDINGS, minimum=10,
56
+ tags=('budget', 'findings'),
57
+ ),
58
+ )
59
+ tolerance: float = TOLERANCE
60
+ keep_windows: int = 24 * 7
61
+ keep_findings: int = KEEP_FINDINGS
62
+
63
+
64
+ class Budget(ReceiptsMixin, WindowsMixin, TickMixin, WireProtocol):
65
+ requires: tuple[str, ...] = ('peering', 'strain')
66
+ """The accounting, as a compartment the operator can watch.
67
+
68
+ Its refusals are the ones that mattered: a receipt this node would not read,
69
+ one naming a window this grant cannot be about, and one naming a grant that
70
+ did not govern the window. That last was refused silently for a night on a
71
+ four-node mesh while every window closed as active silence, and nothing
72
+ anywhere said the two ends were naming different grants. It goes through the
73
+ channel every compartment has now.
74
+
75
+ Its `stop` is the farewell: a node that vanishes mid-window leaves its issuer
76
+ with traffic measured and no receipt, which is an S2 nobody earned.
77
+ """
78
+
79
+ name: str = 'budget'
80
+ piuris: tuple[str, ...] = (PIURI,)
81
+ config_cls = BudgetConfig
82
+ CHANNELS = (
83
+ Channel('window_evaluated', 'A closed window was evaluated against peer limits',
84
+ level=NOTE, wire=eventbus_mod._wire_evaluated,
85
+ say='evaluated window {epoch} for peer {peer} severity {severity}',
86
+ tags=('budget', 'evaluation')),
87
+ Channel('window_repriced', 'A peer limit was repriced under AIMD congestion control',
88
+ level=NOTE, wire=eventbus_mod._wire_repriced,
89
+ say='repriced peer {peer} for window {epoch}: {was} -> {now}',
90
+ tags=('budget', 'repricing')),
91
+ Channel('receipt_refused', 'A receipt from a peer was refused',
92
+ level=TROUBLE, say='receipt refused from peer {peer}: {reason}',
93
+ tags=('budget', 'receipt')),
94
+ )
95
+ METRICS = (
96
+ Metric(name='budget_windows_evaluated', description='Windows evaluated.',
97
+ kind=COUNTER, counts='window_evaluated', tags=('budget', 'evaluation')),
98
+ Metric(name='budget_windows_repriced', description='Windows repriced under AIMD.',
99
+ kind=COUNTER, counts='window_repriced', tags=('budget', 'repricing')),
100
+ Metric(name='budget_receipts_refused', description='Receipts refused from peers.',
101
+ kind=COUNTER, counts='receipt_refused', tags=('budget', 'receipt')),
102
+ )
103
+ OUTBOUND = (
104
+ Outbound('receipt', reply='receipt-ack', doc='deliver window accounting receipt'),
105
+ Outbound('close', doc='signal cooperative window closure'),
106
+ Outbound('receipt-ack', doc='acknowledge received receipt and head status'),
107
+ )
108
+ HANDLERS = (
109
+ Handler('receipt', run='handle_receipt', doc='answer inbound budget/1.0 receipt'),
110
+ Handler('close', run='handle_close', doc='answer inbound budget/1.0 window close'),
111
+ Handler('receipt-ack', run='handle_receipt_ack', doc='take inbound budget/1.0 receipt-ack'),
112
+ )
113
+ STORES = (
114
+ st.Store(key='evaluations', description='completed window evaluations',
115
+ kind=st.RING, shape=Evaluation, bound=KEEP_FINDINGS),
116
+ st.Store(key='findings', description='adverse window findings',
117
+ kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
118
+ st.Store(key='clock_drift', description='receipt clock drift events',
119
+ kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
120
+ st.Store(key='receipt_gaps', description='receipt sequence gaps',
121
+ kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
122
+ st.Store(key='unpublished', description='receipts unpublishable to relays',
123
+ kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
124
+ )
125
+ COMMANDS = (
126
+ Command(
127
+ key='show',
128
+ name='Budget',
129
+ description='what this node is accounting: peers metered, windows evaluated, findings'
130
+ ' held',
131
+ tags=('budget', 'status'),
132
+ permission='budget/show',
133
+ run='state',
134
+ reply='{peers, windows_evaluated, findings, grants_waiting_for_a_boundary, '
135
+ 'clock_drift, receipt_chains_open, idle_streaks, rejected, receipts_refused}',
136
+ ),
137
+ )
138
+
139
+ # Windows kept after they close. A window is history once it has been
140
+ # evaluated and the complaint deadline on it has passed, but a report may
141
+ # still cite the receipt that backs it, so the horizon is generous. Without
142
+ # one the accounting store grows for as long as the node runs. It is also
143
+ # how far back a receipt may reach; see on_receipt.
144
+ KEEP_WINDOWS: int = 24 * 7
145
+
146
+ evaluations: st.Ring[Evaluation]
147
+ findings: st.Ring[tuple[Any, ...]]
148
+ clock_drift: st.Ring[tuple[Any, ...]]
149
+ receipt_gaps: st.Ring[tuple[Any, ...]]
150
+ unpublished: st.Ring[tuple[Any, ...]]
151
+
152
+ NEEDS = (
153
+ Need('keystore', 'ks'),
154
+ Need('transport'),
155
+ Need('grant_store', 'stores.grants'),
156
+ Need('peering'),
157
+ Need('ledger'),
158
+ Need('accounts', 'stores.accounts'),
159
+ Need('strain'),
160
+ Need('events'),
161
+ Need('reporter', late=True),
162
+ )
163
+
164
+ def start(self) -> None:
165
+ if self.reporter is not None:
166
+ if self.reporter.on_evaluated not in self.on_evaluated:
167
+ self.on_evaluated.append(self.reporter.on_evaluated)
168
+ if self.reporter.on_repriced not in self.on_repriced:
169
+ self.on_repriced.append(self.reporter.on_repriced)
170
+ if self.events is not None:
171
+ events = self.events
172
+ self.on_evaluated.append(lambda rec: events.emit(eventbus_mod.TOPIC_WINDOW_EVALUATED, rec.to_dict()))
173
+ self.on_repriced.append(lambda *a: events.emit(eventbus_mod.TOPIC_WINDOW_REPRICED, *a))
174
+
175
+ def __init__(
176
+ self,
177
+ keystore: Any,
178
+ transport: Any,
179
+ grant_store: grants_mod.GrantStore,
180
+ peering: Any,
181
+ clock: Optional[Callable[[], int]] = None,
182
+ ledger: Optional[standing.Ledger] = None,
183
+ accounts: Optional[Any] = None,
184
+ strain: Optional[Any] = None,
185
+ node: Optional[Any] = None,
186
+ events: Optional[Any] = None,
187
+ reporter: Optional[Any] = None,
188
+ ) -> None:
189
+ super().__init__(node, clock)
190
+ self.strain: Optional[Any] = strain # what this node has survived, or None
191
+ self.accounts: Optional[Any] = accounts # AccountStore, or None for a node that forgets
192
+ self.events: Optional[Any] = events
193
+ self.reporter: Optional[Any] = reporter
194
+ self.ks: Any = keystore
195
+ self.transport: Any = transport
196
+ self.grants: grants_mod.GrantStore = grant_store
197
+ self.peering: Any = peering # for the WireNode that writes envelopes
198
+ self.ledger: standing.Ledger = ledger or standing.Ledger()
199
+ self._lock: threading.RLock = threading.RLock()
200
+ # (peer, direction) -> (window seconds, store change counter, monotonic at)
201
+ self._window_memo: dict[tuple[str, str], tuple[int, int, float]] = {} # not a store: memoized window calculations
202
+ self.usage_by: dict[str, dict[int, Meter]] = {} # not a store: restored from accounts, for persist=DURABLE
203
+ self.usage_of_me: dict[str, dict[int, Meter]] = {} # not a store: restored from accounts, for persist=DURABLE
204
+ self.receipts_in: dict[str, dict[int, list[ReceiptBody]]] = {} # not a store: restored from accounts, for persist=DURABLE
205
+ self.heads_in: dict[str, dict[int, str]] = {} # not a store: derived from restored receipts, for persist=DURABLE
206
+ self.chain_out: dict[str, dict[int, OutChain]] = {} # not a store: restored from accounts, for persist=DURABLE
207
+ self.acks: dict[str, Mapping[str, Any]] = {} # not a store: last ack per live peer
208
+ self.evaluated: set[tuple[str, int]] = set() # not a store: restored from accounts, for persist=DURABLE
209
+ self.zero_until: dict[str, int] = {} # not a store: time penalty per zeroed peer
210
+ self.idle: dict[str, int] = {} # not a store: restored from accounts evaluations, consecutive closed idle windows
211
+ # Each of these carries the time it happened, and `trouble` reads only
212
+ # the ones inside TROUBLE_WINDOW. A cumulative counter with no time
213
+ # bound warns forever about a fault that has stopped: every node here
214
+ # spent a day reporting 151 clock-drift events that were hours dead
215
+ # against NTP-synced boxes, and a peering warned for four hours about
216
+ # one message lost in a restart while completing thirty-four sessions.
217
+ # The compartment contract already says what to do - short enough that a
218
+ # fault which has stopped stops being reported - and the six
219
+ # compartments that report through `Refusals.within` already did it.
220
+ self.started: int = self.clock() # a receipt dated before this cannot be told from a relay replay
221
+ self.ack_lag: dict[str, tuple[int, int, int]] = {} # not a store: ack lag per live peer
222
+ self.ack_head: dict[str, tuple[int, int]] = {} # not a store: ack head per live peer
223
+ # Refusals go through the compartment channel. `rejected` remains as a
224
+ # read-only view of the same counts, because the health block, the CLI
225
+ # and three suites already read it by that name.
226
+ self.on_evaluated: list[Callable[[Evaluation], Any]] = [] # not a store: callbacks after every evaluated window
227
+ self.reopen: set[tuple[str, int]] = set() # not a store: priced windows with late receipts pending evaluation
228
+ self.repriced: list[Repricing] = [] # not a store: late receipt repricing records
229
+ self.on_repriced: list[Callable[[str, int, str, str], Any]] = [] # not a store: callbacks after a correction
230
+ if self.accounts is not None:
231
+ self._restore()
232
+
233
+ def _restore(self) -> None:
234
+ """Pick the accounting back up where the last process left it.
235
+
236
+ Without this a restart begins a fresh receipt chain at seq 0 while the
237
+ issuer is still expecting seq N, and reports counters lower than it
238
+ already reported: a broken chain and a counter going backwards, which
239
+ is an S2 misreport filed against a node that did nothing wrong.
240
+ """
241
+ assert self.accounts is not None
242
+ m = self.accounts.meters()
243
+ for side, table in ((accounts_mod.BY, self.usage_by), (accounts_mod.OF_ME, self.usage_of_me)):
244
+ for peer, by_epoch in m.get(side, {}).items():
245
+ for epoch, counters in by_epoch.items():
246
+ meter = table.setdefault(peer, {}).setdefault(epoch, Meter())
247
+ meter.c.update(counters)
248
+ self.receipts_in = self.accounts.receipts()
249
+ for peer, by_epoch in self.receipts_in.items():
250
+ for epoch, chain in by_epoch.items():
251
+ if chain:
252
+ self.heads_in.setdefault(peer, {})[epoch] = accounting.link(chain[-1])
253
+ self.chain_out = {peer: {epoch: OutChain.from_dict(s) for epoch, s in by_epoch.items()} # not a store: restored from accounts, for persist=DURABLE
254
+ for peer, by_epoch in self.accounts.chains().items()}
255
+ self.evaluated = self.accounts.evaluated()
256
+ # The ledger is what standing/1.0 tells a stranger and what weights a
257
+ # reporter. It was memory only, so a restart set every streak to zero
258
+ # and a reporter this node had watched for a week weighed nothing.
259
+ for peer, epoch, severity, tier in self.accounts.evaluations():
260
+ if severity is None:
261
+ continue
262
+ if tier is None: # recorded before the tier was kept: the grant that governed it
263
+ g = self.grants.governing(peer, grants_mod.ISSUED, epoch)
264
+ tier = (g['payload'].get('tier') if g else None) or 'probation'
265
+ self.ledger.record(peer, epoch, severity, tier)
266
+ # The idle streak decides when a dormant grant decays. Counted only in
267
+ # memory it would begin again at zero on every restart, and a node that
268
+ # is redeployed weekly would never reach the threshold at all - the same
269
+ # shape as the standing streak above.
270
+ rows = self.accounts.evaluations()
271
+ for peer in {r[0] for r in rows}:
272
+ run = 0
273
+ for _p, _e, sev, _t in [r for r in rows if r[0] == peer]:
274
+ run = run + 1 if sev is None else 0
275
+ self.idle[peer] = run
276
+
277
+ def _save_meters(self, peer: Optional[str] = None, epoch: Optional[int] = None) -> None:
278
+ if self.accounts is None:
279
+ return
280
+ # The sync thread adds a peer's first meter the moment its first
281
+ # session opens; the tick walks these tables. Snapshot under the lock.
282
+ with self._lock:
283
+ rows = [(side, pk, e, meter)
284
+ for side, table in ((accounts_mod.BY, self.usage_by), (accounts_mod.OF_ME, self.usage_of_me))
285
+ for pk, by_epoch in table.items() if peer is None or pk == peer
286
+ for e, meter in by_epoch.items() if epoch is None or e == epoch]
287
+ for side, pk, e, meter in rows:
288
+ self.accounts.put_meter(pk, e, side, meter.snapshot(), self.clock())
289
+
290
+ # ---- window arithmetic ----------------------------------------------------
291
+ # How long a remembered window size may be trusted without re-reading the
292
+ # grant. A window is a grant payload field that changes only when a grant
293
+ # is re-issued; any write through this node's own store connection drops
294
+ # the memo at once (sqlite's total_changes moves), and the TTL covers a
295
+ # write from another process on the same file.
296
+ WINDOW_MEMO_SECONDS: float = 2.0
297
+
298
+ def _window_of(self, peer: str, direction: grants_mod.GrantDirection) -> int:
299
+ """The window size of the grant in force, in seconds.
300
+
301
+ Every observed sync message asked this three times, and each ask was a
302
+ sqlite read plus a json.loads of the whole payload: measured at ~70%
303
+ of the cost of metering a message. The memo below is keyed on the
304
+ connection's change counter so a grant written through this store is
305
+ seen immediately.
306
+ """
307
+ key = (peer, direction)
308
+ changes = self.grants.db.total_changes
309
+ now_mono = time.monotonic()
310
+ memo = self._window_memo.get(key)
311
+ if memo is not None and memo[1] == changes and now_mono - memo[2] < self.WINDOW_MEMO_SECONDS:
312
+ return memo[0]
313
+ row = self.grants.get(peer, direction)
314
+ w = int((row['payload'].get('window') if row else None) or C['window_seconds_default'])
315
+ self._window_memo[key] = (w, changes, now_mono)
316
+ return w
317
+
318
+ def _peers(self) -> list[str]:
319
+ """The peers holding a live grant, as a plain list of pubkeys.
320
+
321
+ GrantStore.list() shadows the builtin inside the class body, so the
322
+ checker cannot read GrantStore.peers()' own annotation; this is the one
323
+ place the node re-states it (the fix belongs in node/grants.py).
324
+ """
325
+ # Read through Any: the store's return annotation is unusable to the
326
+ # checker, so its shape is re-stated on this side and validated by the
327
+ # call sites that index/sort it.
328
+ raw: Any = self.grants.peers()
329
+ peers: list[str] = [str(p) for p in raw]
330
+ return peers
331
+
332
+ def governing(self, peer: str, direction: grants_mod.GrantDirection, epoch: int) -> Optional[GoverningRow]:
333
+ """The grant that governed one window, which is what the window is
334
+ priced under and what a receipt for it names."""
335
+ return self.grants.governing(peer, direction, epoch)
336
+
337
+ def _acceptable_grants(self, peer: str, epoch: int) -> set[str]:
338
+ """Which grant ids a receipt for this window may name: the one that
339
+ governed it, the one before (an amend published at the boundary may
340
+ not have reached the subject yet) and the one current now (a seed
341
+ applied mid-window)."""
342
+ ids: set[str] = set()
343
+ for e in (epoch - 1, epoch):
344
+ g = self.grants.governing(peer, grants_mod.ISSUED, e)
345
+ if g:
346
+ ids.add(g['envelope_id'])
347
+ row = self.grants.get(peer, grants_mod.ISSUED)
348
+ if row:
349
+ ids.add(row['envelope_id'])
350
+ return ids
351
+
352
+ def epoch(self, peer: str, direction: grants_mod.GrantDirection, at: Optional[int] = None) -> int:
353
+ return accounting.epoch(at if at is not None else self.clock(), self._window_of(peer, direction))
354
+
355
+ def aggregate(self, at: Optional[int] = None) -> dict[str, int]:
356
+ """What every peer together is doing to this node, in the open window.
357
+
358
+ The issuer frame - `usage_by` is what a peer did to me - because that is
359
+ the frame a ceiling is about. A grant bounds one peer and nothing bounded
360
+ the node, so this is the number `capacity` measures pressure against.
361
+
362
+ Windows are per peering, so each peer contributes its own open epoch.
363
+ With the default window that is one hour for all of them; with mixed
364
+ windows it is still each peer's current promise, which is the honest
365
+ reading of "what is happening to me now".
366
+ """
367
+ now = at if at is not None else self.clock()
368
+ total: Counter[str] = collections.Counter()
369
+ with self._lock:
370
+ peers = list(self.usage_by)
371
+ for peer in peers:
372
+ e = self.epoch(peer, grants_mod.ISSUED, now)
373
+ with self._lock:
374
+ m = self.usage_by.get(peer, {}).get(e)
375
+ snap = m.snapshot() if m is not None else None
376
+ if snap:
377
+ total.update(snap)
378
+ return dict(total)
379
+
380
+ def _meter(self, table: dict[str, dict[int, Meter]], peer: str, epoch: int) -> Meter:
381
+ with self._lock:
382
+ return table.setdefault(peer, {}).setdefault(epoch, Meter())
383
+
384
+ # ---- what the sync layer reports ----------------------------------------
385
+ def observe(self, peer: str, message: Mapping[str, Any], i_initiated: bool, outbound: bool) -> None:
386
+ """One sync message, charged to the session's initiator.
387
+
388
+ If I opened the session it is my usage under the peer's grant,
389
+ whichever way this message went; if the peer opened it, it is the
390
+ peer's usage under mine. The wire knows who opened it by the thid.
391
+
392
+ An `open` is flushed before this returns, because the counter it moves
393
+ is the one this node's own guard reads to decide whether it may open
394
+ the next session. `observe_session` flushes at session END, which
395
+ bounds the loss to one session in flight - and one session in flight
396
+ is exactly what a restart loses. The node then reloaded a meter one
397
+ short, `throttled()` found room that was not there, and the node
398
+ opened a session the grant did not cover while believing it did. The
399
+ peer measured the overrun and priced it: S1 on conn_rate at 5 over a
400
+ grant of 4, which cost a peer 51 clean windows for restarting. See
401
+ I15 in docs/audit-2026-10-07-4-issues.md, where the arithmetic is
402
+ reproduced. Nothing else here needs to be durable this early - the
403
+ volume counters are reconciled by the receipt and their divergence
404
+ floor already absorbs a lost session by construction - so this is the
405
+ one message kind that writes through.
406
+ """
407
+ if not message.get('type', '').startswith(SYNC + '/'):
408
+ return
409
+ mine = self._meter(self.usage_of_me, peer, self.epoch(peer, grants_mod.RECEIVED))
410
+ theirs = self._meter(self.usage_by, peer, self.epoch(peer, grants_mod.ISSUED))
411
+ session_meter = mine if i_initiated else theirs
412
+ deliverer_meter = mine if outbound else theirs
413
+ _observe(session_meter, deliverer_meter, message, from_initiator=(i_initiated == outbound))
414
+ if message.get('type', '').rsplit('/', 1)[-1] == 'open' and self.accounts is not None:
415
+ # Exactly the one row the open moved, not every epoch on both
416
+ # sides: this is on the sync path, and `_save_meters(peer)` would
417
+ # rewrite each remembered window of each side per session.
418
+ side = accounts_mod.OF_ME if i_initiated else accounts_mod.BY
419
+ at = self.epoch(peer, grants_mod.RECEIVED if i_initiated else grants_mod.ISSUED)
420
+ self.accounts.put_meter(peer, at, side, session_meter.snapshot(), self.clock())
421
+
422
+ def observe_in(self, peer: str, message: Mapping[str, Any], i_initiated: bool = False) -> None:
423
+ """A sync message the peer sent me."""
424
+ self.observe(peer, message, i_initiated, outbound=False)
425
+
426
+ def observe_out(self, peer: str, message: Mapping[str, Any], i_initiated: bool = True) -> None:
427
+ """A sync message I sent the peer."""
428
+ self.observe(peer, message, i_initiated, outbound=True)
429
+
430
+ def observe_session(self, peer: str, role: str, result: Mapping[str, Any]) -> None:
431
+ """After a session: duplicates the peer delivered to me count against
432
+ them, and both meters for this peer reach the store.
433
+
434
+ `usage_of_me` was written down at every receipt - `send_receipt` puts
435
+ the meter before the message leaves - and `usage_by` was written only
436
+ by the tick. So a node that stopped between ticks came back having
437
+ forgotten some of what a peer had done to it while remembering all of
438
+ what it had done to the peer, and an issuer that under-measures reads
439
+ an honest claim as too high: divergence, and an S2 misreport against a
440
+ peer that did nothing but keep working across somebody else's crash.
441
+ The asymmetry was the bug. A session is the natural boundary, the same
442
+ one the other side already uses, and it bounds the loss to one session
443
+ in flight rather than a whole tick interval.
444
+ """
445
+ dups = result.get('counters', {}).get('dups_sent', 0)
446
+ if role == 'initiator' and dups:
447
+ self._meter(self.usage_by, peer, self.epoch(peer, grants_mod.ISSUED)).add('dups_sent', dups)
448
+ self._save_meters(peer)
449
+
450
+ # ---- the compartment contract -------------------------------------------------
451
+ @property
452
+ def rejected(self) -> Counter[str]:
453
+ """The refusal counts, under the name the health block and the suites
454
+ already use. One channel, two readers."""
455
+ return self.refusals.counts
456
+
457
+ def state(self) -> BudgetStateReport:
458
+ pending = sum(1 for p in self._peers() if self.grants.pending(p))
459
+ return {
460
+ 'peers': len(self._peers()),
461
+ 'windows_evaluated': len(self.evaluated),
462
+ 'findings': len(self.findings),
463
+ 'grants_waiting_for_a_boundary': pending,
464
+ 'clock_drift': len(self.clock_drift),
465
+ 'receipt_gaps': len(self.receipt_gaps),
466
+ 'receipts_behind': {pk: n for pk, (_e, n, _at) in self.ack_lag.items() if n},
467
+ 'receipts_unpublished': len(self.unpublished),
468
+ 'receipt_chains_open': sum(len(v) for v in self.chain_out.values()),
469
+ 'idle_streaks': {p[:16]: n for p, n in self.idle.items() if n},
470
+ # Declared rather than reached for: the health block read both
471
+ # of these off this object by name, which is why `rejected` had
472
+ # to survive as a property after the refusal channel replaced it.
473
+ 'rejected': dict(self.refusals.counts),
474
+ 'receipts_refused': self.receipts_refused(),
475
+ }
476
+
477
+ def receipts_refused(self) -> list[ReceiptRefusedRow]:
478
+ """The windows this node would not take a receipt for, because the two
479
+ ends name different grants. Five at most: it is a fault to fix, not a
480
+ history to read, and the counts keep the history."""
481
+ return [ReceiptRefusedRow(peer=r.subject and r.subject[:16], window=r.detail.get('window'),
482
+ named=r.detail.get('named'), acceptable=r.detail.get('acceptable') or [],
483
+ at=r.at)
484
+ for r in self.refusals.within(self.TROUBLE_WINDOW, self.clock(),
485
+ why='names a grant that did not govern that window')][-5:]
486
+
487
+ def trouble(self) -> list[str]:
488
+ out: list[str] = []
489
+ disagree = self.refusals.within(self.TROUBLE_WINDOW, self.clock(),
490
+ why='names a grant that did not govern that window')
491
+ if disagree:
492
+ peers = sorted({(r.subject or '')[:16] for r in disagree})
493
+ out.append('receipts refused over a grant disagreement with %s - every window '
494
+ 'those peerings close will be active silence until it is resolved'
495
+ % ', '.join(peers))
496
+ unaddressable = self.refusals.within(self.TROUBLE_WINDOW, self.clock(),
497
+ kind='accounting', why='the peer raised')
498
+ if unaddressable:
499
+ peers = sorted({(r.subject or '')[:16] for r in unaddressable})
500
+ out.append('accounting raised against %s and that window\'s work was skipped for '
501
+ 'them: most often a descriptor this node will not accept, which is what a '
502
+ 'peer left behind a protocol digest looks like from here' % ', '.join(peers))
503
+ # Only what is still happening. The record stays whole in `state`.
504
+ now = self.clock()
505
+ recent = lambda rows: [r for r in rows if now - r[-1] <= self.TROUBLE_WINDOW]
506
+ drifting, unsent_now, gaps_now = recent(self.clock_drift), recent(self.unpublished), recent(self.receipt_gaps)
507
+ if drifting:
508
+ out.append('%d receipts have been timed outside the tolerance: check NTP at both ends '
509
+ 'before reading any divergence' % len(drifting))
510
+ lagging = {pk: n for pk, (_e, n, at) in self.ack_lag.items() if n and now - at <= self.TROUBLE_WINDOW}
511
+ if lagging:
512
+ out.append('%d peer(s) are acking a chain head behind the one this node sent, by up to %d '
513
+ 'receipt(s): the claims exist and are not arriving'
514
+ % (len(lagging), max(lagging.values())))
515
+ if unsent_now:
516
+ out.append('%d receipt(s) no relay would take at all' % len(unsent_now))
517
+ if gaps_now:
518
+ out.append('%d receipt(s) never arrived and the chain carried on past them: the wire is '
519
+ 'dropping claims, and a window missing its tail still prices as an under-claim'
520
+ % len(gaps_now))
521
+ return out
522
+
523
+ def stop(self) -> list[tuple[Any, ...]]:
524
+ """Say what this node used before it goes. Never raises."""
525
+ try:
526
+ return self.farewell()
527
+ except Exception as e:
528
+ self.refusals.refuse('farewell', 'could not be sent', detail={'error': str(e)})
529
+ return []
530
+
531
+ def run(self, stop: threading.Event, interval: float = 5.0) -> None:
532
+ while not stop.is_set():
533
+ try:
534
+ self.tick()
535
+ except Exception as e: # the loop must outlive one bad tick
536
+ self.findings.append(('self', None, None, 'tick failed: %s' % e))
537
+ stop.wait(interval)
@@ -0,0 +1,80 @@
1
+ """The record one closed window leaves behind.
2
+
3
+ `Budget.evaluate` builds one per (peer, epoch) and appends it to
4
+ `budget.evaluations`; the reporter reads it to decide whether a finding
5
+ opens, the event bus wires a six-key slice of it, `status()` shows the tail,
6
+ and tests reach in for severity and detail. Nothing is stored whole: the
7
+ AccountStore keeps (peer, epoch, severity, tier) and the rest is rebuilt on
8
+ the next window.
9
+
10
+ A window that neither moved nor claimed anything is `not-evaluated`: it
11
+ has no severity, no detail, and no remedy. Those fields are Optional for
12
+ that reason only - an evaluated window has all of them.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import asdict, dataclass, field
18
+ from typing import Any, Mapping, Optional, cast
19
+ from fleetmesh_core.wire.interfaces import ( # noqa: E402
20
+ ReceiptBody,
21
+ SilenceEvaluation,
22
+ WindowEvaluationDetail,
23
+ )
24
+ from .types import EvaluationDict, RepricingDict
25
+
26
+ __all__ = ['Evaluation', 'Repricing', 'evaluation_like']
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class Evaluation:
31
+ peer: str
32
+ epoch: int
33
+ state: str # fleetmesh_core.accounting.window_claim: claim / active-silence / ...
34
+ measured: dict[str, int] # what this node counted, by metric
35
+ claimed: Optional[dict[str, int]] # what the subject's receipt said, if one came
36
+ tier_before: str
37
+ grant: str # envelope id of the grant that governed the window
38
+ limits: Mapping[str, int | float] # the ceilings it was priced against (fleetmesh_core.accounting.window's limit set)
39
+ severity: Optional[str] = None # None only when state == 'not-evaluated'
40
+ note: str = '' # fleetmesh_core.accounting.window.adjust's one-line reason
41
+ detail: dict[str, WindowEvaluationDetail | SilenceEvaluation] = field(default_factory=dict) # by metric, plus 'silence'
42
+ tier_after: Optional[str] = None
43
+ limits_after: Optional[Mapping[str, int | float]] = None
44
+ effective_from: Optional[int] = None
45
+ receipt_heads: list[str] = field(default_factory=list)
46
+ receipts: list[ReceiptBody] = field(default_factory=list) # the bodies as received, which are the evidence
47
+
48
+ def to_dict(self) -> EvaluationDict:
49
+ return cast(EvaluationDict, asdict(self))
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class Repricing:
54
+ """A window this node had priced, read again after its receipt arrived late."""
55
+ peer: str
56
+ epoch: int
57
+ was: str
58
+ severity: str
59
+ state: str
60
+ measured: dict[str, int]
61
+ claimed: Optional[dict[str, int]]
62
+ receipts: int
63
+ remedy_cancelled: bool
64
+ at: int
65
+
66
+ def to_dict(self) -> RepricingDict:
67
+ return cast(RepricingDict, asdict(self))
68
+
69
+
70
+ def evaluation_like(rec: Evaluation | Mapping[str, Any]) -> Evaluation:
71
+ """The reporter is handed Evaluations by the budget and plain mappings by
72
+ tests and plugins. Both are one shape."""
73
+ if isinstance(rec, Evaluation):
74
+ return rec
75
+ known = {f for f in Evaluation.__dataclass_fields__}
76
+ body = {k: v for k, v in rec.items() if k in known}
77
+ for k, default in (('state', ''), ('measured', {}), ('claimed', None), ('tier_before', ''),
78
+ ('grant', ''), ('limits', {})):
79
+ body.setdefault(k, default)
80
+ return Evaluation(**body)