agentconnect-cli 0.1.0a1__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 (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,1002 @@
1
+ """SAS runs (SPEC 11): the commit-then-reveal exchange behind the short code.
2
+
3
+ A run is between this instance and one instance of another member. Its human
4
+ starts it (``start``, human API only); the daemon sends its commitment,
5
+ pairs the run with the first commitment addressed to it from an instance of
6
+ the peer, reveals its nonce once the run is paired and the relay accepted
7
+ its own commitment, checks the peer's reveal and derives the code. Every
8
+ ceremony message stays in the daemon: :meth:`SasService.on_message` takes
9
+ them before the safety policy and nothing reaches an adapter.
10
+
11
+ What a run keeps:
12
+
13
+ * in memory: the run itself (it doesn't survive a restart, SPEC 11.3),
14
+ the commitments held before a run, the ceremony budget and the cadence
15
+ deferral;
16
+ * in the encrypted store: the commitments answered in the current epoch,
17
+ written before the reveal goes out (``sas_answered``), and the warning
18
+ count per peer (``sas_warnings``).
19
+
20
+ Events are human-only by type (``SAS_EVENTS``, part of ``HUMAN_EVENTS``):
21
+ no agent token, adapter or SDK agent sees a code, a run or a prompt.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import base64
28
+ import binascii
29
+ import secrets
30
+ from collections import deque
31
+ from dataclasses import dataclass, field
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ import agentconnect_core as ac
35
+ from agentconnect import log
36
+ from agentconnect.backoff import Backoff
37
+ from agentconnect.relay import b64, unb64
38
+
39
+ if TYPE_CHECKING:
40
+ from agentconnect.runtime.engine import Engine
41
+ from agentconnect.store import GroupRow
42
+
43
+ LOG = log.get("agentconnect.sas")
44
+
45
+ SAS_COMMIT = "control.sas_commit"
46
+ SAS_REVEAL = "control.sas_reveal"
47
+ SAS_CANCEL = "control.sas_cancel"
48
+ SAS_KINDS = frozenset({SAS_COMMIT, SAS_REVEAL, SAS_CANCEL})
49
+
50
+ RUN_TIMEOUT_SECS = 600 # SPEC 11.3, as Matrix SAS verification
51
+ HELD_TTL_SECS = 600
52
+ UNPAIRED_NOTICE_SECS = 60
53
+ SAME_WINDOW_SECS = 600 # SPEC 11.5: "same" within 600 s of step 5
54
+ SIBLING_WINDOW_SECS = 600 # step 1: one run per pair of identities
55
+ MAX_RESTARTS = 3
56
+ WARNING_STOP = 2
57
+ BUDGET = 12 # ceremony messages a minute from one sending instance
58
+ BUDGET_WINDOW_SECS = 60
59
+ UPDATE_DEFER_SECS = 600 # SPEC 3.1, 11.3: all deferrals of commits that can wait
60
+ MAX_EPOCH = (1 << 53) - 1
61
+ NONCE_LEN = 32
62
+
63
+ # Human-only by type (SPEC 11.5): a prompt, a run's progress and its code.
64
+ SAS_EVENTS = frozenset(
65
+ {
66
+ "sas_requested",
67
+ "sas_paired",
68
+ "sas_code",
69
+ "sas_aborted",
70
+ "sas_unpaired",
71
+ "sas_stopped",
72
+ "verifications_reset",
73
+ }
74
+ )
75
+
76
+ # Aborts after this instance's reveal are warnings whatever the reason; these
77
+ # are warnings at any time (SPEC 11.3, "Warnings and the abort count").
78
+ ALWAYS_WARNING = frozenset({"bad_reveal", "out_of_order"})
79
+ # Ends the human asked for: never warnings.
80
+ OWN_ENDS = frozenset({"mismatch", "cancelled", "group_gone"})
81
+ # A ceremony message couldn't be sealed: a proposal waits for its commit.
82
+ PROPOSAL_PENDING = "proposal_pending"
83
+
84
+ STOP_TEXT = (
85
+ "Comparisons with {name} ended twice without a code. This is what an attacker in the "
86
+ "middle produces, but also what a relay that drops messages, or an ordinary commit by "
87
+ "another member, produces.{committers} If {name}'s human says they saw a code, or you "
88
+ "can't finish a comparison, treat it as different codes."
89
+ )
90
+ UNPAIRED_TEXT = (
91
+ "If {name} says they have started, your daemons don't see the same group. "
92
+ "Treat this as different codes."
93
+ )
94
+ WARNING_TEXT = (
95
+ "The comparison ended the way an attacker in the middle ends a run it can't win. "
96
+ "It can also be a relay that drops messages, or another member's commit."
97
+ )
98
+ REPLICAS_TEXT = (
99
+ "The labels name other instances of you and of {name}: two comparisons paired "
100
+ "crosswise. This counts as a warning. Start again, both naming the instance: "
101
+ "`agentconnect verify <group> <member> --instance <id>`."
102
+ )
103
+
104
+
105
+ class SasError(Exception):
106
+ """A run the human asked for can't go ahead; ``code`` is the API code."""
107
+
108
+ def __init__(self, code: str, message: str) -> None:
109
+ super().__init__(message)
110
+ self.code = code
111
+
112
+
113
+ def _canonical_b64(value: object, size: int) -> bytes | None:
114
+ """The bytes of canonical base64url of ``size`` bytes, else ``None``."""
115
+ if not isinstance(value, str) or not value.isascii():
116
+ return None
117
+ try:
118
+ raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
119
+ except (binascii.Error, ValueError):
120
+ return None
121
+ if len(raw) != size or b64(raw) != value:
122
+ return None
123
+ return raw
124
+
125
+
126
+ def canonical_identity(value: object) -> bool:
127
+ """``ac:v0:`` and the canonical base64url of a 32-byte key (SPEC 2, 11.2)."""
128
+ return (
129
+ isinstance(value, str)
130
+ and value.startswith("ac:v0:")
131
+ and _canonical_b64(value[6:], 32) is not None
132
+ )
133
+
134
+
135
+ def label(instance_hex: str) -> str:
136
+ """An instance label (SPEC 11.4): the first 8 hex characters."""
137
+ return instance_hex[:8]
138
+
139
+
140
+ @dataclass
141
+ class Run:
142
+ """One SAS run of this instance with ``peer`` (SPEC 11.3)."""
143
+
144
+ run_id: str
145
+ group_id: str
146
+ peer: str
147
+ started_at: int
148
+ command: str
149
+ restart: int = 0
150
+ want_instance: str | None = None
151
+ epoch: int | None = None
152
+ nonce: Any = None # ac.SasNonce, in Rust; None once the run ended
153
+ commitment: bytes | None = None
154
+ commit_accepted: bool = False
155
+ paired: tuple[str, bytes] | None = None # (instance hex, commitment)
156
+ reveal_queued: bool = False
157
+ revealed: bool = False # the reveal is sealed: the nonce is spent
158
+ peer_nonce: bytes | None = None
159
+ code: dict[str, Any] | None = None
160
+ shown_at: int | None = None
161
+ state: str = "starting" # starting, waiting, exchanging, done, aborted
162
+ reason: str | None = None
163
+ warning: bool = False
164
+ committer: str | None = None
165
+ peer_started: bool = False
166
+ unpaired_told: bool = False
167
+ same_closed: bool = False
168
+ answered: bool = False
169
+ next_run: str | None = None
170
+ acknowledged: bool = False
171
+
172
+ @property
173
+ def under_way(self) -> bool:
174
+ return self.state in ("starting", "waiting", "exchanging")
175
+
176
+
177
+ @dataclass
178
+ class _Held:
179
+ commitment: bytes
180
+ epoch: int
181
+ at: int
182
+
183
+
184
+ @dataclass
185
+ class _Budget:
186
+ times: deque[int] = field(default_factory=deque)
187
+
188
+
189
+ class SasService:
190
+ """SAS runs of one engine (SPEC 11.3)."""
191
+
192
+ def __init__(self, engine: Engine) -> None:
193
+ self._e = engine
194
+ self._runs: dict[str, Run] = {}
195
+ # (group, peer identity) -> the run under way.
196
+ self._active: dict[tuple[str, str], str] = {}
197
+ # (group, sender identity, sender instance) -> a held commitment.
198
+ self._held: dict[tuple[str, str, str], _Held] = {}
199
+ # (group, peer identity) -> when another instance of this identity
200
+ # last committed to that peer, and in which epoch (step 1).
201
+ self._siblings: dict[tuple[str, str], tuple[int, int]] = {}
202
+ # (group, sender identity, sender instance): an instance id is unique
203
+ # only within its identity (SPEC 9.2), so every key carries both.
204
+ self._budgets: dict[tuple[str, str, str], _Budget] = {}
205
+ # group -> since when this instance defers commits that can wait.
206
+ self._defer_since: dict[str, int] = {}
207
+ self._wake = asyncio.Event()
208
+
209
+ # -- helpers ----------------------------------------------------------------
210
+
211
+ def _now(self) -> int:
212
+ return self._e.now()
213
+
214
+ def _name(self, group_id: str, identity: str) -> str:
215
+ return self._e._display_name(group_id, identity) or identity
216
+
217
+ def wake(self) -> None:
218
+ self._wake.set()
219
+
220
+ def _under_way(self, group_id: str) -> list[Run]:
221
+ return [r for r in self._runs.values() if r.group_id == group_id and r.under_way]
222
+
223
+ def _taking_part(self, group_id: str) -> bool:
224
+ """A run under way, or one whose code waits for its human's answer
225
+ (SPEC 11.3: from step 1 until the human answers)."""
226
+ now = self._now()
227
+ return any(
228
+ r.group_id == group_id and (r.under_way or self._answerable(r, now))
229
+ for r in self._runs.values()
230
+ )
231
+
232
+ # -- the human's side (human API only) -----------------------------------------
233
+
234
+ def start(
235
+ self,
236
+ group_id: str,
237
+ peer: str,
238
+ *,
239
+ instance: str | None = None,
240
+ acknowledge: bool = False,
241
+ after: Run | None = None,
242
+ ) -> dict[str, Any]:
243
+ """**Human-only.** Start a run with ``peer`` (SPEC 11.3 step 1).
244
+ ``after`` is the run this one restarts (same command)."""
245
+ e = self._e
246
+ restart = 0 if after is None else after.restart + 1
247
+ group = e._group_or_raise(group_id)
248
+ if peer == e.me:
249
+ raise SasError("bad_request", "a run with one's own identity is refused")
250
+ member = next((m for m in e.store.members(group_id) if m.identity == peer), None)
251
+ if member is None:
252
+ raise SasError("not_found", "no such member")
253
+ if instance is not None and instance not in {
254
+ str(i["instance_id"]) for i in member.instances
255
+ }:
256
+ raise SasError("not_found", "no such instance of this member")
257
+ if (group_id, peer) in self._active:
258
+ raise SasError("conflict", "a comparison with this member is under way")
259
+ epoch = e._core_epoch(unb64(group_id))
260
+ if epoch > MAX_EPOCH:
261
+ raise SasError("bad_request", "the group's epoch is beyond a run's range")
262
+ sibling = self._siblings.get((group_id, peer))
263
+ if (
264
+ sibling is not None
265
+ and sibling[0] == epoch
266
+ and self._now() - sibling[1] < (SIBLING_WINDOW_SECS)
267
+ ):
268
+ raise SasError(
269
+ "conflict",
270
+ "another instance of your identity is comparing codes with this member",
271
+ )
272
+ count, committers = e.store.sas_warnings(group_id, peer)
273
+ if count >= WARNING_STOP and not acknowledge and restart == 0:
274
+ raise SasError("sas_stopped", self._stop_text(group_id, peer, committers))
275
+ now = self._now()
276
+ run = Run(
277
+ run_id=b64(secrets.token_bytes(16)),
278
+ group_id=group_id,
279
+ peer=peer,
280
+ started_at=now,
281
+ command=after.command if after is not None else b64(secrets.token_bytes(16)),
282
+ restart=restart,
283
+ want_instance=instance,
284
+ acknowledged=acknowledge,
285
+ )
286
+ # SPEC 11.5: a new run ends the "same" window of earlier ones.
287
+ self._close_same(group_id, peer)
288
+ self._runs[run.run_id] = run
289
+ self._active[(group_id, peer)] = run.run_id
290
+ self._begin(run, group)
291
+ self.wake()
292
+ return self.view(group_id, run.run_id)
293
+
294
+ def _begin(self, run: Run, group: GroupRow | None = None) -> None:
295
+ """Step 1 once no own commit and no proposal is pending: the nonce
296
+ and the commitment."""
297
+ e = self._e
298
+ gid = unb64(run.group_id)
299
+ if group is None:
300
+ group = e.store.group(run.group_id)
301
+ if group is None or group.status != "active":
302
+ self._abort(run, "group_gone")
303
+ return
304
+ try:
305
+ if e.client.has_pending_commit(gid) or e.client.has_pending_proposals(gid):
306
+ # Waits: begun after the commit is confirmed or discarded, or
307
+ # once the pending proposals are committed (nothing is sealed
308
+ # until then, SPEC 11.3).
309
+ return
310
+ run.epoch = e._core_epoch(gid)
311
+ run.nonce = e.client.sas_nonce()
312
+ run.commitment = bytes(e.client.sas_commitment(gid, run.peer, run.nonce))
313
+ except (ac.AgentConnectError, ac.CoreError) as err:
314
+ LOG.info("sas_begin_failed", error=type(err).__name__)
315
+ self._abort(run, "failed")
316
+ return
317
+ run.state = "waiting"
318
+ body = {"commitment": b64(run.commitment), "epoch": run.epoch, "peer": run.peer}
319
+ with e.store.tx():
320
+ # Kept until the reveal or the end, so that a restart can cancel it.
321
+ e.store.add_sas_sent(run.group_id, run.epoch, run.peer, run.commitment, self._now())
322
+ self._send(run, SAS_COMMIT, body, [run.peer])
323
+ # A commitment held from before the run (the peer's human started first).
324
+ held = sorted(
325
+ (
326
+ (h.at, inst, h)
327
+ for (g, s, inst), h in self._held.items()
328
+ if g == run.group_id and s == run.peer and h.epoch == run.epoch
329
+ ),
330
+ key=lambda x: x[0],
331
+ )
332
+ for _at, inst, h in held:
333
+ if self._pairable(run, inst, h.commitment):
334
+ del self._held[(run.group_id, run.peer, inst)]
335
+ self._pair(run, inst, h.commitment)
336
+ break
337
+
338
+ def _send(self, run: Run, kind: str, body: dict[str, Any], to: list[str]) -> None:
339
+ """Queue one ceremony message (inside a transaction). It bypasses the
340
+ safety policy: own sends don't count against what this instance
341
+ accepts, and ceremony messages carry no content."""
342
+ e = self._e
343
+ msg_id = b64(secrets.token_bytes(16))
344
+ inner = e.inner(kind, body, to, origin="human", hop=0)
345
+ meta = {"run": run.run_id, "kind": kind, "epoch": run.epoch}
346
+ e._enqueue("app", run.group_id, {"inner": inner, "msg_id": msg_id, "sas": meta})
347
+
348
+ def view(self, group_id: str, run_id: str) -> dict[str, Any]:
349
+ """**Human-only.** A run as the human sees it (SPEC 11.5)."""
350
+ run = self._runs.get(run_id)
351
+ if run is None or run.group_id != group_id:
352
+ raise SasError("not_found", "no such comparison (runs end with a restart)")
353
+ now = self._now()
354
+ state = run.state
355
+ count, committers = self._e.store.sas_warnings(group_id, run.peer)
356
+ out: dict[str, Any] = {
357
+ "run": run.run_id,
358
+ "group_id": group_id,
359
+ "peer": run.peer,
360
+ "peer_name": self._e._display_name(group_id, run.peer),
361
+ "epoch": run.epoch,
362
+ "state": state,
363
+ "unpaired": run.under_way and run.paired is None and run.unpaired_told,
364
+ "peer_started": run.peer_started,
365
+ "reason": run.reason,
366
+ "warning": run.warning,
367
+ "committer": run.committer,
368
+ "next": run.next_run,
369
+ "warnings": count,
370
+ "stopped": count >= WARNING_STOP,
371
+ "answerable": self._answerable(run, now),
372
+ "expires_at": run.started_at + RUN_TIMEOUT_SECS,
373
+ }
374
+ if run.paired is not None:
375
+ out["peer_instance"] = run.paired[0]
376
+ if run.code is not None:
377
+ out["sas"] = run.code
378
+ out["labels"] = {
379
+ "self": label(self._e.instance.instance_id_hex),
380
+ "peer": label(run.paired[0] if run.paired else ""),
381
+ }
382
+ if out["stopped"]:
383
+ out["stop_text"] = self._stop_text(group_id, run.peer, committers)
384
+ never_paired = run.state == "aborted" and run.paired is None and run.reason != "mismatch"
385
+ if out["unpaired"] or never_paired:
386
+ # SPEC 11.5: a run that never pairs is how a fork can show.
387
+ out["unpaired_text"] = UNPAIRED_TEXT.format(name=self._name(group_id, run.peer))
388
+ if run.warning:
389
+ out["warning_text"] = WARNING_TEXT
390
+ return out
391
+
392
+ def _answerable(self, run: Run, now: int) -> bool:
393
+ return (
394
+ run.state == "done"
395
+ and run.shown_at is not None
396
+ and now - run.shown_at <= SAME_WINDOW_SECS
397
+ and not run.same_closed
398
+ and not run.answered
399
+ )
400
+
401
+ def confirm(self, group_id: str, run_id: str, peer: str) -> None:
402
+ """**Human-only.** The human read the same code (SPEC 11.5): the peer
403
+ identity is verified at this instance, also after an epoch change
404
+ within the window. ``peer`` must be the run's."""
405
+ e = self._e
406
+ e._group_or_raise(group_id)
407
+ run = self._runs.get(run_id)
408
+ if run is None or run.group_id != group_id:
409
+ raise SasError("not_found", "no such comparison")
410
+ if run.peer != peer:
411
+ raise SasError("bad_request", "that comparison was with another member")
412
+ if run.state != "done":
413
+ raise SasError("not_done", "this comparison has no code yet, or it ended without one")
414
+ if not self._answerable(run, self._now()):
415
+ raise SasError(
416
+ "stale_epoch",
417
+ "this code can't be confirmed any more (too old, or a newer comparison "
418
+ "with this member started): compare again",
419
+ )
420
+ with e.store.tx():
421
+ if not e.store.set_verified(group_id, run.peer, True, run.epoch):
422
+ raise SasError("not_found", "no such member")
423
+ e.store.set_sas_warnings(group_id, run.peer, 0, [])
424
+ run.answered = True
425
+
426
+ def labels(self, group_id: str, run_id: str, theirs: tuple[str, str]) -> dict[str, Any]:
427
+ """**Human-only.** The other human reads the same code, but labels
428
+ that don't mirror ours (SPEC 11.5); a code that differs is a mismatch
429
+ whatever the labels, and never comes here. ``theirs`` is (their own
430
+ label, their peer's).
431
+
432
+ It counts as a warning either way. Only when both name instances in
433
+ this instance's view, the peer's for theirs and ours for their peer,
434
+ are they replicas paired crosswise: the humans start again with
435
+ ``--instance``. Otherwise it is treated as codes that differ: the
436
+ caller leaves the group."""
437
+ e = self._e
438
+ e._group_or_raise(group_id)
439
+ run = self._runs.get(run_id)
440
+ if run is None or run.group_id != group_id:
441
+ raise SasError("not_found", "no such comparison")
442
+ if run.state != "done" or run.paired is None:
443
+ raise SasError("not_done", "this comparison has no code")
444
+ their_self, their_peer = theirs
445
+ members = {m.identity: m for m in e.store.members(group_id)}
446
+ peer_ids = [str(i["instance_id"]) for i in members[run.peer].instances]
447
+ own = members.get(e.me)
448
+ own_ids = [str(i["instance_id"]) for i in (own.instances if own else [])]
449
+ mirrored = (label(run.paired[0]), label(e.instance.instance_id_hex))
450
+ run.answered = True
451
+ self._warn(run)
452
+ replica = next((i for i in peer_ids if label(i) == their_self), None)
453
+ if (
454
+ (their_self, their_peer) != mirrored
455
+ and replica is not None
456
+ and any(label(i) == their_peer for i in own_ids)
457
+ ):
458
+ text = REPLICAS_TEXT.format(name=self._name(group_id, run.peer))
459
+ return {"outcome": "replicas", "instance": replica, "text": text}
460
+ return {"outcome": "mismatch"}
461
+
462
+ def peer_started(self, group_id: str, run_id: str) -> dict[str, Any]:
463
+ """**Human-only.** The human says the peer started its side: if the
464
+ run then times out unpaired, that is a warning (SPEC 11.3)."""
465
+ run = self._runs.get(run_id)
466
+ if run is None or run.group_id != group_id:
467
+ raise SasError("not_found", "no such comparison")
468
+ run.peer_started = True
469
+ return self.view(group_id, run_id)
470
+
471
+ def cancel(self, group_id: str, run_id: str) -> dict[str, Any]:
472
+ """**Human-only.** The human gives a run up. Before this instance's
473
+ reveal, the peer is told (``control.sas_cancel``); after it, the
474
+ peer's run ends as a warning, as any withheld reveal does."""
475
+ run = self._runs.get(run_id)
476
+ if run is None or run.group_id != group_id:
477
+ raise SasError("not_found", "no such comparison")
478
+ if run.under_way:
479
+ self._send_cancel(run)
480
+ self._abort(run, "cancelled")
481
+ return self.view(group_id, run_id)
482
+
483
+ def _send_cancel(self, run: Run) -> None:
484
+ """``control.sas_cancel`` for this instance's commitment, only while
485
+ its nonce is unrevealed and its epoch is current (SPEC 11.3)."""
486
+ e = self._e
487
+ if run.reveal_queued or run.commitment is None or run.epoch is None:
488
+ return
489
+ if run.epoch != e._core_epoch(unb64(run.group_id)):
490
+ return # the epoch's end dropped it everywhere already
491
+ body = {"commitment": b64(run.commitment), "epoch": run.epoch}
492
+ with e.store.tx():
493
+ e.store.forget_sas_sent(run.group_id, run.commitment)
494
+ self._send(run, SAS_CANCEL, body, [run.peer])
495
+
496
+ def mismatch(self, group_id: str, run_id: str | None) -> None:
497
+ """The human answered "differ" (or took the mismatch action): the run
498
+ ends; the group leaves through :meth:`GroupService.sas_mismatch`."""
499
+ run = self._runs.get(run_id or "")
500
+ if run is not None and run.group_id == group_id:
501
+ run.answered = True
502
+ if run.under_way:
503
+ self._end(run, "mismatch")
504
+
505
+ def _stop_text(self, group_id: str, peer: str, committers: list[str]) -> str:
506
+ names = sorted({self._name(group_id, c) for c in committers})
507
+ said = f" The runs were ended by commits from: {', '.join(names)}." if names else ""
508
+ return STOP_TEXT.format(name=self._name(group_id, peer), committers=said)
509
+
510
+ def _close_same(self, group_id: str, peer: str) -> None:
511
+ for r in self._runs.values():
512
+ if r.group_id == group_id and r.peer == peer:
513
+ r.same_closed = True
514
+
515
+ # -- pairing and the reveal --------------------------------------------------------
516
+
517
+ def _pairable(self, run: Run, instance: str, commitment: bytes) -> bool:
518
+ return (
519
+ run.state == "waiting"
520
+ and run.paired is None
521
+ and (run.want_instance is None or run.want_instance == instance)
522
+ and not self._e.store.sas_answered(run.group_id, int(run.epoch or 0), commitment)
523
+ )
524
+
525
+ def _pair(self, run: Run, instance: str, commitment: bytes) -> None:
526
+ run.paired = (instance, commitment)
527
+ run.state = "exchanging"
528
+ self._event(run, "sas_paired", peer_instance=instance)
529
+ self._maybe_reveal(run)
530
+
531
+ def _maybe_reveal(self, run: Run) -> None:
532
+ """Step 3: the reveal, once paired and once the relay accepted this
533
+ instance's commitment. The answered commitment is stored in the same
534
+ transaction that queues the reveal, so before the reveal goes out.
535
+ The run counts as revealed once the reveal is sealed (:meth:`on_sealed`)."""
536
+ if run.reveal_queued or run.paired is None or not run.commit_accepted or not run.under_way:
537
+ return
538
+ e = self._e
539
+ instance, commitment = run.paired
540
+ body = {"answers": b64(commitment), "epoch": run.epoch, "nonce": b64(run.nonce.reveal())}
541
+ with e.store.tx():
542
+ e.store.add_sas_answered(run.group_id, int(run.epoch or 0), commitment)
543
+ e.store.forget_sas_sent(run.group_id, run.commitment or b"")
544
+ self._send(run, SAS_REVEAL, body, [run.peer])
545
+ run.reveal_queued = True
546
+ LOG.info("sas_reveal_queued", group=log.hid(run.group_id), instance=log.hid(instance))
547
+
548
+ def _maybe_done(self, run: Run) -> None:
549
+ """Step 5: the code, under this epoch's authenticator only."""
550
+ if not (run.revealed and run.peer_nonce is not None and run.paired is not None):
551
+ return
552
+ e = self._e
553
+ try:
554
+ sas = e.client.sas_code(
555
+ unb64(run.group_id),
556
+ int(run.epoch or 0),
557
+ run.nonce,
558
+ run.peer,
559
+ int(run.paired[0], 16),
560
+ run.peer_nonce,
561
+ )
562
+ except (ac.AgentConnectError, ac.CoreError) as err:
563
+ # Another epoch by now, or the peer instance left: no code.
564
+ LOG.info("sas_code_refused", error=type(err).__name__)
565
+ self._abort(run, "epoch_changed")
566
+ return
567
+ run.code = {"decimal": sas.decimal, "emoji_indices": sas.emoji_indices}
568
+ run.shown_at = self._now()
569
+ e.store.forget_sas_sent(run.group_id, run.commitment or b"")
570
+ run.state = "done"
571
+ run.nonce = None
572
+ self._active.pop((run.group_id, run.peer), None)
573
+ self._event(run, "sas_code")
574
+
575
+ # -- received ceremony messages ------------------------------------------------------
576
+
577
+ def on_message(self, group: GroupRow, event: dict[str, Any], inner: dict[str, Any]) -> None:
578
+ """A received ceremony message (inside the engine's transaction for
579
+ it). Never delivered: whatever it is, it ends here."""
580
+ e = self._e
581
+ sender = event["sender"]
582
+ identity, instance = str(sender["identity"]), str(sender["instance_id_hex"])
583
+ epoch = int(event["epoch"])
584
+ body = inner.get("body")
585
+ if (
586
+ inner.get("sender") != identity
587
+ or inner.get("instance_id") != instance
588
+ or inner.get("origin") != "human"
589
+ or inner.get("hop") != 0
590
+ or "reply_to" in inner
591
+ or "addressee" in inner
592
+ or not isinstance(body, dict)
593
+ or type(body.get("epoch")) is not int
594
+ or body["epoch"] != epoch
595
+ or epoch > MAX_EPOCH
596
+ ):
597
+ return
598
+ if epoch != e._core_epoch(unb64(group.group_id)):
599
+ return # from an earlier epoch (the grace window): never pairs
600
+ if inner["kind"] == SAS_COMMIT:
601
+ self._on_commit(group.group_id, (identity, instance), epoch, inner, body)
602
+ elif inner["kind"] == SAS_CANCEL:
603
+ self._on_cancel(group.group_id, (identity, instance), epoch, inner, body)
604
+ else:
605
+ self._on_reveal(group.group_id, identity, instance, inner, body)
606
+
607
+ def _within_budget(self, group_id: str, sender: tuple[str, str]) -> bool:
608
+ """12 a minute from one sending instance, counting only messages
609
+ addressed to this instance's identity (SPEC 11.3, "Limits"). The
610
+ instance is named with its identity: another member may certify an
611
+ instance under the same id."""
612
+ now = self._now()
613
+ budget = self._budgets.setdefault((group_id, *sender), _Budget())
614
+ while budget.times and now - budget.times[0] >= BUDGET_WINDOW_SECS:
615
+ budget.times.popleft()
616
+ if len(budget.times) >= BUDGET:
617
+ return False
618
+ budget.times.append(now)
619
+ return True
620
+
621
+ def _on_commit(
622
+ self,
623
+ group_id: str,
624
+ sender: tuple[str, str],
625
+ epoch: int,
626
+ inner: dict[str, Any],
627
+ body: dict[str, Any],
628
+ ) -> None:
629
+ e = self._e
630
+ identity = sender[0]
631
+ commitment = _canonical_b64(body.get("commitment"), NONCE_LEN)
632
+ peer = body.get("peer")
633
+ if commitment is None or not canonical_identity(peer) or inner.get("to") != [peer]:
634
+ return
635
+ if identity == e.me:
636
+ # Another instance of this identity compares with `peer` (step 1).
637
+ self._siblings[(group_id, str(peer))] = (epoch, self._now())
638
+ elif peer != e.me:
639
+ pass # addressed to another member: ignored entirely
640
+ elif not self._within_budget(group_id, sender):
641
+ LOG.info("sas_budget_exceeded", group=log.hid(group_id))
642
+ else:
643
+ self._addressed_commit(group_id, sender, epoch, commitment)
644
+
645
+ def _on_cancel(
646
+ self,
647
+ group_id: str,
648
+ sender: tuple[str, str],
649
+ epoch: int,
650
+ inner: dict[str, Any],
651
+ body: dict[str, Any],
652
+ ) -> None:
653
+ """A peer gives its unrevealed commitment up (SPEC 11.3): honoured
654
+ for a held commitment, or a run paired with it that hasn't revealed,
655
+ from that same sender instance. After this instance's reveal it is
656
+ ignored: the run ends as a warning, as a withheld reveal does."""
657
+ e = self._e
658
+ identity, instance = sender
659
+ commitment = _canonical_b64(body.get("commitment"), NONCE_LEN)
660
+ if commitment is None or inner.get("to") != [e.me] or identity == e.me:
661
+ return
662
+ if not self._within_budget(group_id, sender):
663
+ LOG.info("sas_budget_exceeded", group=log.hid(group_id))
664
+ return
665
+ key = (group_id, identity, instance)
666
+ held = self._held.get(key)
667
+ if held is not None and held.commitment == commitment and held.epoch == epoch:
668
+ del self._held[key]
669
+ run = self._runs.get(self._active.get((group_id, identity), ""))
670
+ if (
671
+ run is not None
672
+ and run.paired == (instance, commitment)
673
+ and run.epoch == epoch
674
+ and not run.revealed
675
+ ):
676
+ self._abort(run, "peer_cancelled")
677
+
678
+ def _addressed_commit(
679
+ self, group_id: str, sender: tuple[str, str], epoch: int, commitment: bytes
680
+ ) -> None:
681
+ identity, instance = sender
682
+ run = self._runs.get(self._active.get((group_id, identity), ""))
683
+ if run is None or run.state == "starting":
684
+ self._hold(group_id, sender, commitment, epoch)
685
+ elif run.paired is None:
686
+ if run.epoch == epoch and self._pairable(run, instance, commitment):
687
+ self._pair(run, instance, commitment)
688
+ elif run.paired[0] == instance and run.paired[1] != commitment:
689
+ # The paired instance committed again: never answered here.
690
+ self._abort(run, "peer_restarted")
691
+ self._hold(group_id, sender, commitment, epoch)
692
+ # Otherwise another instance's, or the same one again: ignored.
693
+
694
+ def _hold(self, group_id: str, sender: tuple[str, str], commitment: bytes, epoch: int) -> None:
695
+ identity, instance = sender
696
+ if self._e.store.sas_answered(group_id, epoch, commitment):
697
+ return
698
+ key = (group_id, identity, instance)
699
+ known = self._held.get(key)
700
+ self._held[key] = _Held(commitment, epoch, self._now())
701
+ if known is None: # one prompt per sending instance while it holds
702
+ self._e._defer(
703
+ lambda: self._emit(
704
+ group_id,
705
+ "sas_requested",
706
+ peer=identity,
707
+ peer_name=self._e._display_name(group_id, identity),
708
+ peer_instance=instance,
709
+ )
710
+ )
711
+ self.wake()
712
+
713
+ def _on_reveal(
714
+ self,
715
+ group_id: str,
716
+ identity: str,
717
+ instance: str,
718
+ inner: dict[str, Any],
719
+ body: dict[str, Any],
720
+ ) -> None:
721
+ e = self._e
722
+ answers = _canonical_b64(body.get("answers"), NONCE_LEN)
723
+ nonce = _canonical_b64(body.get("nonce"), NONCE_LEN)
724
+ if answers is None or nonce is None or inner.get("to") != [e.me] or identity == e.me:
725
+ return
726
+ run = self._runs.get(self._active.get((group_id, identity), ""))
727
+ if run is None or run.commitment is None or answers != run.commitment:
728
+ return # addressed to another run, or none
729
+ if not self._within_budget(group_id, (identity, instance)):
730
+ LOG.info("sas_budget_exceeded", group=log.hid(group_id))
731
+ return
732
+ if run.paired is None:
733
+ self._abort(run, "out_of_order")
734
+ return
735
+ if run.paired[0] != instance or run.peer_nonce is not None:
736
+ return
737
+ expected = ac.sas_commitment(
738
+ unb64(group_id), int(run.epoch or 0), identity, int(instance, 16), e.me, nonce
739
+ )
740
+ if bytes(expected) != run.paired[1]:
741
+ self._abort(run, "bad_reveal")
742
+ return
743
+ run.peer_nonce = nonce
744
+ self._maybe_done(run)
745
+
746
+ # -- the outbox's answers ----------------------------------------------------------
747
+
748
+ def on_sent(self, meta: dict[str, Any]) -> None:
749
+ """The relay accepted a ceremony message (``accepted``)."""
750
+ run = self._runs.get(str(meta.get("run")))
751
+ if run is None or meta.get("kind") != SAS_COMMIT:
752
+ return
753
+ run.commit_accepted = True
754
+ self._maybe_reveal(run)
755
+
756
+ def on_sealed(self, meta: dict[str, Any] | None) -> None:
757
+ """An application message was sealed (``meta`` is None for one that
758
+ isn't a ceremony message). For the reveal, the nonce is spent from
759
+ here on, and the code can be derived (step 5)."""
760
+ if meta is None or meta.get("kind") != SAS_REVEAL:
761
+ return
762
+ run = self._runs.get(str(meta.get("run")))
763
+ if run is None or run.revealed:
764
+ return
765
+ run.revealed = True
766
+ LOG.info("sas_revealed", group=log.hid(run.group_id))
767
+ self._maybe_done(run)
768
+
769
+ def on_send_failed(self, meta: dict[str, Any], code: str) -> None:
770
+ """An ``error`` frame (or a stale epoch) for a ceremony message, or
771
+ one that couldn't be sealed while a proposal is pending: that run
772
+ ends before its reveal, with no warning, and starts again, waiting
773
+ for the commit (SPEC 11.3)."""
774
+ run = self._runs.get(str(meta.get("run")))
775
+ if run is None or not run.under_way or code == "sas_stale":
776
+ return
777
+ if code == PROPOSAL_PENDING and not run.revealed:
778
+ self._abort(run, PROPOSAL_PENDING)
779
+ self._restart(run)
780
+ return
781
+ self._abort(run, "send_failed")
782
+
783
+ def stale(self, meta: dict[str, Any], epoch: int) -> bool:
784
+ """Whether a queued ceremony message is dropped instead of sealed:
785
+ one of an earlier epoch, never sealed in a new one, and a reveal of
786
+ a run that ended before it was sealed, whose nonce stays unspent
787
+ (SPEC 11.3)."""
788
+ if meta.get("epoch") != epoch:
789
+ return True
790
+ run = self._runs.get(str(meta.get("run")))
791
+ return bool(meta.get("kind") == SAS_REVEAL and (run is None or not run.under_way))
792
+
793
+ # -- epoch changes, aborts and timeouts --------------------------------------------------
794
+
795
+ def on_epoch_changed(self, group_id: str, committer: str | None) -> None:
796
+ """A commit merged (another member's or this instance's own): the
797
+ runs of the earlier epoch end, held commitments with them."""
798
+ e = self._e
799
+ epoch = e._core_epoch(unb64(group_id))
800
+ if committer == e.me:
801
+ self._defer_since.pop(group_id, None) # what waited went out
802
+ for key in [k for k, h in self._held.items() if k[0] == group_id and h.epoch != epoch]:
803
+ del self._held[key]
804
+ e.store.forget_sas_answered(group_id, epoch)
805
+ e.store.forget_sas_sent_before(group_id, epoch)
806
+ for run in self._under_way(group_id):
807
+ if run.state == "starting":
808
+ continue # begins in the new epoch
809
+ if run.epoch is not None and run.epoch != epoch:
810
+ restart = not run.revealed
811
+ self._abort(run, "epoch_changed", committer=committer)
812
+ if restart:
813
+ self._restart(run)
814
+ self.wake()
815
+
816
+ def _restart(self, run: Run) -> None:
817
+ """SPEC 11.3: a new run in the new epoch, only for an abort before
818
+ this instance's reveal, at most 3 times per command, and only while
819
+ the command waits (within the first run's timeout)."""
820
+ if run.restart >= MAX_RESTARTS or self._now() - run.started_at >= RUN_TIMEOUT_SECS:
821
+ return
822
+ count, _ = self._e.store.sas_warnings(run.group_id, run.peer)
823
+ if count >= WARNING_STOP and not run.acknowledged:
824
+ return
825
+ try:
826
+ view = self.start(
827
+ run.group_id,
828
+ run.peer,
829
+ instance=run.want_instance,
830
+ acknowledge=run.acknowledged,
831
+ after=run,
832
+ )
833
+ except (SasError, Exception) as err:
834
+ LOG.info("sas_restart_failed", error=type(err).__name__)
835
+ return
836
+ run.next_run = str(view["run"])
837
+ new = self._runs[run.next_run]
838
+ new.started_at = run.started_at # the command's timeout carries over
839
+
840
+ def _abort(self, run: Run, reason: str, *, committer: str | None = None) -> None:
841
+ warning = reason in ALWAYS_WARNING or (run.revealed and reason not in OWN_ENDS)
842
+ if reason == "timed_out" and run.paired is None and run.peer_started:
843
+ warning = True
844
+ run.committer = committer if reason == "epoch_changed" else None
845
+ run.warning = warning
846
+ self._end(run, reason)
847
+ if warning:
848
+ self._warn(run, committer)
849
+ self._event(run, "sas_aborted", reason=reason, warning=warning, committer=run.committer)
850
+
851
+ def _warn(self, run: Run, committer: str | None = None) -> None:
852
+ """Count a warning with the run's peer (SPEC 11.3), persisted; at 2
853
+ the runs with it stop."""
854
+ run.warning = True
855
+ self._close_same(run.group_id, run.peer)
856
+ count, committers = self._e.store.sas_warnings(run.group_id, run.peer)
857
+ if committer is not None:
858
+ committers = [*committers, committer]
859
+ self._e.store.set_sas_warnings(run.group_id, run.peer, count + 1, committers)
860
+ if count + 1 >= WARNING_STOP:
861
+ self._event(
862
+ run, "sas_stopped", text=self._stop_text(run.group_id, run.peer, committers)
863
+ )
864
+
865
+ def _end(self, run: Run, reason: str) -> None:
866
+ run.state = "aborted"
867
+ run.reason = reason
868
+ run.nonce = None
869
+ if run.commitment is not None:
870
+ self._e.store.forget_sas_sent(run.group_id, run.commitment)
871
+ if self._active.get((run.group_id, run.peer)) == run.run_id:
872
+ del self._active[(run.group_id, run.peer)]
873
+
874
+ def _event(self, run: Run, kind: str, **data: Any) -> None:
875
+ data = {
876
+ "run": run.run_id,
877
+ "peer": run.peer,
878
+ "peer_name": self._e._display_name(run.group_id, run.peer),
879
+ **data,
880
+ }
881
+ self._e._defer(lambda: self._emit(run.group_id, kind, **data))
882
+
883
+ def _emit(self, group_id: str, kind: str, **data: Any) -> None:
884
+ loop = asyncio.get_running_loop()
885
+ task = loop.create_task(self._e.emit(group_id, kind, **data))
886
+ self._e._background.add(task)
887
+ task.add_done_callback(self._e._background.discard)
888
+
889
+ def step(self) -> int | None:
890
+ """Timeouts, the unpaired notice, held expiry and runs waiting for an
891
+ own commit; returns the next time something is due."""
892
+ now = self._now()
893
+ due: list[int] = []
894
+ for run in list(self._runs.values()):
895
+ if run.under_way:
896
+ due.extend(self._step_run(run, now))
897
+ for key, h in list(self._held.items()):
898
+ if now - h.at >= HELD_TTL_SECS:
899
+ del self._held[key]
900
+ else:
901
+ due.append(h.at + HELD_TTL_SECS)
902
+ for key, budget in list(self._budgets.items()):
903
+ while budget.times and now - budget.times[0] >= BUDGET_WINDOW_SECS:
904
+ budget.times.popleft()
905
+ if not budget.times:
906
+ del self._budgets[key]
907
+ for key, (_epoch, at) in list(self._siblings.items()):
908
+ if now - at >= SIBLING_WINDOW_SECS:
909
+ del self._siblings[key]
910
+ # Ended runs are kept for their answers, then forgotten.
911
+ for run_id, run in list(self._runs.items()):
912
+ if not run.under_way and now - run.started_at >= 2 * RUN_TIMEOUT_SECS + (
913
+ SAME_WINDOW_SECS
914
+ ):
915
+ del self._runs[run_id]
916
+ return min(due) if due else None
917
+
918
+ def _step_run(self, run: Run, now: int) -> list[int]:
919
+ """One run's timers; returns when it next needs a look."""
920
+ if run.state == "starting":
921
+ self._begin(run)
922
+ deadline = run.started_at + RUN_TIMEOUT_SECS
923
+ if now >= deadline:
924
+ self._send_cancel(run)
925
+ self._abort(run, "timed_out")
926
+ return []
927
+ due = [deadline]
928
+ if run.state == "starting":
929
+ due.append(now + 1)
930
+ if run.paired is None:
931
+ notice = run.started_at + UNPAIRED_NOTICE_SECS
932
+ if now < notice:
933
+ due.append(notice)
934
+ elif not run.unpaired_told:
935
+ run.unpaired_told = True
936
+ text = UNPAIRED_TEXT.format(name=self._name(run.group_id, run.peer))
937
+ self._event(run, "sas_unpaired", text=text)
938
+ return due
939
+
940
+ async def run(self) -> None:
941
+ """The SAS timer (one of the engine's workers)."""
942
+ e = self._e
943
+ backoff = Backoff(base=1.0, cap=60.0, rng=e.options.backoff_rng)
944
+ while True:
945
+ self._wake.clear()
946
+ try:
947
+ due = self.step()
948
+ backoff.reset()
949
+ except Exception as err:
950
+ LOG.warning("sas_timer_failed", error=type(err).__name__)
951
+ due = self._now() + int(backoff.next())
952
+ woken = asyncio.ensure_future(self._wake.wait())
953
+ waits: set[asyncio.Future[Any]] = {woken}
954
+ if due is not None:
955
+ waits.add(asyncio.ensure_future(e.clock.sleep_until(due, deadline=True)))
956
+ try:
957
+ await asyncio.sleep(0)
958
+ e._pulse()
959
+ await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
960
+ finally:
961
+ for w in waits:
962
+ w.cancel()
963
+
964
+ # -- the cadence Update (SPEC 3.1, 11.3) ------------------------------------------------
965
+
966
+ def defer(self, group_id: str) -> bool:
967
+ """Whether to defer the cadence Update (SPEC 3.1, 11.3). Only while
968
+ this instance takes part in a run, its code waiting for an answer
969
+ included, and for at most 600 s in total until this instance's next
970
+ commit. A held commitment doesn't defer it. (A commit of pending
971
+ proposals can't wait: while a proposal is pending, no application
972
+ message goes out, the ceremony messages included.)"""
973
+ if not self._taking_part(group_id):
974
+ self._defer_since.pop(group_id, None)
975
+ return False
976
+ now = self._now()
977
+ since = self._defer_since.setdefault(group_id, now)
978
+ return now - since < UPDATE_DEFER_SECS
979
+
980
+ # -- recovery ------------------------------------------------------------------------
981
+
982
+ def recover(self) -> None:
983
+ """On start: runs don't survive a restart, so their queued messages
984
+ are dropped, and every commitment sent in the last 600 s and not yet
985
+ revealed is cancelled (SPEC 11.3), so its peer doesn't pair with it."""
986
+ e = self._e
987
+ e.store.drop_queued_sas()
988
+ now = self._now()
989
+ for row in e.store.sas_sent():
990
+ group = e.store.group(row["group_id"])
991
+ if (
992
+ group is not None
993
+ and group.status == "active"
994
+ and now - int(row["at"]) < HELD_TTL_SECS
995
+ and int(row["epoch"]) == e._core_epoch(unb64(row["group_id"]))
996
+ ):
997
+ body = {"commitment": b64(row["commitment"]), "epoch": int(row["epoch"])}
998
+ inner = e.inner(SAS_CANCEL, body, [row["peer"]], origin="human", hop=0)
999
+ meta = {"run": "", "kind": SAS_CANCEL, "epoch": int(row["epoch"])}
1000
+ payload = {"inner": inner, "msg_id": b64(secrets.token_bytes(16)), "sas": meta}
1001
+ e.store.enqueue("app", row["group_id"], payload, now)
1002
+ e.store.forget_all_sas_sent()