hypermind 0.11.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 (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,797 @@
1
+ """Real TCP transport — async TCP gossipsub-shape with identity binding.
2
+
3
+ Each peer-to-peer connection performs a 2-message handshake binding
4
+ both endpoints' Ed25519 identities, then exchanges length-prefixed
5
+ CBOR frames carrying topic + message. Topic membership is reputation-
6
+ weighted: a peer joining a mesh announces its kid; the local node
7
+ checks reputation policy before accepting traffic.
8
+
9
+ This is a working real-network transport (not a stub) — start nodes on
10
+ different ports, dial them via `connect()`, and gossip propagates over
11
+ asyncio TCP streams. Production deployments may swap this for libp2p
12
+ gossipsub v1.2; the `GossipBus` interface stays the same.
13
+
14
+ WS-G hardening (v0.7):
15
+ * Per-peer bounded write queue (`asyncio.Queue(maxsize=PEER_QUEUE_MAX)`)
16
+ + dedicated sender task per peer. Prevents one slow peer from
17
+ pinning memory or stalling the publish fan-out path.
18
+ * Drop-newest backpressure: queue-full → ``peer_drops_total{kid}``
19
+ counter, peer score penalty, and graylist after 3 drops in 30s.
20
+ * Per-(source_kid, topic) token bucket: 100 msg/s burst 200. Excess
21
+ is rejected with a score penalty.
22
+ * Per-peer EWMA score: ``score = w1*time_in_mesh + w2*deliveries
23
+ - w3*invalid - w4*backpressure_drops``. Below threshold the peer
24
+ is graylisted for ``GRAYLIST_DURATION_S``.
25
+ * Two-tier seen cache: per-peer LRU (1024) + global secondary LRU
26
+ (16k). Defeats both per-flow flooding and amplification attacks.
27
+
28
+ Wire frame format:
29
+ [u32 BE length][CBOR map { "type": str, ...payload }]
30
+
31
+ Frame types:
32
+ "hello": {"kid": bytes32, "alg": int, "sig": bytes, "nonce": bytes32}
33
+ "publish": {"topic": str, "message": bytes, "msg_id": bytes32}
34
+ "subscribe": {"topic": str}
35
+ "ack": {"msg_id": bytes32}
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import secrets
42
+ import struct
43
+ import time
44
+ from collections import OrderedDict, deque
45
+ from collections.abc import Awaitable, Callable
46
+ from dataclasses import dataclass, field
47
+
48
+ import cbor2
49
+ from nacl.signing import SigningKey
50
+
51
+ from hypermind.crypto.signing import Keypair, verify_ed25519
52
+ from hypermind.transport.bus import Subscription
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Tunables (WS-G robustness profile).
56
+ # ---------------------------------------------------------------------------
57
+
58
+ SEEN_CACHE_MAX = 16384 # global secondary LRU cap (memory DoS bound).
59
+ PER_PEER_SEEN_MAX = 1024 # per-peer LRU; first-line dedup tier.
60
+ PEER_QUEUE_MAX = 4096 # per-peer write queue depth.
61
+
62
+ # Token-bucket rate limit per (source_kid, topic).
63
+ RATE_LIMIT_RATE = 100.0 # tokens/second steady state.
64
+ RATE_LIMIT_BURST = 200.0 # max bucket size.
65
+
66
+ # Backpressure / graylist policy.
67
+ DROP_BURST_THRESHOLD = 3 # consecutive drops within window.
68
+ DROP_BURST_WINDOW_S = 30.0
69
+ GRAYLIST_DURATION_S = 3600.0 # 1 hour default.
70
+
71
+ # EWMA scoring weights (per RFC-style gossipsub v1.2 inspiration).
72
+ SCORE_W_TIME_IN_MESH = 0.1
73
+ SCORE_W_DELIVERIES = 1.0
74
+ SCORE_W_INVALID = 5.0
75
+ SCORE_W_BACKPRESSURE = 2.0
76
+ SCORE_GRAYLIST_THRESHOLD = -50.0
77
+
78
+ Handler = Callable[[bytes], Awaitable[None]]
79
+
80
+ FRAME_HEADER = struct.Struct(">I")
81
+ MAX_FRAME = 16 * 1024 * 1024 # 16 MB hard cap
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Token-bucket rate limiter (per (source_kid, topic)).
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ @dataclass
90
+ class _TokenBucket:
91
+ """Lazy-fill token bucket. Allows ``rate`` tokens/sec, burst ``capacity``.
92
+
93
+ Updates the bucket only on consume — no background task required.
94
+ """
95
+
96
+ rate: float
97
+ capacity: float
98
+ tokens: float
99
+ last_ms: int
100
+
101
+ @classmethod
102
+ def create(cls, rate: float = RATE_LIMIT_RATE, burst: float = RATE_LIMIT_BURST) -> _TokenBucket:
103
+ return cls(rate=rate, capacity=burst, tokens=burst, last_ms=_now_ms())
104
+
105
+ def try_consume(self, n: float = 1.0) -> bool:
106
+ now = _now_ms()
107
+ elapsed = max(0, now - self.last_ms) / 1000.0
108
+ self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
109
+ self.last_ms = now
110
+ if self.tokens >= n:
111
+ self.tokens -= n
112
+ return True
113
+ return False
114
+
115
+
116
+ @dataclass
117
+ class _PeerConn:
118
+ kid: bytes
119
+ reader: asyncio.StreamReader
120
+ writer: asyncio.StreamWriter
121
+ topics: set[str] = field(default_factory=set)
122
+ # WS-G: bounded write queue + dedicated sender task.
123
+ send_queue: asyncio.Queue[dict] = field(
124
+ default_factory=lambda: asyncio.Queue(maxsize=PEER_QUEUE_MAX)
125
+ )
126
+ sender_task: asyncio.Task | None = None
127
+ # Per-peer LRU dedup (first-tier).
128
+ seen: OrderedDict[bytes, None] = field(default_factory=OrderedDict)
129
+ # Score state.
130
+ connected_at_ms: int = field(default_factory=lambda: _now_ms())
131
+ deliveries: int = 0
132
+ invalid_frames: int = 0
133
+ backpressure_drops: int = 0
134
+ drop_history: deque[float] = field(default_factory=deque)
135
+
136
+ def record_seen(self, msg_id: bytes) -> bool:
137
+ """Insert into per-peer LRU. Returns True if NEW (caller should
138
+ dispatch); False if duplicate (caller should drop)."""
139
+ if msg_id in self.seen:
140
+ self.seen.move_to_end(msg_id)
141
+ return False
142
+ self.seen[msg_id] = None
143
+ while len(self.seen) > PER_PEER_SEEN_MAX:
144
+ self.seen.popitem(last=False)
145
+ return True
146
+
147
+ def score(self) -> float:
148
+ """Composite EWMA-style score. Higher is better; below
149
+ SCORE_GRAYLIST_THRESHOLD triggers graylisting."""
150
+ time_in_mesh_s = max(0, _now_ms() - self.connected_at_ms) / 1000.0
151
+ return (
152
+ SCORE_W_TIME_IN_MESH * time_in_mesh_s
153
+ + SCORE_W_DELIVERIES * self.deliveries
154
+ - SCORE_W_INVALID * self.invalid_frames
155
+ - SCORE_W_BACKPRESSURE * self.backpressure_drops
156
+ )
157
+
158
+
159
+ class TCPGossipBus:
160
+ """Real asyncio TCP gossipsub bus.
161
+
162
+ Each instance can simultaneously be a server (accepting incoming
163
+ connections) and a client (dialing peers). Once handshake completes,
164
+ `publish(topic, message)` fans out to every connected peer that has
165
+ subscribed to that topic; `subscribe(topic, handler)` registers a
166
+ local handler that receives both local + remote publishes.
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ *,
172
+ identity: Keypair,
173
+ host: str = "127.0.0.1",
174
+ port: int = 0,
175
+ require_tls: bool = False,
176
+ tls_trust_anchors: list[bytes] | None = None,
177
+ tls_allow_aes128: bool = False,
178
+ ):
179
+ self.identity = identity
180
+ self.host = host
181
+ self.port = port
182
+ # L2 mTLS profile (§22). When True, listen / connect socket is
183
+ # wrapped with the TLS 1.3 profile from `tls_profile.py` BEFORE
184
+ # the existing CBOR handshake runs. The handshake, SEEN_CACHE_MAX
185
+ # LRU, and duplicate-kid rejection are unchanged — TLS is purely
186
+ # additive here.
187
+ self.require_tls = require_tls
188
+ self._tls_allow_aes128 = tls_allow_aes128
189
+ self._tls_trust_anchors: list[bytes] = list(tls_trust_anchors or [])
190
+ self._tls_cert_pem: bytes | None = None
191
+ self._server: asyncio.AbstractServer | None = None
192
+ self._peers: dict[bytes, _PeerConn] = {}
193
+ self._handlers: dict[str, list[Handler]] = {}
194
+ # Two-tier dedup: per-peer (in _PeerConn.seen) + global secondary.
195
+ # OrderedDict-as-LRU bounds memory DoS via msg_id flooding.
196
+ self._seen: OrderedDict[bytes, None] = OrderedDict()
197
+ self._lock = asyncio.Lock()
198
+ self._closed = False
199
+ self._tasks: set[asyncio.Task] = set()
200
+ # Bad-frame counter — surfaced via `metrics()` for monitoring.
201
+ self._bad_frames: int = 0
202
+ # Per-peer drop counter — surfaced via `metrics()`.
203
+ self._peer_drops_total: dict[bytes, int] = {}
204
+ # Per-peer rate-rejected counter — surfaced via `metrics()`.
205
+ self._rate_rejects_total: dict[bytes, int] = {}
206
+ # Token buckets keyed by (source_kid, topic).
207
+ self._buckets: dict[tuple[bytes, str], _TokenBucket] = {}
208
+ # Graylist: kid → expiry wall-time seconds.
209
+ self._graylist: dict[bytes, float] = {}
210
+
211
+ # ---- server / dial ------------------------------------------------
212
+
213
+ async def start(self) -> int:
214
+ """Bind and start accepting peers; return the bound port."""
215
+ ssl_ctx = self._build_server_tls_context() if self.require_tls else None
216
+ self._server = await asyncio.start_server(
217
+ self._on_accept, host=self.host, port=self.port, ssl=ssl_ctx
218
+ )
219
+ sock = self._server.sockets[0]
220
+ self.port = sock.getsockname()[1]
221
+ return self.port
222
+
223
+ # ---- TLS context builders (§22 L2 profile) ------------------------
224
+
225
+ def _build_server_tls_context(self):
226
+ from hypermind.transport.tls_profile import (
227
+ ed25519_priv_from_secret,
228
+ make_server_context,
229
+ selfsigned_cert_for_kid,
230
+ )
231
+
232
+ priv = ed25519_priv_from_secret(self.identity.secret_bytes)
233
+ if self._tls_cert_pem is None:
234
+ self._tls_cert_pem = selfsigned_cert_for_kid(self.identity.kid, priv)
235
+ return make_server_context(
236
+ priv,
237
+ self._tls_cert_pem,
238
+ trust_anchors=self._tls_trust_anchors or None,
239
+ allow_aes128=self._tls_allow_aes128,
240
+ )
241
+
242
+ def _build_client_tls_context(self):
243
+ from hypermind.transport.tls_profile import (
244
+ ed25519_priv_from_secret,
245
+ make_client_context,
246
+ selfsigned_cert_for_kid,
247
+ )
248
+
249
+ priv = ed25519_priv_from_secret(self.identity.secret_bytes)
250
+ if self._tls_cert_pem is None:
251
+ self._tls_cert_pem = selfsigned_cert_for_kid(self.identity.kid, priv)
252
+ anchors = self._tls_trust_anchors or [self._tls_cert_pem]
253
+ return make_client_context(
254
+ anchors,
255
+ client_cert_pem=self._tls_cert_pem,
256
+ client_key=priv,
257
+ allow_aes128=self._tls_allow_aes128,
258
+ )
259
+
260
+ async def connect(self, peer_host: str, peer_port: int) -> bytes:
261
+ """Dial a peer and complete the handshake. Returns peer's kid.
262
+
263
+ Rejects connections to a kid we already have a live peer for —
264
+ defeats the "race-to-overwrite" attack where an adversary
265
+ learning a target's kid evicts the legitimate session
266
+ (closes self-heal Cycle-1 Finding #3).
267
+ """
268
+ ssl_ctx = self._build_client_tls_context() if self.require_tls else None
269
+ reader, writer = await asyncio.open_connection(
270
+ peer_host,
271
+ peer_port,
272
+ ssl=ssl_ctx,
273
+ server_hostname=peer_host if ssl_ctx is not None else None,
274
+ )
275
+ try:
276
+ if self.require_tls:
277
+ _verify_tls_alpn(writer)
278
+ peer_kid = await self._handshake_initiator(reader, writer)
279
+ if self.require_tls:
280
+ _verify_tls_kid_binding(writer, peer_kid)
281
+ except Exception:
282
+ writer.close()
283
+ try:
284
+ await writer.wait_closed()
285
+ except Exception:
286
+ pass
287
+ raise
288
+ if self._is_graylisted(peer_kid):
289
+ writer.close()
290
+ try:
291
+ await writer.wait_closed()
292
+ except Exception:
293
+ pass
294
+ raise RuntimeError(f"peer kid {peer_kid.hex()[:16]}… is graylisted")
295
+ peer = _PeerConn(kid=peer_kid, reader=reader, writer=writer)
296
+ async with self._lock:
297
+ if peer_kid in self._peers:
298
+ # Reject collision; close the new connection cleanly.
299
+ writer.close()
300
+ try:
301
+ await writer.wait_closed()
302
+ except Exception:
303
+ pass
304
+ raise RuntimeError(f"peer kid {peer_kid.hex()[:16]}… already connected")
305
+ self._peers[peer_kid] = peer
306
+ self._spawn_sender(peer)
307
+ # Re-announce existing local subscriptions to the new peer.
308
+ for topic in self._handlers:
309
+ self._enqueue(peer, {"type": "subscribe", "topic": topic})
310
+ task = asyncio.create_task(self._read_loop(peer))
311
+ self._tasks.add(task)
312
+ task.add_done_callback(self._tasks.discard)
313
+ return peer_kid
314
+
315
+ async def _on_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
316
+ try:
317
+ if self.require_tls:
318
+ _verify_tls_alpn(writer)
319
+ peer_kid = await self._handshake_responder(reader, writer)
320
+ if self.require_tls:
321
+ _verify_tls_kid_binding(writer, peer_kid)
322
+ except Exception:
323
+ writer.close()
324
+ try:
325
+ await writer.wait_closed()
326
+ except Exception:
327
+ pass
328
+ return
329
+ if self._is_graylisted(peer_kid):
330
+ writer.close()
331
+ try:
332
+ await writer.wait_closed()
333
+ except Exception:
334
+ pass
335
+ return
336
+ peer = _PeerConn(kid=peer_kid, reader=reader, writer=writer)
337
+ async with self._lock:
338
+ if peer_kid in self._peers:
339
+ # Reject duplicate kid — preserves the legitimate peer.
340
+ writer.close()
341
+ try:
342
+ await writer.wait_closed()
343
+ except Exception:
344
+ pass
345
+ return
346
+ self._peers[peer_kid] = peer
347
+ self._spawn_sender(peer)
348
+ for topic in self._handlers:
349
+ self._enqueue(peer, {"type": "subscribe", "topic": topic})
350
+ await self._read_loop(peer)
351
+
352
+ # ---- handshake ----------------------------------------------------
353
+ # Both sides exchange: their kid + a fresh nonce + a signature over
354
+ # (their_nonce || peer_nonce || self.kid). This binds the identity
355
+ # to the session and prevents replay across connections.
356
+
357
+ async def _handshake_initiator(
358
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
359
+ ) -> bytes:
360
+ my_nonce = secrets.token_bytes(32)
361
+ # First message: send our kid + nonce (no sig yet).
362
+ await _write_frame(
363
+ writer,
364
+ {
365
+ "type": "hello",
366
+ "kid": self.identity.kid,
367
+ "alg": self.identity.alg,
368
+ "nonce": my_nonce,
369
+ "sig": b"",
370
+ },
371
+ )
372
+ peer_msg = await _read_frame(reader)
373
+ if peer_msg.get("type") != "hello":
374
+ raise RuntimeError("expected hello from responder")
375
+ peer_kid = bytes(peer_msg["kid"])
376
+ if len(peer_kid) != 32:
377
+ raise RuntimeError("peer handshake: invalid kid length")
378
+ peer_nonce = bytes(peer_msg["nonce"])
379
+ peer_sig = bytes(peer_msg["sig"])
380
+ # Verify peer's signature over (peer_nonce || my_nonce || peer_kid).
381
+ peer_preimage = peer_nonce + my_nonce + peer_kid
382
+ if not verify_ed25519(peer_kid, peer_sig, peer_preimage):
383
+ raise RuntimeError("peer handshake signature invalid")
384
+ # Send our signature.
385
+ my_preimage = my_nonce + peer_nonce + self.identity.kid
386
+ my_sig = SigningKey(self.identity.secret_bytes[:32]).sign(my_preimage).signature
387
+ await _write_frame(
388
+ writer,
389
+ {
390
+ "type": "hello_ack",
391
+ "kid": self.identity.kid,
392
+ "sig": my_sig,
393
+ },
394
+ )
395
+ return peer_kid
396
+
397
+ async def _handshake_responder(
398
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
399
+ ) -> bytes:
400
+ peer_msg = await _read_frame(reader)
401
+ if peer_msg.get("type") != "hello":
402
+ raise RuntimeError("expected hello from initiator")
403
+ peer_kid = bytes(peer_msg["kid"])
404
+ if len(peer_kid) != 32:
405
+ raise RuntimeError("peer handshake: invalid kid length")
406
+ peer_nonce = bytes(peer_msg["nonce"])
407
+ my_nonce = secrets.token_bytes(32)
408
+ # Sign (my_nonce || peer_nonce || my_kid)
409
+ my_preimage = my_nonce + peer_nonce + self.identity.kid
410
+ my_sig = SigningKey(self.identity.secret_bytes[:32]).sign(my_preimage).signature
411
+ await _write_frame(
412
+ writer,
413
+ {
414
+ "type": "hello",
415
+ "kid": self.identity.kid,
416
+ "alg": self.identity.alg,
417
+ "nonce": my_nonce,
418
+ "sig": my_sig,
419
+ },
420
+ )
421
+ # Receive initiator's signature (over their_nonce || my_nonce || their_kid)
422
+ ack = await _read_frame(reader)
423
+ if ack.get("type") != "hello_ack":
424
+ raise RuntimeError("expected hello_ack from initiator")
425
+ peer_sig = bytes(ack["sig"])
426
+ peer_preimage = peer_nonce + my_nonce + peer_kid
427
+ if not verify_ed25519(peer_kid, peer_sig, peer_preimage):
428
+ raise RuntimeError("initiator signature invalid")
429
+ return peer_kid
430
+
431
+ # ---- pub/sub interface --------------------------------------------
432
+
433
+ async def publish(self, topic: str, message: bytes) -> None:
434
+ if self._closed:
435
+ raise RuntimeError("bus is closed")
436
+ if not isinstance(message, (bytes, bytearray)):
437
+ raise TypeError(f"message must be bytes, got {type(message).__name__}")
438
+ msg_id = secrets.token_bytes(32)
439
+ async with self._lock:
440
+ self._record_seen(msg_id)
441
+ # Local delivery first (own subscribers see own publishes).
442
+ for h in list(self._handlers.get(topic, ())):
443
+ await _safe_invoke(h, message)
444
+ # Then fan-out to every peer subscribed to the topic.
445
+ async with self._lock:
446
+ peers = [p for p in self._peers.values() if topic in p.topics]
447
+ frame = {"type": "publish", "topic": topic, "message": message, "msg_id": msg_id}
448
+ for p in peers:
449
+ self._enqueue(p, frame)
450
+
451
+ def _record_seen(self, msg_id: bytes) -> None:
452
+ """Global secondary LRU (`_seen`) cache. Evicts oldest when full.
453
+
454
+ First-tier dedup is per-peer in `_PeerConn.seen`; this global
455
+ tier catches messages we already saw via *some other peer*."""
456
+ if msg_id in self._seen:
457
+ self._seen.move_to_end(msg_id)
458
+ return
459
+ self._seen[msg_id] = None
460
+ while len(self._seen) > SEEN_CACHE_MAX:
461
+ self._seen.popitem(last=False)
462
+
463
+ def metrics(self) -> dict[str, object]:
464
+ """Operational counters (bad frames, peer count, drops, scores)."""
465
+ # Snapshot keyed-counters into hex-shortened views for readability.
466
+ peer_drops = {kid.hex()[:16]: v for kid, v in self._peer_drops_total.items()}
467
+ rate_rejects = {kid.hex()[:16]: v for kid, v in self._rate_rejects_total.items()}
468
+ scores = {p.kid.hex()[:16]: p.score() for p in self._peers.values()}
469
+ return {
470
+ "peers": len(self._peers),
471
+ "seen_cache": len(self._seen),
472
+ "bad_frames": self._bad_frames,
473
+ "peer_drops_total": peer_drops,
474
+ "rate_rejects_total": rate_rejects,
475
+ "peer_scores": scores,
476
+ "graylist_size": len(self._graylist),
477
+ }
478
+
479
+ async def subscribe(self, topic: str, handler: Handler) -> Subscription:
480
+ if self._closed:
481
+ raise RuntimeError("bus is closed")
482
+ async with self._lock:
483
+ self._handlers.setdefault(topic, []).append(handler)
484
+ peers = list(self._peers.values())
485
+ # Announce subscription to all known peers (queued; best-effort).
486
+ for p in peers:
487
+ self._enqueue(p, {"type": "subscribe", "topic": topic})
488
+
489
+ def _cancel() -> None:
490
+ try:
491
+ self._handlers[topic].remove(handler)
492
+ except (ValueError, KeyError):
493
+ pass
494
+
495
+ return Subscription(topic=topic, _cancel=_cancel)
496
+
497
+ async def close(self) -> None:
498
+ if self._closed:
499
+ return
500
+ self._closed = True
501
+ async with self._lock:
502
+ peers = list(self._peers.values())
503
+ self._peers.clear()
504
+ for peer in peers:
505
+ if peer.sender_task is not None:
506
+ peer.sender_task.cancel()
507
+ try:
508
+ peer.writer.close()
509
+ await peer.writer.wait_closed()
510
+ except Exception:
511
+ pass
512
+ if self._server is not None:
513
+ self._server.close()
514
+ await self._server.wait_closed()
515
+ self._server = None
516
+ for task in list(self._tasks):
517
+ task.cancel()
518
+
519
+ # ---- I/O ----------------------------------------------------------
520
+
521
+ async def _read_loop(self, peer: _PeerConn) -> None:
522
+ """Decode + dispatch frames. Malformed frames bump the
523
+ bad-frame counter and continue (a single bad frame must NOT
524
+ kill the connection — closes self-heal Cycle-1 Finding #9
525
+ from data review).
526
+ """
527
+ try:
528
+ while not self._closed:
529
+ try:
530
+ msg = await _read_frame(peer.reader)
531
+ except (cbor2.CBORDecodeError, ValueError, TypeError):
532
+ self._bad_frames += 1
533
+ peer.invalid_frames += 1
534
+ self._maybe_graylist(peer)
535
+ continue
536
+ if not isinstance(msg, dict):
537
+ self._bad_frames += 1
538
+ peer.invalid_frames += 1
539
+ self._maybe_graylist(peer)
540
+ continue
541
+ msg_type = msg.get("type")
542
+ if msg_type == "subscribe":
543
+ topic = msg.get("topic")
544
+ if not isinstance(topic, str):
545
+ self._bad_frames += 1
546
+ peer.invalid_frames += 1
547
+ continue
548
+ peer.topics.add(topic)
549
+ elif msg_type == "publish":
550
+ msg_id_v = msg.get("msg_id")
551
+ topic_v = msg.get("topic")
552
+ payload_v = msg.get("message")
553
+ if (
554
+ not isinstance(msg_id_v, (bytes, bytearray))
555
+ or not isinstance(topic_v, str)
556
+ or not isinstance(payload_v, (bytes, bytearray))
557
+ ):
558
+ self._bad_frames += 1
559
+ peer.invalid_frames += 1
560
+ continue
561
+ msg_id = bytes(msg_id_v)
562
+ if len(msg_id) != 32:
563
+ self._bad_frames += 1
564
+ peer.invalid_frames += 1
565
+ continue
566
+ topic = topic_v
567
+ payload = bytes(payload_v)
568
+ # Token-bucket: per (source_kid, topic). On reject,
569
+ # bump invalid counter and skip dispatch + re-gossip.
570
+ bucket_key = (peer.kid, topic)
571
+ bucket = self._buckets.get(bucket_key)
572
+ if bucket is None:
573
+ bucket = _TokenBucket.create()
574
+ self._buckets[bucket_key] = bucket
575
+ if not bucket.try_consume():
576
+ self._rate_rejects_total[peer.kid] = (
577
+ self._rate_rejects_total.get(peer.kid, 0) + 1
578
+ )
579
+ peer.invalid_frames += 1
580
+ self._maybe_graylist(peer)
581
+ continue
582
+ # Per-peer LRU first; if duplicate, skip.
583
+ if not peer.record_seen(msg_id):
584
+ continue
585
+ async with self._lock:
586
+ if msg_id in self._seen:
587
+ self._seen.move_to_end(msg_id)
588
+ # Already saw this via another peer; dedup
589
+ # but still credit the peer for delivery.
590
+ peer.deliveries += 1
591
+ continue
592
+ self._record_seen(msg_id)
593
+ peer.deliveries += 1
594
+ for h in list(self._handlers.get(topic, ())):
595
+ await _safe_invoke(h, payload)
596
+ # Re-gossip to other peers subscribed to this topic.
597
+ async with self._lock:
598
+ forward_targets = [
599
+ p for p in self._peers.values() if p is not peer and topic in p.topics
600
+ ]
601
+ forward_frame = {
602
+ "type": "publish",
603
+ "topic": topic,
604
+ "message": payload,
605
+ "msg_id": msg_id,
606
+ }
607
+ for fwd in forward_targets:
608
+ self._enqueue(fwd, forward_frame)
609
+ else:
610
+ # Unknown frame type — count and ignore.
611
+ self._bad_frames += 1
612
+ peer.invalid_frames += 1
613
+ except (asyncio.IncompleteReadError, OSError, asyncio.CancelledError):
614
+ # OSError catches ECONNRESET and the broader connection-error
615
+ # family on every platform (and subsumes ConnectionError).
616
+ # Closes self-heal Cycle-3 issue #5.
617
+ pass
618
+ finally:
619
+ async with self._lock:
620
+ self._peers.pop(peer.kid, None)
621
+ if peer.sender_task is not None:
622
+ peer.sender_task.cancel()
623
+ try:
624
+ peer.writer.close()
625
+ # Drain the OS-level socket so the file descriptor is fully
626
+ # released; otherwise abrupt disconnects can leak fds.
627
+ await peer.writer.wait_closed()
628
+ except Exception:
629
+ pass
630
+
631
+ # ---- per-peer sender + queue --------------------------------------
632
+
633
+ def _spawn_sender(self, peer: _PeerConn) -> None:
634
+ """Start the dedicated sender task for ``peer``.
635
+
636
+ Caller must hold ``self._lock`` (or be in single-threaded init).
637
+ """
638
+ task = asyncio.create_task(self._sender_loop(peer))
639
+ peer.sender_task = task
640
+ self._tasks.add(task)
641
+ task.add_done_callback(self._tasks.discard)
642
+
643
+ async def _sender_loop(self, peer: _PeerConn) -> None:
644
+ """Drain ``peer.send_queue`` and write frames. One per peer."""
645
+ try:
646
+ while not self._closed:
647
+ msg = await peer.send_queue.get()
648
+ try:
649
+ await _write_frame(peer.writer, msg)
650
+ except (ConnectionError, asyncio.CancelledError):
651
+ return
652
+ except Exception:
653
+ # A single failed write must not kill the loop —
654
+ # log and continue with the next frame.
655
+ continue
656
+ except asyncio.CancelledError:
657
+ return
658
+
659
+ def _enqueue(self, peer: _PeerConn, frame: dict) -> None:
660
+ """Enqueue a frame for ``peer``. Drops newest on overflow.
661
+
662
+ On drop: increments per-peer ``peer_drops_total``, scores down,
663
+ and graylists the peer if 3+ drops fall inside a 30s window
664
+ (matches the WS-G policy).
665
+ """
666
+ try:
667
+ peer.send_queue.put_nowait(frame)
668
+ except asyncio.QueueFull:
669
+ self._peer_drops_total[peer.kid] = self._peer_drops_total.get(peer.kid, 0) + 1
670
+ peer.backpressure_drops += 1
671
+ now = time.time()
672
+ peer.drop_history.append(now)
673
+ cutoff = now - DROP_BURST_WINDOW_S
674
+ while peer.drop_history and peer.drop_history[0] < cutoff:
675
+ peer.drop_history.popleft()
676
+ if len(peer.drop_history) >= DROP_BURST_THRESHOLD:
677
+ self._graylist[peer.kid] = now + GRAYLIST_DURATION_S
678
+ # Schedule eviction; writer close happens here, full
679
+ # cleanup runs in _read_loop's finally.
680
+ try:
681
+ peer.writer.close()
682
+ except Exception:
683
+ pass
684
+
685
+ async def _send(self, peer: _PeerConn, msg: dict) -> None:
686
+ """Back-compat direct-write shim.
687
+
688
+ Most code paths now go through ``_enqueue`` + the per-peer
689
+ sender task; this entrypoint stays for tests + callers that
690
+ need synchronous-ish "best effort" delivery semantics.
691
+ """
692
+ try:
693
+ await _write_frame(peer.writer, msg)
694
+ except (ConnectionError, asyncio.CancelledError):
695
+ pass
696
+
697
+ # ---- scoring + graylist -------------------------------------------
698
+
699
+ def _is_graylisted(self, kid: bytes) -> bool:
700
+ expiry = self._graylist.get(kid)
701
+ if expiry is None:
702
+ return False
703
+ if time.time() >= expiry:
704
+ self._graylist.pop(kid, None)
705
+ return False
706
+ return True
707
+
708
+ def _maybe_graylist(self, peer: _PeerConn) -> None:
709
+ """If ``peer.score()`` has dropped below threshold, graylist."""
710
+ if peer.score() < SCORE_GRAYLIST_THRESHOLD:
711
+ self._graylist[peer.kid] = time.time() + GRAYLIST_DURATION_S
712
+ try:
713
+ peer.writer.close()
714
+ except Exception:
715
+ pass
716
+
717
+
718
+ # ---- frame I/O ----------------------------------------------------------
719
+
720
+
721
+ async def _read_frame(reader: asyncio.StreamReader) -> dict:
722
+ header = await reader.readexactly(FRAME_HEADER.size)
723
+ (length,) = FRAME_HEADER.unpack(header)
724
+ if length > MAX_FRAME:
725
+ raise RuntimeError(f"frame too large: {length} bytes")
726
+ body = await reader.readexactly(length)
727
+ return cbor2.loads(body)
728
+
729
+
730
+ async def _write_frame(writer: asyncio.StreamWriter, msg: dict) -> None:
731
+ body = cbor2.dumps(msg, canonical=True)
732
+ if len(body) > MAX_FRAME:
733
+ raise RuntimeError(f"frame too large: {len(body)} bytes")
734
+ writer.write(FRAME_HEADER.pack(len(body)))
735
+ writer.write(body)
736
+ await writer.drain()
737
+
738
+
739
+ async def _safe_invoke(handler: Handler, message: bytes) -> None:
740
+ try:
741
+ await handler(message)
742
+ except Exception as e:
743
+ try:
744
+ from hypermind.observability.recorder import get_recorder
745
+
746
+ get_recorder().incr(
747
+ "hypermind_handler_errors_total",
748
+ kind=type(e).__name__,
749
+ )
750
+ except Exception: # pragma: no cover
751
+ pass
752
+
753
+
754
+ def _now_ms() -> int:
755
+ return int(time.time() * 1000)
756
+
757
+
758
+ def _verify_tls_alpn(writer: asyncio.StreamWriter) -> None:
759
+ """Enforce §22.3: the negotiated ALPN protocol MUST be `hm/2`.
760
+
761
+ CPython's `ssl` module does not expose a selection callback, so we
762
+ enforce the §22.3 ban on ALPN-absent operation at the application
763
+ layer: if the peer offered no overlap, `selected_alpn_protocol()`
764
+ is None and we close the connection (`CASCADE-L2-ALPN`).
765
+ """
766
+ from hypermind.transport.tls_profile import ALPN_HM2
767
+
768
+ ssl_obj = writer.get_extra_info("ssl_object")
769
+ if ssl_obj is None:
770
+ raise RuntimeError("TLS required but no SSL object on the connection")
771
+ selected = ssl_obj.selected_alpn_protocol()
772
+ if selected != ALPN_HM2:
773
+ raise RuntimeError(f"CASCADE-L2-ALPN: expected ALPN {ALPN_HM2!r}, got {selected!r}")
774
+
775
+
776
+ def _verify_tls_kid_binding(writer: asyncio.StreamWriter, claimed_kid: bytes) -> None:
777
+ """Ensure the TLS peer cert's `id-hm-kid` SAN equals the application-
778
+ layer kid claimed during the CBOR handshake. Closes connection on
779
+ mismatch — implements `CASCADE-L2-KID-MISMATCH` (§22.5/§22.8).
780
+ """
781
+ import ssl as _ssl
782
+
783
+ from hypermind.transport.tls_profile import extract_kid_from_cert
784
+
785
+ ssl_obj = writer.get_extra_info("ssl_object")
786
+ if ssl_obj is None:
787
+ raise RuntimeError("TLS required but no SSL object on the connection")
788
+ der = ssl_obj.getpeercert(binary_form=True)
789
+ if not der:
790
+ raise RuntimeError("TLS required but peer presented no certificate")
791
+ pem = _ssl.DER_cert_to_PEM_cert(der).encode("ascii")
792
+ cert_kid = extract_kid_from_cert(pem)
793
+ if cert_kid != claimed_kid:
794
+ raise RuntimeError(
795
+ f"CASCADE-L2-KID-MISMATCH: TLS cert kid {cert_kid.hex()[:16]}… "
796
+ f"!= handshake kid {claimed_kid.hex()[:16]}…"
797
+ )