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,206 @@
1
+ """Gossip bus interface + in-process implementation.
2
+
3
+ Real deployments use libp2p-gossipsub v1.2 with reputation-weighted
4
+ peer-scoring (P5) and topic-scoped meshes. The in-process bus mimics
5
+ the topic-fan-out semantics so agents can run end-to-end tests without
6
+ a real network. The interface is intentionally small so swapping in
7
+ libp2p later is a one-file change.
8
+
9
+ T5-03 — credit-based backpressure: each subscriber may advertise a
10
+ per-(peer_kid, topic_id) credit window via :meth:`InProcessBus.set_credit`.
11
+ Publishers that overrun the window get :class:`BackpressureExceeded`
12
+ instead of an unbounded internal queue. The token-bucket implementation
13
+ is reused from :mod:`hypermind.capability` (the same one rate-caveats
14
+ already use, so we avoid two parallel rate machineries).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import os
21
+ from collections.abc import Awaitable, Callable
22
+ from dataclasses import dataclass, field
23
+ from typing import Protocol
24
+
25
+ from hypermind.capability import TokenBucket
26
+ from hypermind.errors import HyperMindError
27
+
28
+
29
+ class BackpressureExceeded(HyperMindError):
30
+ """Publisher exceeded a subscriber's advertised credit window.
31
+
32
+ The bus refuses to enqueue further messages until the subscriber's
33
+ bucket refills. Carries ``peer_kid`` and ``topic_id`` so callers can
34
+ back off per-peer rather than throttling the whole namespace.
35
+ """
36
+
37
+ def __init__(self, message: str, *, peer_kid: str, topic_id: str) -> None:
38
+ super().__init__(message)
39
+ self.peer_kid = peer_kid
40
+ self.topic_id = topic_id
41
+ self.telemetry["peer_kid"] = peer_kid
42
+ self.telemetry["topic_id"] = topic_id
43
+
44
+
45
+ def _default_credit() -> int:
46
+ """Default per-(peer, topic) capacity. Override via env var.
47
+
48
+ 1024 messages / 1s burst is the v0.10.1 default — enough headroom
49
+ for steady-state gossip but small enough that runaway publishers
50
+ surface immediately in tests.
51
+ """
52
+ raw = os.environ.get("HYPERMIND_BUS_CREDIT_DEFAULT", "1024")
53
+ try:
54
+ v = int(raw)
55
+ return v if v > 0 else 1024
56
+ except ValueError:
57
+ return 1024
58
+
59
+
60
+ @dataclass
61
+ class Subscription:
62
+ topic: str
63
+ _cancel: Callable[[], None]
64
+
65
+ def unsubscribe(self) -> None:
66
+ self._cancel()
67
+
68
+
69
+ Handler = Callable[[bytes], Awaitable[None]]
70
+
71
+
72
+ class GossipBus(Protocol):
73
+ async def publish(self, topic: str, message: bytes) -> None: ...
74
+ async def subscribe(self, topic: str, handler: Handler) -> Subscription: ...
75
+ async def close(self) -> None: ...
76
+ def set_credit(self, peer_kid: str, topic_id: str, capacity: int) -> None: ...
77
+
78
+
79
+ @dataclass
80
+ class InProcessBus:
81
+ """An asyncio-native fan-out bus.
82
+
83
+ All published messages are delivered to every subscriber on the
84
+ same topic (pure broadcast). Eventual-consistency / partition
85
+ behaviour is not modelled at this layer — see `partition.py`.
86
+
87
+ Backpressure: callers may publish with a ``peer_kid`` keyword to
88
+ pin the (peer, topic) credit window. Plain ``publish(topic, msg)``
89
+ treats the publisher as anonymous (peer_kid="") which still shares
90
+ a bucket so misconfigured callers can't escape backpressure.
91
+ """
92
+
93
+ _subscribers: dict[str, list[Handler]] = field(default_factory=dict)
94
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock)
95
+ _closed: bool = False
96
+ # Credit windows: (peer_kid, topic_id) -> TokenBucket. Lazily created
97
+ # on first publish; advertised capacity overrides the default.
98
+ _buckets: dict[tuple[str, str], TokenBucket] = field(default_factory=dict)
99
+ _capacities: dict[tuple[str, str], int] = field(default_factory=dict)
100
+ # Internal "queue" — used only as a depth gauge for tests. The
101
+ # in-process bus delivers synchronously inside `publish`, so the
102
+ # queue length is bounded by len(subscribers) at any instant; we
103
+ # track it explicitly so backpressure tests can assert on it.
104
+ _queue: list[tuple[str, str, bytes]] = field(default_factory=list)
105
+
106
+ def set_credit(self, peer_kid: str, topic_id: str, capacity: int) -> None:
107
+ """Advertise this subscriber's max consumption rate.
108
+
109
+ ``capacity`` is messages/second burst. The bucket is reset so a
110
+ previously-throttled publisher gets a clean slate after the
111
+ subscriber re-advertises.
112
+ """
113
+ if capacity <= 0:
114
+ raise ValueError(f"capacity must be > 0, got {capacity}")
115
+ key = (peer_kid, topic_id)
116
+ self._capacities[key] = capacity
117
+ self._buckets[key] = TokenBucket(f"{capacity}/sec")
118
+
119
+ def _bucket_for(self, peer_kid: str, topic_id: str) -> TokenBucket:
120
+ key = (peer_kid, topic_id)
121
+ if key not in self._buckets:
122
+ cap = self._capacities.get(key, _default_credit())
123
+ self._capacities[key] = cap
124
+ self._buckets[key] = TokenBucket(f"{cap}/sec")
125
+ return self._buckets[key]
126
+
127
+ async def publish(
128
+ self,
129
+ topic: str,
130
+ message: bytes,
131
+ *,
132
+ peer_kid: str = "",
133
+ ) -> None:
134
+ if self._closed:
135
+ raise RuntimeError("bus is closed")
136
+
137
+ bucket = self._bucket_for(peer_kid, topic)
138
+ if not bucket.consume():
139
+ raise BackpressureExceeded(
140
+ f"backpressure exceeded for peer={peer_kid!r} topic={topic!r}",
141
+ peer_kid=peer_kid,
142
+ topic_id=topic,
143
+ )
144
+
145
+ async with self._lock:
146
+ handlers = list(self._subscribers.get(topic, ()))
147
+ # Track in-flight depth for test observability. Bounded by the
148
+ # bucket's capacity per peer; we cap the gauge at sum-of-capacities
149
+ # so a long-running bus does not leak memory in `_queue`.
150
+ self._queue.append((peer_kid, topic, message))
151
+ max_depth = sum(self._capacities.values()) or _default_credit()
152
+ if len(self._queue) > max_depth:
153
+ # Drop oldest gauge entries — this is just an observability
154
+ # buffer, not a real delivery queue.
155
+ del self._queue[: len(self._queue) - max_depth]
156
+
157
+ try:
158
+ # Run handlers concurrently; isolation prevents one bad
159
+ # handler from blocking the whole fan-out.
160
+ await asyncio.gather(
161
+ *(_safe_invoke(h, message) for h in handlers),
162
+ return_exceptions=True,
163
+ )
164
+ finally:
165
+ try:
166
+ self._queue.remove((peer_kid, topic, message))
167
+ except ValueError:
168
+ pass
169
+
170
+ async def subscribe(self, topic: str, handler: Handler) -> Subscription:
171
+ if self._closed:
172
+ raise RuntimeError("bus is closed")
173
+ async with self._lock:
174
+ self._subscribers.setdefault(topic, []).append(handler)
175
+
176
+ def _cancel() -> None:
177
+ try:
178
+ self._subscribers[topic].remove(handler)
179
+ except (ValueError, KeyError):
180
+ pass
181
+
182
+ return Subscription(topic=topic, _cancel=_cancel)
183
+
184
+ async def close(self) -> None:
185
+ async with self._lock:
186
+ self._subscribers.clear()
187
+ self._closed = True
188
+
189
+
190
+ async def _safe_invoke(handler: Handler, message: bytes) -> None:
191
+ try:
192
+ await handler(message)
193
+ except Exception as e:
194
+ # Surface to the global recorder so a misbehaving handler is
195
+ # auditable rather than silent (closes self-heal Cycle-1
196
+ # Finding #9 from data review). One bad handler doesn't break
197
+ # the fan-out; the exception is caught.
198
+ try:
199
+ from hypermind.observability.recorder import get_recorder
200
+
201
+ get_recorder().incr(
202
+ "hypermind_handler_errors_total",
203
+ kind=type(e).__name__,
204
+ )
205
+ except Exception: # pragma: no cover (recorder always present)
206
+ pass
@@ -0,0 +1,204 @@
1
+ """P2-16: KNOWS_DELTA gossip message type.
2
+
3
+ A lightweight reputation-delta broadcast that lets agents efficiently
4
+ synchronise knowledge of each other's reputations without replaying the
5
+ full claim + outcome history. Each message is signed (domain tag
6
+ DomainTag.KNOWS_DELTA = 0x18) and carries one or more per-topic deltas.
7
+
8
+ Wire shape (deterministic CBOR):
9
+ {
10
+ "issuer_kid": <32 bytes>,
11
+ "namespace_id": <str>,
12
+ "hlc": <int>, # issuer's HLC logical_ms at emission
13
+ "buckets": [
14
+ {
15
+ "topic_id": <str>,
16
+ "subject_kid": <32 bytes>,
17
+ "alpha_delta": <float>,
18
+ "beta_delta": <float>,
19
+ "evidence_kid": <bytes|None>, # optional claim that drove the delta
20
+ },
21
+ ...
22
+ ],
23
+ "sig": <bytes>, # Ed25519 over domain_tag || canonical_cbor(body)
24
+ }
25
+
26
+ Publishing: call ``publish_knows_delta(delta, bus, topic=KNOWS_DELTA_BUS_TOPIC)``
27
+ Subscribing: call ``on_knows_delta(bus, handler, topic=KNOWS_DELTA_BUS_TOPIC)``
28
+
29
+ The bus topic is a fixed string so all agents in a namespace share a
30
+ single gossip channel for reputation deltas.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Awaitable, Callable
36
+ from dataclasses import dataclass, field
37
+ from typing import Any
38
+
39
+ import cbor2
40
+
41
+ from hypermind.crypto.domain import DomainTag
42
+ from hypermind.crypto.signing import Keypair, verify_ed25519
43
+ from hypermind.transport.bus import GossipBus, Subscription
44
+
45
+ # Fixed gossip bus topic for KNOWS_DELTA messages.
46
+ KNOWS_DELTA_BUS_TOPIC = "hm/knows_delta/v1"
47
+
48
+ # Domain-separation tag prefix for the KNOWS_DELTA signature preimage.
49
+ _DOMAIN_BYTE = bytes([int(DomainTag.KNOWS_DELTA)])
50
+
51
+
52
+ @dataclass
53
+ class KnowsDeltaBucket:
54
+ """One per-topic reputation delta inside a KnowsDeltaMessage."""
55
+
56
+ topic_id: str
57
+ subject_kid: bytes
58
+ alpha_delta: float
59
+ beta_delta: float
60
+ evidence_kid: bytes | None = None
61
+
62
+ def to_cbor_dict(self) -> dict[str, Any]:
63
+ d: dict[str, Any] = {
64
+ "topic_id": self.topic_id,
65
+ "subject_kid": bytes(self.subject_kid),
66
+ "alpha_delta": self.alpha_delta,
67
+ "beta_delta": self.beta_delta,
68
+ }
69
+ if self.evidence_kid is not None:
70
+ d["evidence_kid"] = bytes(self.evidence_kid)
71
+ return d
72
+
73
+ @classmethod
74
+ def from_cbor_dict(cls, d: dict[str, Any]) -> KnowsDeltaBucket:
75
+ return cls(
76
+ topic_id=str(d["topic_id"]),
77
+ subject_kid=bytes(d["subject_kid"]),
78
+ alpha_delta=float(d["alpha_delta"]),
79
+ beta_delta=float(d["beta_delta"]),
80
+ evidence_kid=bytes(d["evidence_kid"]) if d.get("evidence_kid") else None,
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class KnowsDeltaMessage:
86
+ """A signed reputation-delta gossip message (P2-16 / DomainTag.KNOWS_DELTA).
87
+
88
+ Carries one or more ``KnowsDeltaBucket`` entries — each records the
89
+ raw α/β increment applied by the issuer to a specific (topic, agent).
90
+ Recipients MAY apply these deltas to their own reputation kernel or
91
+ use them as a signal for peer learning.
92
+
93
+ The message is signed with ``issuer_keypair`` at construction time
94
+ via :meth:`create`. Parse with :meth:`from_bytes`.
95
+ """
96
+
97
+ issuer_kid: bytes
98
+ namespace_id: str
99
+ hlc: int
100
+ buckets: list[KnowsDeltaBucket] = field(default_factory=list)
101
+ sig: bytes = b""
102
+
103
+ def _body_bytes(self) -> bytes:
104
+ body = {
105
+ "issuer_kid": bytes(self.issuer_kid),
106
+ "namespace_id": self.namespace_id,
107
+ "hlc": int(self.hlc),
108
+ "buckets": [b.to_cbor_dict() for b in self.buckets],
109
+ }
110
+ return cbor2.dumps(body, canonical=True)
111
+
112
+ @classmethod
113
+ def create(
114
+ cls,
115
+ *,
116
+ keypair: Keypair,
117
+ namespace_id: str,
118
+ hlc: int,
119
+ buckets: list[KnowsDeltaBucket],
120
+ ) -> KnowsDeltaMessage:
121
+ """Build and sign a KnowsDeltaMessage."""
122
+ msg = cls(
123
+ issuer_kid=keypair.kid,
124
+ namespace_id=namespace_id,
125
+ hlc=hlc,
126
+ buckets=list(buckets),
127
+ )
128
+ preimage = _DOMAIN_BYTE + msg._body_bytes()
129
+ msg.sig = keypair.sign(preimage)
130
+ return msg
131
+
132
+ def verify(self) -> bool:
133
+ """Verify the issuer's Ed25519 signature."""
134
+ if len(self.issuer_kid) != 32:
135
+ return False
136
+ preimage = _DOMAIN_BYTE + self._body_bytes()
137
+ return verify_ed25519(self.issuer_kid, self.sig, preimage)
138
+
139
+ def to_bytes(self) -> bytes:
140
+ """Serialise as deterministic CBOR for bus transport."""
141
+ payload = {
142
+ "issuer_kid": bytes(self.issuer_kid),
143
+ "namespace_id": self.namespace_id,
144
+ "hlc": int(self.hlc),
145
+ "buckets": [b.to_cbor_dict() for b in self.buckets],
146
+ "sig": bytes(self.sig),
147
+ }
148
+ return cbor2.dumps(payload, canonical=True)
149
+
150
+ @classmethod
151
+ def from_bytes(cls, raw: bytes) -> KnowsDeltaMessage:
152
+ """Parse a KnowsDeltaMessage from CBOR bytes."""
153
+ try:
154
+ d = cbor2.loads(raw)
155
+ except Exception as exc:
156
+ raise ValueError(f"KnowsDeltaMessage: invalid CBOR: {exc}") from exc
157
+ if not isinstance(d, dict):
158
+ raise ValueError("KnowsDeltaMessage: expected CBOR map")
159
+ try:
160
+ buckets = [KnowsDeltaBucket.from_cbor_dict(b) for b in d.get("buckets", [])]
161
+ return cls(
162
+ issuer_kid=bytes(d["issuer_kid"]),
163
+ namespace_id=str(d["namespace_id"]),
164
+ hlc=int(d["hlc"]),
165
+ buckets=buckets,
166
+ sig=bytes(d.get("sig", b"")),
167
+ )
168
+ except (KeyError, TypeError, ValueError) as exc:
169
+ raise ValueError(f"KnowsDeltaMessage: malformed fields: {exc}") from exc
170
+
171
+
172
+ async def publish_knows_delta(
173
+ delta: KnowsDeltaMessage,
174
+ bus: GossipBus,
175
+ *,
176
+ topic: str = KNOWS_DELTA_BUS_TOPIC,
177
+ ) -> None:
178
+ """Publish a signed KnowsDeltaMessage to the gossip bus."""
179
+ await bus.publish(topic, delta.to_bytes())
180
+
181
+
182
+ async def on_knows_delta(
183
+ bus: GossipBus,
184
+ handler: Callable[[KnowsDeltaMessage], Awaitable[None]],
185
+ *,
186
+ topic: str = KNOWS_DELTA_BUS_TOPIC,
187
+ ) -> Subscription:
188
+ """Subscribe to KnowsDelta messages on the bus.
189
+
190
+ ``handler`` is called with each decoded and signature-verified
191
+ ``KnowsDeltaMessage``. Messages that fail signature verification
192
+ are silently dropped (logged via the recorder if available).
193
+ """
194
+
195
+ async def _decode_and_call(raw: bytes) -> None:
196
+ try:
197
+ msg = KnowsDeltaMessage.from_bytes(raw)
198
+ except ValueError:
199
+ return # malformed — drop
200
+ if not msg.verify():
201
+ return # bad signature — drop
202
+ await handler(msg)
203
+
204
+ return await bus.subscribe(topic, _decode_and_call)