agentic-runner 2.6.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 (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,509 @@
1
+ """The installation's Recipient Key, and the sealed box it opens (PRD issue 48, 22 A4-A7).
2
+
3
+ One X25519 keypair per **installation** -- a Helm release's replicas share the host Secret
4
+ and register the same key, a workstation process is its own installation (issues 46, 47).
5
+ The public half is registered at bootstrap (issue 41) and relayed by a platform that did
6
+ not mint it; the private half never leaves this state directory, and the plaintext it
7
+ opens never leaves process memory.
8
+
9
+ **The construction**, and why this one. Research 18's precedents are HPKE Base, the age
10
+ stanza and libsodium's ``crypto_box_seal`` -- all the same sealed-box shape: an ephemeral
11
+ X25519 keypair, one Diffie-Hellman against the recipient's public key, a KDF over the
12
+ result, and an AEAD. We build that shape out of primitives both ends already have rather
13
+ than shipping a fourth implementation of it:
14
+
15
+ ikm = X25519(ephemeral_private, recipient_public)
16
+ key = HKDF-SHA256(ikm, salt="", info=SEAL_DOMAIN || ephemeral_public
17
+ || recipient_public, length=32)
18
+ body = AES-256-GCM(key, nonce=0, plaintext, aad=delivery_binding(...))
19
+ blob = base64(ephemeral_public || body)
20
+
21
+ The all-zero nonce is safe and is the reason the ephemeral public key is in the KDF info:
22
+ the key is derived per sealing from a fresh ephemeral scalar, so it encrypts exactly one
23
+ message. The binding triple is the AEAD's associated data, which is what makes a
24
+ ciphertext lifted from another ``(contract, slot, recipient key)`` fail the tag check
25
+ rather than decrypt to something.
26
+
27
+ That choice is what lets the **browser** seal with no library at all: Web Crypto does
28
+ X25519, HKDF and AES-GCM natively (ADR-0009's constraint is that the console pulls in no
29
+ new dependency, and the native platform feature is the smallest way to honour it), and
30
+ ``frontend/src/lib/sealed-credential.ts`` is the same five lines in TypeScript.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import base64
36
+ import binascii
37
+ import json
38
+ import secrets
39
+ from collections.abc import Callable, Sequence
40
+ from dataclasses import dataclass
41
+ from datetime import UTC, datetime
42
+ from pathlib import Path
43
+ from typing import Final
44
+ from uuid import UUID
45
+
46
+ from cryptography.exceptions import InvalidTag
47
+ from cryptography.hazmat.primitives import hashes
48
+ from cryptography.hazmat.primitives.asymmetric.x25519 import (
49
+ X25519PrivateKey,
50
+ X25519PublicKey,
51
+ )
52
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
53
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
54
+
55
+ from agentic_runner_contracts.runner_registration import RecipientKey
56
+ from agentic_runner_contracts.sealed_credential import (
57
+ RENEWAL_INTERVAL,
58
+ SEAL_DOMAIN,
59
+ OpenedCredential,
60
+ SealedCredential,
61
+ delivery_binding,
62
+ key_fingerprint,
63
+ )
64
+
65
+ __all__ = [
66
+ "RECIPIENT_KEY_FILENAME",
67
+ "RecipientKeyPair",
68
+ "RecipientKeyStore",
69
+ "SealedCredentialError",
70
+ "SealedCredentialStream",
71
+ "generate_recipient_key",
72
+ "open_sealed",
73
+ "seal",
74
+ ]
75
+
76
+ RECIPIENT_KEY_FILENAME: Final[str] = "recipient-key.json"
77
+
78
+ _PUBLIC_KEY_BYTES: Final[int] = 32
79
+ _KEY_BYTES: Final[int] = 32
80
+ _NONCE: Final[bytes] = bytes(12)
81
+
82
+
83
+ class SealedCredentialError(ValueError):
84
+ """A sealed value could not be opened: wrong key, wrong binding, or malformed."""
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class RecipientKeyPair:
89
+ """One installation's Recipient Key. ``private_key`` is memory and 0600 disk only."""
90
+
91
+ key_id: str
92
+ public_key: str
93
+ private_key: str
94
+
95
+ @property
96
+ def fingerprint(self) -> str:
97
+ return key_fingerprint(self.public_key)
98
+
99
+
100
+ def generate_recipient_key() -> RecipientKeyPair:
101
+ """A fresh X25519 keypair and the id the platform will relay it under.
102
+
103
+ The id is random rather than derived from the key: a derived id would let anyone
104
+ holding the public half recompute it, and the id is what a delivered ciphertext is
105
+ bound to -- it has to change on every renewal even in the (impossible, but) case of a
106
+ repeated key.
107
+ """
108
+
109
+ private = X25519PrivateKey.generate()
110
+ return RecipientKeyPair(
111
+ key_id=f"rk_{secrets.token_hex(16)}",
112
+ public_key=_b64(private.public_key().public_bytes_raw()),
113
+ private_key=_b64(private.private_bytes_raw()),
114
+ )
115
+
116
+
117
+ def seal(*, public_key: str, binding: bytes, plaintext: str) -> str:
118
+ """Seal one value to a Recipient Key. The Runner's own half of 22 A7's renewal.
119
+
120
+ Delivery itself happens in the browser; this exists because a renewal re-seals values
121
+ the Runner already holds, and because a test that seals in Python and opens in Python
122
+ proves nothing about the wire -- the fixture the round-trip test opens is produced by
123
+ the *TypeScript* sealer for exactly that reason.
124
+ """
125
+
126
+ ephemeral = X25519PrivateKey.generate()
127
+ ephemeral_public = ephemeral.public_key().public_bytes_raw()
128
+ recipient = _public_key(public_key)
129
+ shared = ephemeral.exchange(recipient)
130
+ key = _derive(shared, ephemeral_public, recipient.public_bytes_raw())
131
+ body = AESGCM(key).encrypt(_NONCE, plaintext.encode("utf-8"), binding)
132
+ return _b64(ephemeral_public + body)
133
+
134
+
135
+ def open_sealed(*, private_key: str, binding: bytes, ciphertext: str) -> str:
136
+ """Open a sealed value, or refuse.
137
+
138
+ Every failure is one exception type: a ciphertext bound to another triple, one sealed
139
+ to a different Recipient Key and one that is simply corrupt are indistinguishable to
140
+ the holder, and telling them apart would be an oracle. The caller's answer to all
141
+ three is the same -- refuse the slot and say which reference could not be opened.
142
+ """
143
+
144
+ try:
145
+ blob = base64.b64decode(ciphertext, validate=True)
146
+ except (binascii.Error, ValueError) as error:
147
+ raise SealedCredentialError("the sealed value is not base64") from error
148
+ if len(blob) <= _PUBLIC_KEY_BYTES:
149
+ raise SealedCredentialError("the sealed value is too short to carry an ephemeral key")
150
+ ephemeral_public, body = blob[:_PUBLIC_KEY_BYTES], blob[_PUBLIC_KEY_BYTES:]
151
+ private = _private_key(private_key)
152
+ shared = private.exchange(X25519PublicKey.from_public_bytes(ephemeral_public))
153
+ key = _derive(shared, ephemeral_public, private.public_key().public_bytes_raw())
154
+ try:
155
+ opened = AESGCM(key).decrypt(_NONCE, body, binding)
156
+ except InvalidTag as error:
157
+ raise SealedCredentialError(
158
+ "the sealed value does not open with this Recipient Key and binding"
159
+ ) from error
160
+ return opened.decode("utf-8")
161
+
162
+
163
+ class RecipientKeyStore:
164
+ """The installation's key on disk, plus the 30-day renewal's two-key window (22 A7).
165
+
166
+ A renewal is the Runner's own act on its own schedule -- nothing on the platform side
167
+ triggers it and no funder is asked for anything. It generates a new keypair, keeps the
168
+ old one beside it, re-seals every slot and only then destroys the old private half.
169
+ The window is what makes the sequence safe to interrupt: a Runner that crashes between
170
+ the push and the confirmation re-reads both keys and opens whatever it is handed.
171
+
172
+ A **reinstall** is this state file being gone. The new process generates a key with no
173
+ previous half, so nothing it is handed opens -- which is precisely the signal the
174
+ control plane turns into "every slot of that installation is stale, notify each
175
+ funder" (22 A7's last clause).
176
+ """
177
+
178
+ def __init__(
179
+ self, state_dir: Path, *, clock: Callable[[], datetime] = lambda: datetime.now(UTC)
180
+ ) -> None:
181
+ self._path = state_dir / RECIPIENT_KEY_FILENAME
182
+ self._state: dict[str, object] | None = None
183
+ # Injected so the renewal window is testable without waiting 30 days. Only the
184
+ # *first* generation reads it; every rotation is stamped with the caller's `now`.
185
+ self._clock = clock
186
+
187
+ def current(self) -> RecipientKeyPair:
188
+ """This installation's Recipient Key, generated on first call."""
189
+
190
+ state = self._load()
191
+ return _pair(state["current"])
192
+
193
+ def previous(self) -> RecipientKeyPair | None:
194
+ """The key being retired, while a renewal is in flight."""
195
+
196
+ raw = self._load().get("previous")
197
+ return None if raw is None else _pair(raw)
198
+
199
+ def rotated_at(self) -> datetime:
200
+ return datetime.fromisoformat(str(self._load()["rotated_at"]))
201
+
202
+ def managed_by(self) -> str | None:
203
+ """Who owns rotation: ``None`` for this process, else the installer (issue 46).
204
+
205
+ A Helm release's replicas share one key through a Secret, so a rotation by one of
206
+ them would strand the others on a key the platform no longer relays to. There the
207
+ installer rotates, by replacing the Secret, and :meth:`install` hands the new
208
+ key in with the old one kept as ``previous`` -- the same two-key window a
209
+ process-driven renewal walks through.
210
+ """
211
+
212
+ raw = self._load().get("managed_by")
213
+ return None if raw is None else str(raw)
214
+
215
+ def due(self, now: datetime) -> bool:
216
+ """Whether the renewal window has passed. An installation offline past it is due
217
+ the moment it wakes, which is 22 A7's "re-seals on wake" with no extra state."""
218
+
219
+ return (
220
+ self.managed_by() is None
221
+ and self.previous() is None
222
+ and now - self.rotated_at() >= RENEWAL_INTERVAL
223
+ )
224
+
225
+ def install(self, pair: RecipientKeyPair, *, managed_by: str, now: datetime) -> bool:
226
+ """Adopt an installer-held key; returns whether anything changed.
227
+
228
+ The same key again is a no-op, so a pod restart re-reads its Secret without
229
+ touching the file. A *different* key is the installer's rotation: the key on
230
+ disk becomes ``previous`` so every value sealed to it still opens until the
231
+ re-seal confirms (22 A7), and the process never rotates on its own after this.
232
+ """
233
+
234
+ if not self._path.exists():
235
+ # A first boot: adopt outright, rather than let `_load` mint a key nobody
236
+ # registered only to demote it.
237
+ self._save(
238
+ {
239
+ "current": _raw(pair),
240
+ "previous": None,
241
+ "rotated_at": now.isoformat(),
242
+ "managed_by": managed_by,
243
+ }
244
+ )
245
+ return True
246
+ state = self._load()
247
+ current = _pair(state["current"])
248
+ if current.key_id == pair.key_id and state.get("managed_by") == managed_by:
249
+ return False
250
+ if current.key_id != pair.key_id:
251
+ state["previous"] = state["current"]
252
+ state["current"] = _raw(pair)
253
+ state["rotated_at"] = now.isoformat()
254
+ state["managed_by"] = managed_by
255
+ self._save(state)
256
+ return True
257
+
258
+ def rotate(self, now: datetime) -> RecipientKeyPair:
259
+ """Generate the next key and keep the old one openable until every slot confirms."""
260
+
261
+ state = self._load()
262
+ fresh = generate_recipient_key()
263
+ state["previous"] = state["current"]
264
+ state["current"] = _raw(fresh)
265
+ state["rotated_at"] = now.isoformat()
266
+ self._save(state)
267
+ return fresh
268
+
269
+ def retire_previous(self) -> None:
270
+ """Destroy the old private key. Called only once every slot reports the new id."""
271
+
272
+ state = self._load()
273
+ if state.get("previous") is None:
274
+ return
275
+ state["previous"] = None
276
+ self._save(state)
277
+
278
+ def _load(self) -> dict[str, object]:
279
+ if self._state is not None:
280
+ return self._state
281
+ if self._path.exists():
282
+ self._state = json.loads(self._path.read_text(encoding="utf-8"))
283
+ else:
284
+ self._state = {
285
+ "current": _raw(generate_recipient_key()),
286
+ "previous": None,
287
+ "rotated_at": self._clock().isoformat(),
288
+ }
289
+ self._save(self._state)
290
+ return self._state
291
+
292
+ def _save(self, state: dict[str, object]) -> None:
293
+ self._path.parent.mkdir(parents=True, exist_ok=True)
294
+ self._path.write_text(json.dumps(state), encoding="utf-8")
295
+ # The private half is in this file; a shared PVC or a workstation home is not a
296
+ # place to leave it group-readable (the same rule `registration.save_state` keeps).
297
+ self._path.chmod(0o600)
298
+ self._state = state
299
+
300
+
301
+ def _raw(pair: RecipientKeyPair) -> dict[str, str]:
302
+ return {
303
+ "key_id": pair.key_id,
304
+ "public_key": pair.public_key,
305
+ "private_key": pair.private_key,
306
+ }
307
+
308
+
309
+ def _pair(raw: object) -> RecipientKeyPair:
310
+ if not isinstance(raw, dict):
311
+ raise SealedCredentialError("the Recipient Key state file is malformed")
312
+ return RecipientKeyPair(
313
+ key_id=str(raw["key_id"]),
314
+ public_key=str(raw["public_key"]),
315
+ private_key=str(raw["private_key"]),
316
+ )
317
+
318
+
319
+ def _derive(shared: bytes, ephemeral_public: bytes, recipient_public: bytes) -> bytes:
320
+ return HKDF(
321
+ algorithm=hashes.SHA256(),
322
+ length=_KEY_BYTES,
323
+ salt=b"",
324
+ info=SEAL_DOMAIN.encode("utf-8") + ephemeral_public + recipient_public,
325
+ ).derive(shared)
326
+
327
+
328
+ def _public_key(value: str) -> X25519PublicKey:
329
+ return X25519PublicKey.from_public_bytes(_raw_key(value, "public"))
330
+
331
+
332
+ def _private_key(value: str) -> X25519PrivateKey:
333
+ return X25519PrivateKey.from_private_bytes(_raw_key(value, "private"))
334
+
335
+
336
+ def _raw_key(value: str, half: str) -> bytes:
337
+ try:
338
+ raw = base64.b64decode(value, validate=True)
339
+ except (binascii.Error, ValueError) as error:
340
+ raise SealedCredentialError(f"a Recipient Key's {half} half must be base64") from error
341
+ if len(raw) != _PUBLIC_KEY_BYTES:
342
+ raise SealedCredentialError(f"a Recipient Key's {half} half must be 32 bytes")
343
+ return raw
344
+
345
+
346
+ def _b64(raw: bytes) -> str:
347
+ return base64.b64encode(raw).decode("ascii")
348
+
349
+
350
+ class SealedCredentialStream:
351
+ """The sealed half of the heartbeat, as one testable step (22 A5, A7, A9).
352
+
353
+ The Runner has no long-running heartbeat driver of its own yet (PRD issue 44 owns
354
+ that loop), so this is written as a pure step over one ack: hand it what the control
355
+ plane sent, take back the plaintext it opened and the fields the *next* envelope must
356
+ carry. A loop calls it every 30 s; a test calls it twice with a fake clock.
357
+
358
+ Three behaviours fall out of the ack being **authoritative** rather than incremental:
359
+
360
+ * **Pull on boot, on activation, on a version change.** The Runner opens a slot only
361
+ when its version or key id differs from what it holds, so a steady state costs no
362
+ X25519 at all and a re-delivery lands at the next beat.
363
+ * **Termination drops the value.** A slot absent from the ack is forgotten here and
364
+ dropped from the resolver -- wiped, not listed (22 A9; the revocation checklist is
365
+ for org-provisioned credentials, which this is not).
366
+ * **Suspension keeps it.** Suspension does not delete the ciphertext row, so the ack
367
+ still carries the slot and nothing above happens.
368
+ """
369
+
370
+ def __init__(self, keys: RecipientKeyStore) -> None:
371
+ self._keys = keys
372
+ self._plaintext: dict[tuple[str, str], str] = {}
373
+ self._held: dict[tuple[str, str], tuple[int, str]] = {}
374
+ self._awaiting: set[tuple[str, str]] = set()
375
+ self._opened: list[OpenedCredential] = []
376
+
377
+ @property
378
+ def plaintext(self) -> dict[tuple[str, str], str]:
379
+ """``{(contract_id, slot): value}`` -- process memory only, never disk (22 A5)."""
380
+
381
+ return dict(self._plaintext)
382
+
383
+ def apply(self, credentials: Sequence[SealedCredential]) -> list[SealedCredential]:
384
+ """Open what is new, forget what is gone; return what failed to open.
385
+
386
+ A failure is not an exception: one slot sealed to a Recipient Key this
387
+ installation no longer has (a half-finished renewal on the other side, or a
388
+ reinstall) must not stop the other slots from opening. The caller records
389
+ Evidence naming each refused slot -- ids only, never a fingerprint of the value.
390
+ """
391
+
392
+ refused: list[SealedCredential] = []
393
+ seen: set[tuple[str, str]] = set()
394
+ current = self._keys.current().key_id
395
+ # The confirmation a renewal waits for is read off the ack itself, never carried
396
+ # in memory: a Runner that crashed after `rotate()` persisted but before the push
397
+ # landed comes back with nothing awaited, and an in-memory set would let its first
398
+ # ack -- still all under the old key -- retire the only key that opens it.
399
+ self._awaiting = {
400
+ (str(sealed.contract_id), sealed.slot)
401
+ for sealed in credentials
402
+ if sealed.recipient_key_id != current
403
+ }
404
+ for sealed in credentials:
405
+ key = (str(sealed.contract_id), sealed.slot)
406
+ seen.add(key)
407
+ if self._held.get(key) == (sealed.version, sealed.recipient_key_id):
408
+ continue
409
+ opened = self._open(sealed)
410
+ if opened is None:
411
+ refused.append(sealed)
412
+ continue
413
+ self._plaintext[key] = opened
414
+ self._held[key] = (sealed.version, sealed.recipient_key_id)
415
+ self._opened.append(
416
+ OpenedCredential(
417
+ contract_id=sealed.contract_id,
418
+ slot=sealed.slot,
419
+ recipient_key_id=sealed.recipient_key_id,
420
+ version=sealed.version,
421
+ )
422
+ )
423
+ for gone in set(self._held) - seen:
424
+ self._held.pop(gone, None)
425
+ self._plaintext.pop(gone, None)
426
+ self._retire_if_confirmed()
427
+ return refused
428
+
429
+ def take_opened(self) -> list[OpenedCredential]:
430
+ """What the next envelope reports as opened, drained (22 A10's Evidence).
431
+
432
+ Drained, not read: "opened by Runner" is one event per open, and a beat that
433
+ reported it must not report it again thirty seconds later. A heartbeat that fails
434
+ loses the report rather than the value -- this is Evidence, not state, and the
435
+ plaintext it describes is already held.
436
+ """
437
+
438
+ opened, self._opened = self._opened, []
439
+ return opened
440
+
441
+ def renew(self, now: datetime) -> tuple[RecipientKey | None, list[SealedCredential]]:
442
+ """22 A7, the Runner-driven renewal: a new key and every slot re-sealed to it.
443
+
444
+ Nothing in flight is affected -- the provider values themselves do not change, so
445
+ a running Directive keeps spending the same key while this happens, and a funder
446
+ sees only the fingerprint change. Returns what the next heartbeat envelope carries;
447
+ the old private half is destroyed in :meth:`apply`, once the ack shows every slot
448
+ stored under the new id.
449
+
450
+ While a previous key is still held, every call re-announces the current key and
451
+ re-seals whatever the last ack still showed under the old one: the push that
452
+ registers it may never have landed, and nothing else would ever send it again.
453
+ """
454
+
455
+ if self._keys.due(now):
456
+ fresh = self._keys.rotate(now)
457
+ pending = set(self._plaintext)
458
+ elif self._keys.previous() is not None:
459
+ fresh = self._keys.current()
460
+ pending = self._awaiting & set(self._plaintext)
461
+ else:
462
+ return None, []
463
+ resealed = [
464
+ SealedCredential(
465
+ contract_id=UUID(contract_id),
466
+ slot=slot,
467
+ recipient_key_id=fresh.key_id,
468
+ version=self._held[(contract_id, slot)][0] + 1,
469
+ ciphertext=seal(
470
+ public_key=fresh.public_key,
471
+ binding=delivery_binding(
472
+ contract_id=contract_id, slot=slot, recipient_key_id=fresh.key_id
473
+ ),
474
+ plaintext=value,
475
+ ),
476
+ )
477
+ for (contract_id, slot), value in sorted(self._plaintext.items())
478
+ if (contract_id, slot) in pending
479
+ ]
480
+ # `_held` is deliberately left at what was *opened*, not what was pushed (PRD
481
+ # issue 70): a funder delivery landing between this seal and the push can take
482
+ # the very (version, key) this re-seal names, and marking it held here would skip
483
+ # opening the funder's value. The echo of a re-seal that did land costs one open.
484
+ return RecipientKey(key_id=fresh.key_id, public_key=fresh.public_key), resealed
485
+
486
+ def _retire_if_confirmed(self) -> None:
487
+ """Destroy the old private key only once every slot reports the new key id.
488
+
489
+ Order matters and is the whole safety property: retiring first would strand any
490
+ slot whose re-seal the control plane never stored, with no key left to open the
491
+ value it still holds.
492
+ """
493
+
494
+ if not self._awaiting:
495
+ self._keys.retire_previous()
496
+
497
+ def _open(self, sealed: SealedCredential) -> str | None:
498
+ for pair in (self._keys.current(), self._keys.previous()):
499
+ if pair is None or pair.key_id != sealed.recipient_key_id:
500
+ continue
501
+ try:
502
+ return open_sealed(
503
+ private_key=pair.private_key,
504
+ binding=sealed.binding(),
505
+ ciphertext=sealed.ciphertext,
506
+ )
507
+ except SealedCredentialError:
508
+ return None
509
+ return None