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,868 @@
1
+ """BASE: the abstract compartment every mechanism in this node extends.
2
+
3
+ A compartment is one mechanism of the node - link, budget, announce - and it
4
+ is written as a set of declarations rather than wired into the node by hand.
5
+ Six of them, each a tuple of frozen records on the class, each with a census
6
+ behind it and a package that defines it:
7
+
8
+ PARAMS what it can be told its config block (node/config)
9
+ CHANNELS what it says node/channels
10
+ METRICS what it measures node/metrics
11
+ COMMANDS what it can be asked to do node/compartment/command.py
12
+ LIFECYCLE how it fails, and restarts node/lifecycle
13
+ STORES what it keeps in memory node/stores
14
+
15
+ The base class is abstract in two senses, both enforced:
16
+
17
+ * It cannot be built. `Compartment(node)` raises, and so does any class
18
+ that says `class X(Compartment, abstract=True)` - a KIND of compartment,
19
+ like sidecar_bridge.Bridge, which is extended rather than run.
20
+ * Its declarations are linted when a subclass is DEFINED, not when a node
21
+ happens to build it. A concrete compartment must have a name; a store,
22
+ command, channel or metric must be unique within it; a snapshot must have
23
+ something to carry; a command's `reads`/`writes` must name a store this
24
+ compartment declares, or one of a compartment it `requires` or `uses`.
25
+ A class that breaks one of those fails at import, with every problem
26
+ listed - which is what makes the declarations a lint rather than prose.
27
+
28
+ And it is a working default for everything a compartment need not say: no
29
+ tick, no state, no trouble, and a snapshot derived from its SNAPSHOT stores.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import threading
34
+ import time
35
+ from typing import (
36
+ TYPE_CHECKING,
37
+ Any,
38
+ Callable,
39
+ ClassVar,
40
+ Mapping,
41
+ Optional,
42
+ Protocol,
43
+ Sequence,
44
+ TypedDict,
45
+ TypeVar,
46
+ Union,
47
+ runtime_checkable,
48
+ )
49
+
50
+ from ..channels import journal
51
+ from . import lifecycle as lifecycle_mod
52
+ from . import stores as stores_mod
53
+ from ..interfaces import CommandSpec
54
+ from .command import Command
55
+ from .every import Every
56
+ from .problem import (
57
+ Note,
58
+ NoteList,
59
+ Trouble,
60
+ TroubleList,
61
+ )
62
+ from .refusals import Refusals
63
+ from .worker import Worker
64
+
65
+ T = TypeVar('T', bound='Compartment')
66
+
67
+
68
+ class EveryJobReport(TypedDict):
69
+ """One EVERY job as `report()` gives it. `seconds` and `next_run` are
70
+ None for a job that is off (`Every.is_off`)."""
71
+ seconds: Optional[Union[int, float]]
72
+ last_run: Optional[int]
73
+ next_run: Optional[int]
74
+
75
+
76
+ def wall() -> int:
77
+ """The wall clock in whole seconds: every compartment's default clock."""
78
+ return int(time.time())
79
+
80
+
81
+ if TYPE_CHECKING:
82
+ from .registry import Registry
83
+
84
+
85
+ @runtime_checkable
86
+ class CompartmentProtocol(Protocol):
87
+ """The contract the Registry relies on, stated structurally.
88
+
89
+ `Compartment` below is the convenient base class and every compartment in
90
+ node/ extends it, but the registry's verbs need only this surface. Naming
91
+ it lets a test double or a foreign compartment be typed against the
92
+ contract without inheriting the base.
93
+ """
94
+
95
+ # Read-only in the contract: a compartment declares `name: str = 'strain'`
96
+ # and a plain mutable attribute would make that an invariance mismatch.
97
+ @property
98
+ def name(self) -> Optional[str]: ...
99
+ @property
100
+ def attr(self) -> Optional[str]: ...
101
+ @property
102
+ def requires(self) -> tuple[str, ...]: ...
103
+ @property
104
+ def uses(self) -> tuple[str, ...]: ...
105
+ @property
106
+ def optional(self) -> bool: ...
107
+ @property
108
+ def enabled_by(self) -> Optional[str]: ...
109
+ @property
110
+ def reads(self) -> tuple[str, ...]: ...
111
+ @property
112
+ def live(self) -> tuple[str, ...]: ...
113
+ @property
114
+ def config_cls(self) -> Optional[type[Any]]: ...
115
+ @property
116
+ def api(self) -> str: ...
117
+ @property
118
+ def protocol(self) -> str: ...
119
+ @property
120
+ def piuris(self) -> tuple[str, ...]: ...
121
+ @property
122
+ def exact_types(self) -> tuple[str, ...]: ...
123
+ @property
124
+ def commands(self) -> Mapping[str, CommandSpec]: ...
125
+ @property
126
+ def CHANNELS(self) -> tuple[Any, ...]: ...
127
+ @property
128
+ def METRICS(self) -> tuple[Any, ...]: ...
129
+ @property
130
+ def COMMANDS(self) -> tuple[Any, ...]: ...
131
+ @property
132
+ def EVERY(self) -> tuple[Any, ...]: ...
133
+ @property
134
+ def WORKERS(self) -> tuple[Any, ...]: ...
135
+ @property
136
+ def LIFECYCLE(self) -> lifecycle_mod.Lifecycle: ...
137
+ @property
138
+ def STORES(self) -> tuple[Any, ...]: ...
139
+ @property
140
+ def NEEDS(self) -> tuple[Any, ...]: ...
141
+ @property
142
+ def SCHEMA(self) -> tuple[Any, ...]: ...
143
+ @property
144
+ def MIGRATIONS(self) -> tuple[Any, ...]: ...
145
+ @property
146
+ def HANDLERS(self) -> tuple[Any, ...]: ...
147
+ @property
148
+ def holdings(self) -> Optional[stores_mod.Holdings]: ...
149
+ @property
150
+ def db(self) -> Any: ...
151
+ @property
152
+ def refusals(self) -> Refusals: ...
153
+ @property
154
+ def cli(self) -> tuple[tuple[str, Callable[..., Any]], ...]: ...
155
+ @property
156
+ def sidecars(self) -> tuple[Any, ...]: ...
157
+
158
+ def wire(self, registry: 'Registry', ctx: Any) -> None: ...
159
+ def start(self) -> None: ...
160
+ def start_workers(self) -> None: ...
161
+ def stop_workers(self) -> None: ...
162
+ def tick(self, now: int) -> list[tuple[Any, ...]]: ...
163
+ def run_tick(self, now: int) -> list[tuple[Any, ...]]: ...
164
+ def stop(self) -> Any: ...
165
+ def state(self) -> Mapping[str, Any]: ...
166
+ def trouble(self) -> list[str]: ...
167
+ def notes(self) -> list[str]: ...
168
+ def report(self) -> Mapping[str, Any]: ...
169
+ def sections(self) -> Mapping[str, Any]: ...
170
+ def snapshot(self) -> Mapping[str, Any]: ...
171
+ def restore(self, state: Mapping[str, Any]) -> None: ...
172
+ def command(self, name: str, args: Mapping[str, Any]) -> Any: ...
173
+ def handle(self, inbound: Any) -> Any: ...
174
+ def capability_protocols(self) -> Sequence[str]: ...
175
+ def descriptor_tags(self) -> Sequence[Sequence[str]]: ...
176
+
177
+
178
+
179
+ class Compartment:
180
+ """One compartment of the node. Assembled, ticked and read uniformly.
181
+
182
+ The `subsystem/1` declaration. Every compartment states, as class
183
+ attributes, what it is and what it needs, and `Registry.assemble()` reads
184
+ those to build the node in dependency order:
185
+
186
+ name the one word the CLI, status.json and the docs use
187
+ requires names of compartments this one must be built after
188
+ (it receives them already constructed through ctx)
189
+ uses names of OPTIONAL compartments this one reaches when
190
+ they are present; it must still work without them. The
191
+ assembler holds them to the same manifest order as
192
+ `requires` (listed earlier) but does not demand they be
193
+ built. Resolve each once in `from_context` with
194
+ `ctx.optional(name)`; never probe the node per call.
195
+ optional True if the node runs without it; it is then built only
196
+ when `enabled_by` names a config section that is present
197
+ enabled_by the config key whose presence turns an optional one on
198
+ reads / live config keys read; of those, the ones re-read each tick
199
+ api the declaration version this class was written against
200
+ protocol the schema digest it was built against, or '' if it
201
+ reads no schema (checked at assembly when set)
202
+ piuris the wire protocols (PIURI prefixes) this one handles;
203
+ the registry routes each to its `handle(inbound)`
204
+ exact_types whole message types it handles, routed exactly
205
+
206
+ `from_context(ctx)` is the one constructor the assembler calls. The
207
+ default forwards to `cls(node=ctx.node)` so a compartment that has not
208
+ been migrated still builds; a migrated one overrides it and names its
209
+ dependencies there and nowhere else.
210
+ """
211
+
212
+ API: str = 'subsystem/1'
213
+
214
+ # How far back `trouble` looks. Long enough to catch a fault that recurs on
215
+ # a slow cadence - receipts go every ten minutes - and short enough that a
216
+ # fault which has stopped stops being reported.
217
+ TROUBLE_WINDOW: int = 1800
218
+
219
+ name: Optional[str] = None
220
+ attr: Optional[str] = None # attribute the node keeps it under, when not `name`
221
+ requires: tuple[str, ...] = () # compartments built before this one
222
+ uses: tuple[str, ...] = () # optional compartments reached when present
223
+ optional: bool = False # off unless `enabled_by` is configured
224
+ enabled_by: Optional[str] = None # config key that turns an optional one on
225
+ reads: tuple[str, ...] = () # config keys this compartment reads
226
+ live: tuple[str, ...] = () # of those, the ones a running node re-reads each tick
227
+ config_cls: Optional[type[Any]] = None # the node/config Block that types this one's own key
228
+ api: str = API
229
+ protocol: str = '' # schema digest built against, or ''
230
+ piuris: tuple[str, ...] = () # PIURI prefixes routed to handle()
231
+ exact_types: tuple[str, ...] = () # exact message types routed to handle()
232
+ commands: Mapping[str, CommandSpec] = {} # name -> {'args': {...}, 'does': str, 'reply': str}
233
+ # What this compartment can say, declared here rather than as a topic name
234
+ # in a central list. A Channel carries both renderings - the structured
235
+ # payload a reader gets and the line a person reads - so an event cannot
236
+ # reach the control socket and miss the journal, which is what 45
237
+ # journal.line call sites against 6 bus emits had already produced. See
238
+ # node/channels.py.
239
+ CHANNELS: tuple[Any, ...] = ()
240
+ # What this compartment measures. node/metrics reads it through
241
+ # getattr(obj, 'METRICS', ()), so the census never needed the default -
242
+ # which is exactly why its absence went unnoticed until something asked a
243
+ # compartment for `c.METRICS` directly and got AttributeError on 27 of
244
+ # 29. Four surfaces, four defaults: a declaration surface that is only
245
+ # present when used is not a surface.
246
+ METRICS: tuple[Any, ...] = ()
247
+ COMMANDS: tuple[Any, ...] = ()
248
+ EVERY: tuple[Any, ...] = ()
249
+ WORKERS: tuple[Any, ...] = ()
250
+ # How this compartment behaves when it fails: the breaker that turns
251
+ # repeated tick failures into a quarantine with backoff. The default
252
+ # suits nearly every compartment; one that must be ticked whatever
253
+ # happens declares Lifecycle(breaker=Breaker(failures=0)). See
254
+ # node/lifecycle.
255
+ LIFECYCLE: lifecycle_mod.Lifecycle = lifecycle_mod.DEFAULT
256
+ # What this compartment keeps: every collection that grows with the
257
+ # outside world, with its bound and what happens at it. The base builds
258
+ # one container per store and binds it as `self.<key>`, so the
259
+ # declaration is also the allocation and the two cannot drift. See
260
+ # node/stores.
261
+ STORES: tuple[stores_mod.Store, ...] = ()
262
+ # What this compartment takes from the node: its dependencies, declared
263
+ # where the compartment is. The base `from_context` resolves each against
264
+ # the NodeContext at assembly. See node/compartment/need.py.
265
+ NEEDS: tuple[Any, ...] = ()
266
+ # What this compartment persists: relational database schema, declared
267
+ # as Table and Index entries. The base instantiates a database adapter
268
+ # (sqlite, psql, etc.) bound to the compartment.
269
+ SCHEMA: tuple[Any, ...] = ()
270
+ # Schema evolution: versioned migrations applied in order by the database
271
+ # adapter after SCHEMA.
272
+ MIGRATIONS: tuple[Any, ...] = ()
273
+ # Inbound wire protocol message handlers, dispatched by base handle(inbound).
274
+ HANDLERS: tuple[Any, ...] = ()
275
+ # CLI extension verbs declared by a plugin or compartment: (('verb', register_fn),)
276
+ cli: tuple[tuple[str, Callable[..., Any]], ...] = ()
277
+ # Sidecar manifests declared by a plugin or compartment: ({...}, '@/path/to/manifest.json')
278
+ sidecars: tuple[Any, ...] = ()
279
+ # Runtime collections, database adapter, and refusals buffer bound to this instance
280
+ holdings: Optional[stores_mod.Holdings] = None
281
+ db: Any = None
282
+ # The store class a compartment's `db` must be, when it is not the generic
283
+ # adapter. from_context fills a `db` parameter, and without this it filled
284
+ # it with a SqliteAdapter whatever the class used it as - so Updates, which
285
+ # reads `db` as an UpdateStore, could not be built by a running node at
286
+ # all (audit 2026-10-15, B13). Named here, the store is opened as that
287
+ # class, through the node's locator like every other store.
288
+ #
289
+ # Naming it also says the compartment owns its store: the base opens one
290
+ # only to hand to a `db` parameter, never on its own account. A class that
291
+ # declares SCHEMA and builds its own store used to get one or two generic
292
+ # adapters as well, which nothing read - an orphan `<name>.sqlite` on disk
293
+ # and a handle left to the collector (B5).
294
+ STORE_CLS: Optional[type] = None
295
+ refusals: Refusals = None # type: ignore[assignment]
296
+
297
+ # Set from the class keyword: `class Bridge(Compartment, abstract=True)`.
298
+ # Read from the class's own __dict__, so a concrete subclass of an
299
+ # abstract kind is concrete without saying so.
300
+ abstract: ClassVar[bool] = True
301
+
302
+ def __init_subclass__(cls, abstract: bool = False, **kw: Any) -> None:
303
+ """Derive what is derived, then lint what is declared.
304
+
305
+ Derived: `commands` from `COMMANDS`, so the control socket and the CLI
306
+ never need a hand-written dict beside the declarations; and `live`
307
+ for this compartment's own config key from its parameters (see
308
+ `_derive_live`).
309
+
310
+ Linted: everything in `declaration_problems`. A problem is a
311
+ TypeError naming the class and every problem at once, raised here -
312
+ at import - so a malformed compartment never reaches a node.
313
+ """
314
+ super().__init_subclass__(**kw)
315
+ cls.abstract = abstract
316
+ # Read before `commands` is derived, which writes it into __dict__.
317
+ hand_written = 'commands' in cls.__dict__
318
+ if cls.COMMANDS:
319
+ cls.commands = {c.key: c.to_spec(subsystem=cls.name or '')
320
+ for c in cls.COMMANDS if isinstance(c, Command)}
321
+ if cls.HANDLERS and 'exact_types' not in cls.__dict__:
322
+ exact_from_handlers = tuple(
323
+ h.kind for h in cls.HANDLERS
324
+ if getattr(h, 'exact', False) or getattr(h, 'kind', '').startswith(('http://', 'https://'))
325
+ )
326
+ if exact_from_handlers:
327
+ cls.exact_types = tuple(sorted(set(cls.exact_types + exact_from_handlers)))
328
+ cls._derive_live()
329
+ problems = cls.declaration_problems()
330
+ if cls.COMMANDS and hand_written:
331
+ problems.append('a compartment that declares COMMANDS does not also write `commands`: '
332
+ 'it is derived from them')
333
+ if problems:
334
+ raise TypeError('%s declares what it cannot be:\n %s' % (cls.__qualname__, '\n '.join(problems)))
335
+
336
+
337
+ @classmethod
338
+ def _derive_live(cls) -> None:
339
+ """`live` for this compartment's own config key, from its parameters.
340
+
341
+ `live` named the config keys a running node re-reads each tick, and
342
+ `Parameter.live` says the same thing per setting - two expressions of
343
+ one fact, kept in step by hand. The parameters win: whether a reload
344
+ reaches a value is a property of the value, and the compartment's
345
+ tuple was the copy.
346
+
347
+ Only the compartment's OWN key is derived, and only when its block
348
+ states parameters. Other keys in `live` belong to blocks this
349
+ compartment merely reads (`scheduler` re-reads `peers`, which is a
350
+ NodeConfig field and not its own block) and stay declared.
351
+ """
352
+ blk = cls.config_cls
353
+ params = getattr(blk, 'PARAMS', None)
354
+ own = getattr(blk, 'key', '') if blk is not None else ''
355
+ if params is None or not own:
356
+ return
357
+ rest = tuple(k for k in (cls.live or ()) if k != own)
358
+ cls.live = rest + ((own,) if any(p.live for p in params) else ())
359
+
360
+ @classmethod
361
+ def declaration_problems(cls) -> list[str]:
362
+ """Everything wrong with this class's declarations, as sentences.
363
+
364
+ Collected rather than raised one at a time, like `Metric.problems`
365
+ and `config.Problems`: whoever fixes one wants to see them all.
366
+ Returns Problems (a list subclass) holding typed Problem instances.
367
+ """
368
+ from . import lint as lint_mod
369
+ return lint_mod.declaration_problems(cls, Compartment)
370
+
371
+
372
+ @classmethod
373
+ def store_refs(cls) -> tuple[tuple[str, str, str], ...]:
374
+ """(command, compartment, store) for every store of ANOTHER
375
+ compartment this one's commands read."""
376
+ out: list[tuple[str, str, str]] = []
377
+ for c in cls.COMMANDS:
378
+ if isinstance(c, Command):
379
+ for ref in c.reads:
380
+ other, dot, key = ref.partition('.')
381
+ if dot:
382
+ out.append((c.key, other, key))
383
+ return tuple(out)
384
+
385
+ def __new__(cls, *args: Any, **kw: Any) -> 'Compartment':
386
+ if cls.__dict__.get('abstract', False):
387
+ raise TypeError('%s is abstract: extend it, with a name, rather than build it' % cls.__name__)
388
+ return super().__new__(cls)
389
+
390
+ def __init__(
391
+ self,
392
+ node: Any = None,
393
+ clock: Optional[Callable[[], int]] = None,
394
+ cfg: Optional[Mapping[str, Any] | Callable[[], Mapping[str, Any]]] = None,
395
+ says: Optional[Any] = None,
396
+ db: Optional[Any] = None,
397
+ ) -> None:
398
+ self.node: Any = node
399
+ # The clock every compartment reads: the node's (`ctx.clock`), or the
400
+ # wall in whole seconds. 28 of 29 compartments took one and 25 wrote
401
+ # the same default by hand, after this ran - which is why the ledger
402
+ # below used to reach for `getattr(self, 'clock', time.time)`. It is
403
+ # still read late, through `self.clock`, so a suite that swaps the
404
+ # clock on a built compartment moves the refusals with it.
405
+ self.clock: Callable[[], int] = clock or wall
406
+ self._injected_cfg: Optional[Mapping[str, Any] | Callable[[], Mapping[str, Any]]] = cfg
407
+ self._injected_says: Optional[Any] = says
408
+ self.refusals: Refusals = Refusals(self.name, clock=lambda: int(self.clock()))
409
+ # One container per declared store, each bound under its key. A
410
+ # subclass that assigns `self.<key>` afterwards has replaced the
411
+ # container with something unbounded; Registry.add refuses that.
412
+ self.holdings: stores_mod.Holdings = stores_mod.Holdings(self.STORES)
413
+ for key, container in self.holdings.items():
414
+ setattr(self, key, container)
415
+ self._every_last: dict[str, int] = {}
416
+ self._stop_event: threading.Event = threading.Event()
417
+ self._worker_threads: dict[str, threading.Thread] = {}
418
+
419
+ if db is not None:
420
+ self.db: Any = db
421
+ elif getattr(self, 'db', None) is not None:
422
+ pass # Already initialized by subclass
423
+ elif self.SCHEMA and self.STORE_CLS is None:
424
+ self.db = type(self).open_store(getattr(node, 'locator', None), getattr(node, 'dir', None),
425
+ self.clock)
426
+ else:
427
+ self.db = None
428
+
429
+ @classmethod
430
+ def open_store(cls, locator: Any, directory: Optional[str], clock: Callable[[], int]) -> Any:
431
+ """This compartment's store, where the node keeps it. The one place a
432
+ compartment's database is opened: `__init__` and `from_context` each
433
+ had a copy of this, and both ran for one build (audit D5).
434
+
435
+ STORE_CLS when named, else the generic adapter over SCHEMA. Through
436
+ the node's locator when it has one, its directory when not, and in
437
+ memory for a compartment built with neither, as a suite builds it.
438
+ """
439
+ from ..storage.adapter import SqliteAdapter, adapter_from_locator
440
+ from ..storage.locator import Locator
441
+ if locator is None and directory:
442
+ locator = Locator.from_config(directory)
443
+ name = cls.name or 'store'
444
+ if cls.STORE_CLS is not None:
445
+ return locator.open(cls.STORE_CLS, name) if locator is not None else cls.STORE_CLS(':memory:')
446
+ if locator is not None:
447
+ return adapter_from_locator(locator, name, schema=cls.SCHEMA, migrations=cls.MIGRATIONS, clock=clock)
448
+ return SqliteAdapter(':memory:', schema=cls.SCHEMA, migrations=cls.MIGRATIONS, clock=clock)
449
+
450
+ @property
451
+ def stop_event(self) -> threading.Event:
452
+ return self._stop_event
453
+
454
+ @property
455
+ def stopping(self) -> bool:
456
+ return self._stop_event.is_set()
457
+
458
+ @classmethod
459
+ def from_context(cls: type[T], ctx: Any) -> T:
460
+ """Build from a NodeContext. When NEEDS is declared, constructs from declarations."""
461
+ declared: tuple[Any, ...] = tuple(getattr(cls, 'NEEDS', ()) or ())
462
+ if not declared and not cls.SCHEMA:
463
+ return cls(node=ctx.node)
464
+ kwargs = {}
465
+ for need in declared:
466
+ if not getattr(need, 'late', False):
467
+ kwargs[need.key] = need.resolve(ctx, cls)
468
+ import inspect
469
+ sig = inspect.signature(cls.__init__)
470
+ params = sig.parameters
471
+ has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
472
+ if ('node' in params or has_varkw) and 'node' not in kwargs:
473
+ kwargs['node'] = getattr(ctx, 'node', None)
474
+ if ('clock' in params or has_varkw) and 'clock' not in kwargs:
475
+ kwargs['clock'] = getattr(ctx, 'clock', None)
476
+ if ('cfg' in params or has_varkw) and 'cfg' not in kwargs:
477
+ kwargs['cfg'] = getattr(ctx, 'cfg_getter', lambda: getattr(ctx, 'cfg', None))()
478
+ if ('says' in params or has_varkw) and 'says' not in kwargs:
479
+ kwargs['says'] = getattr(ctx, 'says', None) or getattr(getattr(ctx, 'node', None), 'says', None)
480
+ # Opened only for a constructor that takes it. One that does not builds
481
+ # its own, and a store opened here for it was a handle nobody closed.
482
+ if cls.SCHEMA and 'db' not in kwargs and ('db' in params or has_varkw):
483
+ node = getattr(ctx, 'node', None)
484
+ kwargs['db'] = cls.open_store(getattr(ctx, 'locator', None) or getattr(node, 'locator', None),
485
+ getattr(ctx, 'dir', None) or getattr(node, 'dir', None),
486
+ ctx.clock)
487
+ return cls(**kwargs)
488
+
489
+
490
+ def settings(self) -> Any:
491
+ """This compartment's own config block, typed, as the file says now.
492
+
493
+ `config_cls` names the Block; its `key` is read through `config()`
494
+ so the read is counted against `reads` like any other. Parsed on
495
+ each call, so a compartment that lists its key in `live` sees an
496
+ edit without a restart; the others call it once at construction.
497
+ """
498
+ if self.config_cls is None:
499
+ raise TypeError('%s declares no config_cls' % type(self).__name__)
500
+ # `or {}` turned `key: false` into `{}`, which a SWITCHABLE block reads
501
+ # as present - so on (audit 2026-10-15, B6). from_dict already handles
502
+ # a bare bool, and anything else that is not a mapping, itself; only
503
+ # an absent key becomes {} here, as it always did.
504
+ raw = self.config(self.config_cls.key)
505
+ return self.config_cls.from_dict({} if raw is None else raw)
506
+
507
+ # ---- lifecycle ----------------------------------------------------------
508
+ def wire(self, registry: 'Registry', ctx: Any) -> None:
509
+ """Reach the compartments built after this one, once all exist.
510
+
511
+ The assembler builds in tick order and some edges run against it:
512
+ link records into strain, which ticks later; peering refuses through
513
+ capacity, built after budget. Each compartment names those edges
514
+ here, with `registry.get(name)`, and nowhere else - the node used to
515
+ hold every one of them in one block that nothing checked. Called
516
+ once, after the whole manifest is built and before `start()`.
517
+ """
518
+
519
+ def start_workers(self) -> None:
520
+ """Start all declared WORKERS that are not already running."""
521
+ if not hasattr(self, '_stop_event'):
522
+ self._stop_event = threading.Event()
523
+ self._stop_event.clear()
524
+ if not hasattr(self, '_worker_threads'):
525
+ self._worker_threads = {}
526
+ import inspect
527
+ for w in getattr(self, 'WORKERS', ()):
528
+ if not isinstance(w, Worker):
529
+ continue
530
+ if not w.should_run(self):
531
+ continue
532
+ existing = self._worker_threads.get(w.name)
533
+ if existing is not None and existing.is_alive():
534
+ continue
535
+ fn = getattr(self, w.run, None)
536
+ if not callable(fn):
537
+ continue
538
+ sig = inspect.signature(fn)
539
+ target = (lambda f=fn: f(self._stop_event)) if len(sig.parameters) > 0 else fn
540
+ t = threading.Thread(target=target, name=w.name or f"{self.name or 'compartment'}-{w.run}", daemon=w.daemon)
541
+ self._worker_threads[w.name] = t
542
+ t.start()
543
+
544
+ def stop_workers(self) -> None:
545
+ """Signal and join all running worker threads."""
546
+ if hasattr(self, '_stop_event'):
547
+ self._stop_event.set()
548
+ stop_attr = getattr(self, '_stop', None)
549
+ if stop_attr is not None and isinstance(stop_attr, threading.Event):
550
+ stop_attr.set()
551
+ threads = getattr(self, '_worker_threads', {})
552
+ for w in getattr(self, 'WORKERS', ()):
553
+ if not isinstance(w, Worker):
554
+ continue
555
+ t = threads.get(w.name)
556
+ if t is not None and t.is_alive():
557
+ t.join(timeout=w.timeout)
558
+ # A thread still running after its timeout keeps its handle. Its
559
+ # handle used to be dropped regardless, so the next start_workers
560
+ # - a restart after quarantine - found no thread and started a
561
+ # second copy beside the one still running (audit 2026-10-15, B7).
562
+ # Kept, start_workers sees it alive and leaves it be; report()
563
+ # keeps saying it is alive.
564
+ if t is None or not t.is_alive():
565
+ threads.pop(w.name, None)
566
+
567
+ def start(self) -> None:
568
+ """Everything that needs the rest of the node to exist already."""
569
+ self.start_workers()
570
+
571
+ def run_every(self, now: int) -> list[tuple[Any, ...]]:
572
+ """Run each declared EVERY job that is due at `now`, recording last run time."""
573
+ did: list[tuple[Any, ...]] = []
574
+ if not hasattr(self, '_every_last'):
575
+ self._every_last = {}
576
+ # Each job on its own: one that raised used to end the tick for every
577
+ # job after it (audit 2026-10-15, B10). The first raise is kept and
578
+ # raised once the rest have run, so the registry still counts a failed
579
+ # tick and its breaker still trips. A job that raised is stamped like
580
+ # one that ran, so it waits its interval rather than retrying hot.
581
+ failed: Optional[BaseException] = None
582
+ for job in getattr(self, 'EVERY', ()):
583
+ if not isinstance(job, Every):
584
+ continue
585
+ if not job.due(self, now):
586
+ continue
587
+ self._every_last[job.name] = now
588
+ try:
589
+ res = job.execute(self, now)
590
+ except Exception as e:
591
+ if failed is None:
592
+ failed = e
593
+ continue
594
+ if res is None:
595
+ continue
596
+ if isinstance(res, (list, tuple)):
597
+ if res and isinstance(res[0], tuple):
598
+ did.extend(res)
599
+ elif res and not isinstance(res[0], tuple):
600
+ did.append(tuple(res))
601
+ elif isinstance(res, dict):
602
+ rep = None
603
+ if job.report is not None:
604
+ if callable(job.report):
605
+ rep = job.report(self, res)
606
+ else:
607
+ rep = getattr(self, job.report)(res)
608
+ else:
609
+ reporter = getattr(self, f'_report_{job.run}', None)
610
+ if reporter is not None:
611
+ rep = reporter(res)
612
+ if rep:
613
+ if isinstance(rep, (list, tuple)):
614
+ did.extend(rep)
615
+ else:
616
+ did.append(rep)
617
+ if failed is not None:
618
+ raise failed
619
+ return did
620
+
621
+ _tick_runs_every: bool = False
622
+
623
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
624
+ """Periodic work. Runs due EVERY jobs when not overridden."""
625
+ return self.run_every(now)
626
+
627
+ def run_tick(self, now: int) -> list[tuple[Any, ...]]:
628
+ """Run periodic work (EVERY) and any custom tick logic."""
629
+ if self.__class__.tick is Compartment.tick:
630
+ return self.run_every(now)
631
+ if self._tick_runs_every:
632
+ return list(self.tick(now) or [])
633
+ return self.run_every(now) + list(self.tick(now) or [])
634
+
635
+ def stop(self) -> Any:
636
+ """Anything that must be said before the node goes. Must not raise."""
637
+ self.stop_workers()
638
+ db = getattr(self, 'db', None)
639
+ if db is not None:
640
+ self.db = None
641
+ if hasattr(db, 'close'):
642
+ try:
643
+ db.close()
644
+ except Exception:
645
+ pass
646
+ return None
647
+
648
+ def __del__(self) -> None:
649
+ """Close the database handle of a compartment nobody stopped. Only that.
650
+
651
+ This called stop(), and a subclass's stop() is where a compartment
652
+ says goodbye - the budget's is the farewell receipt to every peer. So
653
+ an instance the collector found, one `assemble()` had thrown away,
654
+ could speak for a node that never ran it (audit 2026-10-15, B8). The
655
+ handle is what the collector must not leak; the rest is stop()'s, and
656
+ stop() is the registry's to call.
657
+
658
+ Not wrapped: a raise here cannot propagate, and Python reports it
659
+ through sys.unraisablehook - which is the one way a finalizer can say
660
+ a close failed (check-swallowed counts `close` as a call that matters).
661
+ """
662
+ db = self.__dict__.get('db')
663
+ if db is not None and hasattr(db, 'close'):
664
+ db.close()
665
+
666
+ # ---- being watched ------------------------------------------------------
667
+ def state(self) -> Mapping[str, Any]:
668
+ """What this compartment is, as plain data. Never raises. A
669
+ compartment with a shape worth naming returns its TypedDict."""
670
+ return {}
671
+
672
+ def trouble_with(
673
+ self,
674
+ message: str,
675
+ *,
676
+ code: str = 'fault',
677
+ severity: str = 'trouble',
678
+ since: Optional[int] = None,
679
+ hint: Optional[str] = None,
680
+ **metadata: Any,
681
+ ) -> Trouble:
682
+ """Create a typed Trouble instance associated with this compartment."""
683
+ return Trouble(
684
+ message,
685
+ code=code,
686
+ compartment=self.name or '',
687
+ severity=severity,
688
+ since=since or self.clock(),
689
+ hint=hint,
690
+ **metadata,
691
+ )
692
+
693
+ def trouble(self) -> list[str]:
694
+ """What an operator should be told without having to ask for it.
695
+
696
+ Everything here is WRONG: a watcher reds on it and a deploy fails on
697
+ it. Something worth reading that is not a fault goes in `notes()`.
698
+ May return a list of strings or typed Trouble instances.
699
+ """
700
+ return TroubleList()
701
+
702
+ def note_with(
703
+ self,
704
+ message: str,
705
+ *,
706
+ topic: str = 'general',
707
+ level: str = 'info',
708
+ since: Optional[int] = None,
709
+ **metadata: Any,
710
+ ) -> Note:
711
+ """Create a typed Note instance associated with this compartment."""
712
+ return Note(
713
+ message,
714
+ topic=topic,
715
+ compartment=self.name or '',
716
+ level=level,
717
+ since=since or self.clock(),
718
+ **metadata,
719
+ )
720
+
721
+ def notes(self) -> list[str]:
722
+ """What an operator would want to read, that is not a fault.
723
+
724
+ A relay cooling after a rate-limit, a peer that announced it is
725
+ leaving: the node behaving, and the reason a quiet mesh is quiet.
726
+ Kept apart from `trouble()` so no reader has to classify prose.
727
+ May return a list of strings or typed Note instances.
728
+ """
729
+ return NoteList()
730
+
731
+
732
+ def report(self) -> Mapping[str, Any]:
733
+ """state plus refusals, which is what health and the CLI both render.
734
+ Plus, for a compartment that declares stores, what each one holds now
735
+ (sizes and counts only - a report never carries the entries)."""
736
+ s = dict(self.state())
737
+ s['refusals'] = self.refusals.state()
738
+ holdings = getattr(self, 'holdings', None)
739
+ if holdings is not None and len(holdings):
740
+ s['stores'] = holdings.live()
741
+ s.update(self.sections())
742
+ if getattr(self, 'EVERY', ()):
743
+ if not hasattr(self, '_every_last'):
744
+ self._every_last = {}
745
+ every_rep: dict[str, EveryJobReport] = {}
746
+ for job in self.EVERY:
747
+ if not isinstance(job, Every):
748
+ continue
749
+ last = self._every_last.get(job.name)
750
+ if job.is_off(self):
751
+ # Off is null, not a number: there is no next run.
752
+ every_rep[job.name] = {'seconds': None, 'last_run': last, 'next_run': None}
753
+ continue
754
+ sec = job.get_seconds(self)
755
+ sec_val = int(sec) if sec == int(sec) else sec
756
+ next_run = (last + int(sec)) if last is not None else None
757
+ every_rep[job.name] = {
758
+ 'seconds': sec_val,
759
+ 'last_run': last,
760
+ 'next_run': next_run,
761
+ }
762
+ s['every'] = every_rep
763
+ if getattr(self, 'WORKERS', ()):
764
+ threads = getattr(self, '_worker_threads', {})
765
+ s['workers'] = {
766
+ w.name: {
767
+ 'alive': bool(threads.get(w.name) is not None and threads[w.name].is_alive())
768
+ }
769
+ for w in self.WORKERS if isinstance(w, Worker)
770
+ }
771
+ return s
772
+
773
+ def sections(self) -> Mapping[str, Any]:
774
+ """What a KIND of compartment adds to every report of its kind - a
775
+ bridge, its link - so that a subclass's own state() stays its own."""
776
+ return {}
777
+
778
+ # ---- across a process restart ----------------------------------------------
779
+ def snapshot(self) -> Mapping[str, Any]:
780
+ """What this compartment hands the next process: every SNAPSHOT store,
781
+ by key. Override only for state that is not in a store, and then
782
+ override restore() with it."""
783
+ return self.holdings.snapshot() if self.holdings is not None else {}
784
+
785
+ def restore(self, state: Mapping[str, Any]) -> None:
786
+ """Take back what snapshot() handed on. All or nothing: every store
787
+ checks its part before any is loaded, so a raise leaves it fresh."""
788
+ if self.holdings is not None:
789
+ self.holdings.restore(state)
790
+
791
+ def get_command(self, name: str) -> Optional[Any]:
792
+ """Look up a declared command by key, bound to this compartment."""
793
+ cmd = next((c for c in getattr(self, 'COMMANDS', ()) if getattr(c, 'key', None) == name), None)
794
+ if cmd is not None and hasattr(cmd, 'bind'):
795
+ return cmd.bind(self)
796
+ return None
797
+
798
+ @property
799
+ def bound_commands(self) -> tuple[Any, ...]:
800
+ """All declared commands bound to this compartment instance."""
801
+ return tuple(c.bind(self) for c in getattr(self, 'COMMANDS', ()) if hasattr(c, 'bind'))
802
+
803
+ # ---- being operated -----------------------------------------------------
804
+ def command(self, name: str, args: Mapping[str, Any]) -> Any:
805
+ """Answer a declared command: its arguments checked against the
806
+ declaration, then what its `run` names. A KeyError for a name this
807
+ compartment does not declare; a CommandError for arguments it does
808
+ not take.
809
+
810
+ The KeyError is the declared contract and a dozen suites assert it, so
811
+ it stays. `Registry.dispatch` is what turns this one shape into a
812
+ ControlError on the way to the control socket - see the note there
813
+ (audit I14)."""
814
+ cmd = next((c for c in self.COMMANDS if isinstance(c, Command) and c.key == name), None)
815
+ if cmd is None:
816
+ raise KeyError(name)
817
+ return cmd.execute(self, args)
818
+
819
+ def handle(self, inbound: Any, *args: Any, **kw: Any) -> Any:
820
+ """Answer an inbound wire protocol message according to HANDLERS declarations."""
821
+ if not self.HANDLERS:
822
+ return None
823
+ msg = getattr(inbound, 'message', inbound)
824
+ if not isinstance(msg, dict):
825
+ return None
826
+ t = msg.get('type', '')
827
+ if not isinstance(t, str):
828
+ return None
829
+ for h in self.HANDLERS:
830
+ if hasattr(h, 'matches') and h.matches(t, self.piuris):
831
+ return h.execute(self, inbound, *args, **kw)
832
+ return None
833
+
834
+ def config(self, key: str, default: Any = None) -> Any:
835
+
836
+ """A config key, or `default`. Safe on a compartment with no node."""
837
+ cfg = self._injected_cfg
838
+ if callable(cfg):
839
+ cfg = cfg()
840
+ if cfg is None and self.node is not None:
841
+ cfg = getattr(self.node, 'cfg', None)
842
+ return (cfg or {}).get(key, default)
843
+
844
+ def say(self, name: str, *args: Any, **kw: Any) -> Any:
845
+ """Raise a declared channel to the node log, if a log is reachable.
846
+
847
+ In a running node, delegates to the injected `says` logger or `self.node.says.say()`.
848
+ In tests or standalone runs without a full node log, formats the channel's declared
849
+ line and prints to `journal.line`, preserving the existing format and avoiding regressions.
850
+ """
851
+ log = self._injected_says
852
+ if log is None and self.node is not None:
853
+ log = getattr(self.node, 'says', None)
854
+ if log is not None and hasattr(log, 'say'):
855
+ return log.say(name, *args, **kw)
856
+ ch = next((c for c in getattr(self, 'CHANNELS', ()) if c.name == name), None)
857
+ if ch is not None:
858
+ journal.line(' %s' % (self.name or ''), ch.said(*args, **kw))
859
+ return None
860
+
861
+ # ---- capability advertisement -------------------------------------------
862
+ def capability_protocols(self) -> Sequence[str]:
863
+ """Protocols (PIURIs or dialects) this compartment advertises in kind 11801 descriptor."""
864
+ return ()
865
+
866
+ def descriptor_tags(self) -> Sequence[Sequence[str]]:
867
+ """Extra Nostr tags this compartment adds to the kind 11801 descriptor."""
868
+ return ()