1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
//! Retry safety classification for every Trust Task the VTA serves.
//!
//! A client that retries a timed-out request is doing the right thing: the
//! dominant transport fault is a request that never arrived. The dangerous case
//! is the other one — the VTA *did* process it and only the reply was lost — and
//! whether that case is harmful depends entirely on the operation. Deleting an
//! already-deleted DID is free. Creating a second auto-assigned `did:webvh` is
//! not: the first is published in the log with nobody holding a reference to it.
//!
//! Today a caller cannot tell those apart, so it has to guess, and
//! [`crate::client::VtaClient`]'s own retry helpers guess conservatively for
//! everything. This module makes the property explicit and machine-readable, so
//! a retry layer can consult it instead of guessing, and so no new task can be
//! added without someone deciding which case it is.
//!
//! # The classification is about *lost replies*, not about mutation
//!
//! [`RetrySafety::RetrySafe`] does not mean "read-only". It means **a second
//! execution does no harm** — either because the operation converges on the same
//! end state (revoke, disable, delete) or because the duplicate artefact is inert
//! and self-expiring (a spare auth challenge). Both are safe to blind-retry, and
//! that is the only question a retry layer is asking.
//!
//! [`RetrySafety::Keyed`] means the opposite: a second execution leaves a
//! *second durable artefact that persists and matters*. These are the operations
//! that need an idempotency key the VTA dedups on.
//!
//! # Conservative by construction
//!
//! Where an operation's convergence is not obvious from its contract, it is
//! classified [`Keyed`](RetrySafety::Keyed) rather than
//! [`RetrySafe`](RetrySafety::RetrySafe). The asymmetry is deliberate and nearly
//! free: an over-classified task costs one dedup record, while an
//! under-classified one silently loses the protection in exactly the rare
//! lost-reply case the classification exists for. When you tighten one of these,
//! say why in the entry's comment.
//!
//! # This does not gate anything on its own
//!
//! Classification changes how a *keyed* request is handled. A request carrying no
//! idempotency key behaves exactly as it always has, on every task in the table —
//! so adding an entry here can never reject traffic that used to work.
use crate::trust_tasks;
/// What a second execution of a Trust Task costs, when the first one landed and
/// only its reply was lost.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RetrySafety {
/// No durable effect at all. Retry freely; no dedup record is worth keeping.
ReadOnly,
/// Mutating, but a repeat is harmless — it either converges on the same end
/// state or leaves an inert, self-expiring duplicate. Safe to blind-retry
/// with no idempotency key.
RetrySafe,
/// Non-convergent: a second execution leaves a second durable artefact that
/// persists and matters. Needs an idempotency key; the response is cached
/// and replayed to the retry.
Keyed,
/// As [`Keyed`](Self::Keyed), but the response carries secret material
/// (mnemonics, sealed bundles, private keys), so the response is **not**
/// cached. A replay is recognised and refused with a typed "already
/// performed" answer rather than a stored copy of the secret — deduping the
/// effect without turning the dedup store into a second place secrets live.
KeyedSecret,
}
impl RetrySafety {
/// Whether a caller may retry this task without an idempotency key.
pub fn is_blind_retry_safe(self) -> bool {
matches!(self, Self::ReadOnly | Self::RetrySafe)
}
/// Whether the VTA should dedup this task on an idempotency key when one is
/// supplied.
pub fn needs_key(self) -> bool {
matches!(self, Self::Keyed | Self::KeyedSecret)
}
/// Whether a replayed request may be answered from the cached response.
/// False for [`KeyedSecret`](Self::KeyedSecret), whose body is never stored.
pub fn response_is_replayable(self) -> bool {
!matches!(self, Self::KeyedSecret)
}
}
use RetrySafety::{Keyed, KeyedSecret, ReadOnly, RetrySafe};
/// Every URI in [`trust_tasks::ALL_URIS`], with what a lost reply costs it.
///
/// Pinned exhaustively by `every_uri_is_classified` — a new task cannot reach
/// the catalog without an entry here, which is the point. Same discipline as
/// [`trust_tasks::REST_ROUTED_URIS`].
#[allow(deprecated)] // names the deprecated 0.1 URIs on purpose — they are still served
pub const RETRY_SAFETY: &[(&str, RetrySafety)] = &[
// ── Auth ────────────────────────────────────────────────────────────
// A spare challenge is inert and expires on its own.
(trust_tasks::TASK_AUTH_CHALLENGE_0_1, RetrySafe),
// Consumes the challenge, mints a session. A repeat fails deterministically
// (challenge spent) or leaves a spare expiring session.
(trust_tasks::TASK_AUTH_AUTHENTICATE_0_1, RetrySafe),
// Refresh-token *rotation*: the old token is consumed as the new one is
// issued, so a lost reply leaves the caller holding a spent token and no
// replacement — locked out until re-auth. The one auth task that genuinely
// needs the key.
(trust_tasks::TASK_AUTH_REFRESH_0_1, Keyed),
(trust_tasks::TASK_AUTH_REVOKE_SESSION_0_1, RetrySafe),
(trust_tasks::TASK_AUTH_WHOAMI_0_1, ReadOnly),
(trust_tasks::TASK_AUTH_SESSIONS_LIST_0_1, ReadOnly),
(trust_tasks::TASK_AUTH_PASSKEY_LOGIN_START_0_1, RetrySafe),
(trust_tasks::TASK_AUTH_PASSKEY_LOGIN_START_0_2, RetrySafe),
(trust_tasks::TASK_AUTH_PASSKEY_LOGIN_FINISH_0_1, RetrySafe),
(trust_tasks::TASK_AUTH_PASSKEY_LOGIN_FINISH_0_2, RetrySafe),
// Consumes a one-shot step-up approval. A lost reply spends the approval
// without the caller learning it was granted.
(trust_tasks::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_1, Keyed),
(trust_tasks::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_2, Keyed),
// Same, and no looser for being answerable with `recorded`: a bound
// approval is still a one-shot, and a lost reply still spends it.
(trust_tasks::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_3, Keyed),
// ── Device ──────────────────────────────────────────────────────────
// Registration is keyed by a caller-supplied device identity, so a repeat
// lands on the same record — but it also mints device credentials, and
// whether those are re-minted is not visible from the contract. Conservative.
(trust_tasks::TASK_DEVICE_REGISTER_0_1, Keyed),
(trust_tasks::TASK_DEVICE_REGISTER_0_2, Keyed),
(trust_tasks::TASK_DEVICE_HEARTBEAT_0_1, RetrySafe),
(trust_tasks::TASK_DEVICE_HEARTBEAT_0_2, RetrySafe),
(trust_tasks::TASK_DEVICE_LIST_0_1, ReadOnly),
(trust_tasks::TASK_DEVICE_LIST_0_2, ReadOnly),
(trust_tasks::TASK_DEVICE_DISABLE_0_1, RetrySafe),
(trust_tasks::TASK_DEVICE_WIPE_0_1, RetrySafe),
(trust_tasks::TASK_DEVICE_WIPE_0_2, RetrySafe),
(trust_tasks::TASK_DEVICE_SET_WAKE_0_1, RetrySafe),
(trust_tasks::TASK_DEVICE_SET_WAKE_0_2, RetrySafe),
// ── Messaging ───────────────────────────────────────────────────────
(trust_tasks::TASK_MESSAGING_PING_0_1, ReadOnly),
// ── ACL ─────────────────────────────────────────────────────────────
(trust_tasks::TASK_ACL_LIST_0_1, ReadOnly),
// Grant is addressed by subject DID + context, so a repeat overwrites the
// same entry rather than adding a second.
(trust_tasks::TASK_ACL_GRANT_0_1, RetrySafe),
(trust_tasks::TASK_ACL_SHOW_0_1, ReadOnly),
(trust_tasks::TASK_ACL_UPDATE_0_1, RetrySafe),
(trust_tasks::TASK_ACL_CHANGE_ROLE_0_1, RetrySafe),
(trust_tasks::TASK_ACL_REVOKE_0_1, RetrySafe),
// Swap deletes the current subject's entry as it creates the new one. A lost
// reply strands the caller: old entry gone, new one unknown to them. This is
// remediation-plan F6 seen from the wire.
(trust_tasks::TASK_ACL_SWAP_KEY_0_1, Keyed),
// ── Contexts ────────────────────────────────────────────────────────
(trust_tasks::TASK_CONTEXTS_LIST_1_0, ReadOnly),
// Allocates an immutable BIP-32 base path. A second create is a second
// context with a second path — the caller only ever hears about one.
(trust_tasks::TASK_CONTEXTS_CREATE_1_0, Keyed),
(trust_tasks::TASK_CONTEXTS_GET_1_0, ReadOnly),
(trust_tasks::TASK_CONTEXTS_UPDATE_1_0, RetrySafe),
(trust_tasks::TASK_CONTEXTS_UPDATE_DID_1_0, RetrySafe),
(trust_tasks::TASK_CONTEXTS_PREVIEW_DELETE_1_0, ReadOnly),
(trust_tasks::TASK_CONTEXTS_DELETE_1_0, RetrySafe),
// ── Services ────────────────────────────────────────────────────────
//
// Every mutation here republishes the agent's did:webvh log, and that log
// is append-only history. The question is therefore not "does the end state
// converge" — it does — but "does a repeat leave a second entry", and for
// an operation that writes one unconditionally it can.
//
// `enable` refuses a transport already enabled, so a repeat is a conflict
// rather than a duplicate, which reads like RetrySafe. It is Keyed anyway:
// the caller who lost the reply cannot tell that conflict apart from "it
// never landed", and the cached response is exactly what resolves that.
(trust_tasks::TASK_SERVICES_LIST_1_0, ReadOnly),
(trust_tasks::TASK_SERVICES_GET_1_0, ReadOnly),
(trust_tasks::TASK_SERVICES_ENABLE_1_0, Keyed),
(trust_tasks::TASK_SERVICES_UPDATE_1_0, Keyed),
// Disable schedules a drain, and a repeat inside the window would restart
// it — extending the life of a mediator the operator is decommissioning.
(trust_tasks::TASK_SERVICES_DISABLE_1_0, Keyed),
(trust_tasks::TASK_SERVICES_ROLLBACK_1_0, Keyed),
(trust_tasks::TASK_SERVICES_DRAIN_LIST_1_0, ReadOnly),
// Destructive and not undoable: the messages the cancelled drain was
// protecting are already gone by the time a retry arrives.
(trust_tasks::TASK_SERVICES_DRAIN_CANCEL_1_0, Keyed),
// ── Keys ────────────────────────────────────────────────────────────
(trust_tasks::TASK_KEYS_LIST_0_1, ReadOnly),
// The orphan-key case the OpenVTC retry helper already documents: a lost
// reply mints a second key nobody references.
(trust_tasks::TASK_KEYS_CREATE_0_1, Keyed),
(trust_tasks::TASK_KEYS_IMPORT_0_1, Keyed),
(trust_tasks::TASK_KEYS_SHOW_0_1, ReadOnly),
(trust_tasks::TASK_KEYS_RENAME_0_1, RetrySafe),
(trust_tasks::TASK_KEYS_REVOKE_0_1, RetrySafe),
// `RetrySafe`, not `Keyed`: `exportable` is an absolute state, so a second
// execution converges on the same record rather than leaving a second
// artefact. That is the property the spec insists on, and it is exactly
// what makes a blind retry safe — a producer that retried a lost `false`
// must land on `false`, never toggle back to `true`.
(trust_tasks::TASK_KEYS_SET_EXPORTABILITY_0_1, RetrySafe),
// `ReadOnly`, for the same reason as `vta/contexts/secrets` above: it reads
// one key and changes nothing, so it needs no dedup record and therefore
// parks no secret in one. The URI it replaces was classified `KeyedSecret`
// on the stated grounds that "the export guard is one-shot" — there was no
// guard, and there never had been. The name said mnemonic, the comment said
// guard, and the code did neither.
(trust_tasks::TASK_KEYS_EXPORT_SECRET_0_1, ReadOnly),
// Signing is a pure function of key + payload; the same request signs the
// same bytes. No durable effect beyond the audit row.
(trust_tasks::TASK_KEYS_SIGN_0_1, ReadOnly),
(trust_tasks::TASK_KEYS_DERIVE_AND_SIGN_0_1, ReadOnly),
(
trust_tasks::TASK_KEYS_DERIVE_AND_SIGN_DOCUMENT_0_1,
ReadOnly,
),
// ── Seeds ───────────────────────────────────────────────────────────
(trust_tasks::TASK_SEEDS_LIST_1_0, ReadOnly),
// Rotation re-parents the key hierarchy. A second rotation on a lost reply
// rotates again, past the seed the caller thinks it landed on.
(trust_tasks::TASK_SEEDS_ROTATE_1_0, Keyed),
// `ReadOnly` even though the response carries private keys, and the
// reasoning is worth keeping because it is not the obvious answer.
// `KeyedSecret` exists for a task that needs dedup *and* returns a secret,
// so that the dedup store never becomes a second place secrets live. This
// task needs no dedup at all: `sideEffects: none`, and a second call
// returns the same bundle. `ReadOnly` therefore takes the dedup path out
// altogether (`idempotency.rs` early-returns unless `needs_key()`), so no
// response body is stored — the same protection, reached by the
// classification actually being true rather than by a stronger one.
(trust_tasks::TASK_CONTEXTS_SECRETS_1_0, ReadOnly),
// ── Audit ───────────────────────────────────────────────────────────
(trust_tasks::TASK_AUDIT_LIST_0_1, ReadOnly),
(trust_tasks::TASK_AUDIT_VERIFY_0_1, ReadOnly),
(trust_tasks::TASK_AUDIT_GET_RETENTION_1_0, ReadOnly),
(trust_tasks::TASK_AUDIT_UPDATE_RETENTION_1_0, RetrySafe),
// ── Discovery ───────────────────────────────────────────────────────
(trust_tasks::TASK_TRUST_TASK_DISCOVERY_0_1, ReadOnly),
// ── Password vault ──────────────────────────────────────────────────
(trust_tasks::TASK_VAULT_LIST_0_1, ReadOnly),
(trust_tasks::TASK_VAULT_LIST_0_2, ReadOnly),
(trust_tasks::TASK_VAULT_LIST_0_3, ReadOnly),
(trust_tasks::TASK_VAULT_GET_0_1, ReadOnly),
(trust_tasks::TASK_VAULT_GET_0_2, ReadOnly),
(trust_tasks::TASK_VAULT_GET_0_3, ReadOnly),
// Upsert is addressed by entry id — a repeat writes the same value.
(trust_tasks::TASK_VAULT_UPSERT_0_1, RetrySafe),
(trust_tasks::TASK_VAULT_UPSERT_0_2, RetrySafe),
(trust_tasks::TASK_VAULT_UPSERT_0_3, RetrySafe),
(trust_tasks::TASK_VAULT_DELETE_0_1, RetrySafe),
(trust_tasks::TASK_VAULT_ARCHIVE_0_1, RetrySafe),
(trust_tasks::TASK_VAULT_UNARCHIVE_0_1, RetrySafe),
(trust_tasks::TASK_VAULT_RESTORE_0_1, RetrySafe),
(trust_tasks::TASK_VAULT_PURGE_0_1, RetrySafe),
// Release and proxy-login read stored secrets and seal them to the caller.
// No durable mutation, so a repeat is a repeat read — but the *response* is
// secret-bearing, which matters to anything that would cache it.
(trust_tasks::TASK_VAULT_RELEASE_0_1, ReadOnly),
(trust_tasks::TASK_VAULT_RELEASE_0_2, ReadOnly),
(trust_tasks::TASK_VAULT_PROXY_LOGIN_0_1, ReadOnly),
(trust_tasks::TASK_VAULT_PROXY_LOGIN_0_2, ReadOnly),
(trust_tasks::TASK_VAULT_SIGN_TRUST_TASK_0_1, ReadOnly),
(trust_tasks::TASK_VAULT_SIGN_TRUST_TASK_0_2, ReadOnly),
// ── did-management (remote DID-hosting control plane) ───────────────
// Register/publish create a hosted record and a log entry respectively.
(trust_tasks::TASK_DID_MANAGEMENT_DID_REGISTER_0_1, Keyed),
(trust_tasks::TASK_DID_MANAGEMENT_DID_PUBLISH_0_1, Keyed),
(trust_tasks::TASK_DID_MANAGEMENT_DID_DELETE_0_1, RetrySafe),
(trust_tasks::TASK_DID_MANAGEMENT_DID_ENABLE_0_1, RetrySafe),
(trust_tasks::TASK_DID_MANAGEMENT_DID_DISABLE_0_1, RetrySafe),
(trust_tasks::TASK_DID_MANAGEMENT_DID_LIST_0_1, ReadOnly),
(trust_tasks::TASK_DID_MANAGEMENT_DID_INFO_0_1, ReadOnly),
(
trust_tasks::TASK_DID_MANAGEMENT_DID_CHECK_NAME_0_1,
ReadOnly,
),
(
trust_tasks::TASK_DID_MANAGEMENT_DID_CHANGE_OWNER_0_1,
RetrySafe,
),
// Rollback is a *relative* step — applying it twice rewinds twice.
(trust_tasks::TASK_DID_MANAGEMENT_DID_ROLLBACK_0_1, Keyed),
(
trust_tasks::TASK_DID_MANAGEMENT_DID_PROBLEM_REPORT_0_1,
RetrySafe,
),
(trust_tasks::TASK_DID_MANAGEMENT_DOMAIN_CREATE_0_1, Keyed),
(
trust_tasks::TASK_DID_MANAGEMENT_DOMAIN_UPDATE_0_1,
RetrySafe,
),
(
trust_tasks::TASK_DID_MANAGEMENT_DOMAIN_DISABLE_0_1,
RetrySafe,
),
(trust_tasks::TASK_DID_MANAGEMENT_DOMAIN_PURGE_0_1, RetrySafe),
(
trust_tasks::TASK_DID_MANAGEMENT_DOMAIN_SET_DEFAULT_0_1,
RetrySafe,
),
(
trust_tasks::TASK_DID_MANAGEMENT_DOMAIN_ASSIGN_0_1,
RetrySafe,
),
(
trust_tasks::TASK_DID_MANAGEMENT_DOMAIN_UNASSIGN_0_1,
RetrySafe,
),
(trust_tasks::TASK_DID_MANAGEMENT_SERVER_REGISTER_0_1, Keyed),
(trust_tasks::TASK_DID_MANAGEMENT_SERVER_HEALTH_0_1, ReadOnly),
(
trust_tasks::TASK_DID_MANAGEMENT_SERVER_STATS_SYNC_0_1,
RetrySafe,
),
(
trust_tasks::TASK_DID_MANAGEMENT_REGISTRY_ADMIN_REGISTER_0_1,
Keyed,
),
(
trust_tasks::TASK_DID_MANAGEMENT_REGISTRY_DEREGISTER_0_1,
RetrySafe,
),
// ── Config + management ─────────────────────────────────────────────
(trust_tasks::TASK_CONFIG_SHOW_0_1, ReadOnly),
(trust_tasks::TASK_CONFIG_PATCH_0_1, RetrySafe),
(trust_tasks::TASK_MANAGEMENT_RELOAD_SERVICES_1_0, RetrySafe),
// ── Passkey VMs ─────────────────────────────────────────────────────
(
trust_tasks::TASK_PASSKEY_VMS_ENROLL_CHALLENGE_0_1,
RetrySafe,
),
// Enrolment adds a verification method to the DID document — a second
// submission adds a second.
(trust_tasks::TASK_PASSKEY_VMS_ENROLL_SUBMIT_0_1, Keyed),
(trust_tasks::TASK_PASSKEY_VMS_LIST_0_1, ReadOnly),
(trust_tasks::TASK_PASSKEY_VMS_REVOKE_0_1, RetrySafe),
// ── Provisioning ────────────────────────────────────────────────────
// Mints a DID, keys, an ACL grant and an authorization VC, and returns them
// HPKE-sealed. Non-convergent in every one of those, and the response is the
// secret bundle. Remediation-plan F3.
(trust_tasks::TASK_PROVISION_INTEGRATION_0_3, KeyedSecret),
// ── WebVH servers ───────────────────────────────────────────────────
(trust_tasks::TASK_WEBVH_SERVERS_LIST_1_0, ReadOnly),
(trust_tasks::TASK_WEBVH_SERVERS_REGISTER_1_0, Keyed),
(trust_tasks::TASK_WEBVH_SERVERS_REMOVE_1_0, RetrySafe),
(trust_tasks::TASK_WEBVH_SERVERS_DOMAINS_0_1, ReadOnly),
(trust_tasks::TASK_WEBVH_SERVERS_RECONCILE_0_1, RetrySafe),
// Destructive, but convergent: once the slot is gone a repeat removes
// nothing — the same reasoning as `dids/delete` below. The one hazard is a
// host that re-allocates the slot id, and two guards already close it: the
// VTA re-derives orphanhood (a re-allocated slot with a local record is
// refused outright), and `expectedDid` refuses a slot that no longer serves
// what the caller saw.
(trust_tasks::TASK_WEBVH_SERVERS_RETIRE_ORPHAN_0_1, RetrySafe),
// ── WebVH DIDs ──────────────────────────────────────────────────────
(trust_tasks::TASK_WEBVH_DIDS_LIST_1_0, ReadOnly),
// The finding that opened all of this. Production callers use
// `WebvhPathMode::AutoAssign`, so a retried create is assigned a *different*
// path: the first DID stays published in the log with no local reference.
// An explicit path would collide and surface as a Conflict; auto-assign
// silently orphans.
(trust_tasks::TASK_WEBVH_DIDS_CREATE_1_0, Keyed),
(trust_tasks::TASK_WEBVH_DIDS_GET_1_0, ReadOnly),
// Deleting an already-deleted DID answers not-found, which is deterministic
// and therefore never retried.
(trust_tasks::TASK_WEBVH_DIDS_DELETE_1_0, RetrySafe),
// Each update appends a log entry; two updates append two.
(trust_tasks::TASK_WEBVH_DIDS_UPDATE_1_0, Keyed),
(trust_tasks::TASK_WEBVH_DIDS_ROTATE_KEYS_1_0, Keyed),
// `RetrySafe` where its two neighbours above are `Keyed`, and the difference
// is convergence rather than caution. `update` and `rotate-keys` append a
// log entry, so a second execution leaves a second durable artefact. A
// realign appends nothing: it re-derives its plan from the document current
// at execution time and renames records to match, so a repeat finds every
// record already aligned and moves none. `a_second_run_has_nothing_left_to_do`
// in `operations::did_webvh::realign` is that property, pinned.
(trust_tasks::TASK_WEBVH_DIDS_REALIGN_KEYS_1_0, RetrySafe),
(trust_tasks::TASK_WEBVH_DIDS_REGISTER_WITH_SERVER_1_0, Keyed),
(trust_tasks::TASK_WEBVH_AGENT_NAME_LIST_1_0, ReadOnly),
(trust_tasks::TASK_WEBVH_AGENT_NAME_CHECK_1_0, ReadOnly),
(trust_tasks::TASK_WEBVH_AGENT_NAME_SET_1_0, RetrySafe),
(trust_tasks::TASK_WEBVH_AGENT_NAME_REMOVE_1_0, RetrySafe),
(trust_tasks::TASK_WEBVH_AGENT_NAME_DISABLE_1_0, RetrySafe),
(trust_tasks::TASK_WEBVH_AGENT_NAME_ENABLE_1_0, RetrySafe),
// ── DID templates ───────────────────────────────────────────────────
(trust_tasks::TASK_DID_TEMPLATES_LIST_2_0, ReadOnly),
// Addressed by template name, so a repeat overwrites rather than duplicates.
(trust_tasks::TASK_DID_TEMPLATES_CREATE_2_0, RetrySafe),
(trust_tasks::TASK_DID_TEMPLATES_GET_2_0, ReadOnly),
(trust_tasks::TASK_DID_TEMPLATES_UPDATE_2_0, RetrySafe),
(trust_tasks::TASK_DID_TEMPLATES_DELETE_2_0, RetrySafe),
// Render is a pure function of template + variables.
(trust_tasks::TASK_DID_TEMPLATES_RENDER_2_0, ReadOnly),
// ── Backup ──────────────────────────────────────────────────────────
// The descriptor flow. `initiate-export` stages the encrypted state and
// answers with the descriptor — including `transportToken`, the bearer
// credential that fetches the whole VTA under its passphrase — so its reply
// is the secret one and must never sit in the dedup store. `complete-export`
// only acknowledges: it returns `{bundleId, downloaded}` and no bytes. (This
// comment used to say complete returned the encrypted state, and the two
// classifications were swapped to match.) All four non-abort verbs are
// non-convergent: a repeat mints another bundle or re-commits.
(trust_tasks::TASK_BACKUP_INITIATE_EXPORT_1_0, KeyedSecret),
(trust_tasks::TASK_BACKUP_COMPLETE_EXPORT_1_0, Keyed),
(trust_tasks::TASK_BACKUP_INITIATE_IMPORT_1_0, Keyed),
(trust_tasks::TASK_BACKUP_FINALIZE_IMPORT_1_0, Keyed),
(trust_tasks::TASK_BACKUP_ABORT_1_0, RetrySafe),
// `chunkedTrustTask`. The 1.1 initiators mint a bundle exactly as 1.0 does.
// `initiate-export/1.1`'s chunked reply carries no bearer token, but its
// `stream` reply does, and one classification covers the URI.
(trust_tasks::TASK_BACKUP_INITIATE_EXPORT_1_1, KeyedSecret),
(trust_tasks::TASK_BACKUP_INITIATE_IMPORT_1_1, Keyed),
(trust_tasks::TASK_BACKUP_FINALIZE_IMPORT_1_1, Keyed),
// Non-consuming: a repeat returns the same bytes and at most slides the
// bundle's expiry again, within its ceiling. Not `ReadOnly` because it does
// record the index as served and extend that expiry.
(trust_tasks::TASK_BACKUP_GET_CHUNK_1_0, RetrySafe),
// Idempotent per index by construction — the manifest fixes each index's
// bytes before any arrive, so a repeat stores nothing new (`stored: false`).
(trust_tasks::TASK_BACKUP_PUT_CHUNK_1_0, RetrySafe),
// ── Attestation ─────────────────────────────────────────────────────
(trust_tasks::TASK_ATTESTATION_STATUS_1_0, ReadOnly),
(trust_tasks::TASK_ATTESTATION_REPORT_1_0, ReadOnly),
// ── Consent (DTTE) ──────────────────────────────────────────────────
// A consent request is addressed by the payload digest it binds, so a
// repeat lands on the same pending request.
(trust_tasks::TASK_CONSENT_REQUEST_1_0, RetrySafe),
(trust_tasks::TASK_CONSENT_DECISION_1_0, RetrySafe),
(trust_tasks::TASK_TASK_CONSENT_DECISION_0_1, RetrySafe),
(trust_tasks::TASK_CONSENT_REVOKE_1_0, RetrySafe),
(trust_tasks::TASK_CONSENT_LIST_1_0, ReadOnly),
(trust_tasks::TASK_CONSENT_APPROVER_SET_1_0, RetrySafe),
(trust_tasks::TASK_CONSENT_APPROVER_LIST_1_0, ReadOnly),
// ── Credentials ─────────────────────────────────────────────────────
// Every issuance mints a new credential id; a lost reply leaves one issued
// and unknown to the holder.
(trust_tasks::TASK_VTA_CREDENTIALS_ISSUE_0_2, Keyed),
(trust_tasks::TASK_VTA_CREDENTIALS_REVOKE_0_1, RetrySafe),
// A read, like `acl/list` and `policy/list`. No durable effect, nothing to
// dedup, and `status` is derived at read time — so a retry is not merely
// safe, it is the way to get a fresher answer.
(trust_tasks::TASK_VTA_CREDENTIALS_LIST_0_1, ReadOnly),
// ── Memory ──────────────────────────────────────────────────────────
(trust_tasks::TASK_VTA_MEMORY_PUT_0_1, RetrySafe),
(trust_tasks::TASK_VTA_MEMORY_LIST_0_1, ReadOnly),
// The room oracle mints a presentation and **stores nothing** — the spec's
// own `sideEffects: none`. A second execution leaves this VTA's state
// byte-identical, which is what `RetrySafe` means; it does not mean the
// call is read-only, and this one signs.
//
// Not `Keyed`, and the reasoning is worth stating because the conservative
// instinct points the other way: a retry mints a second short-lived leaf,
// and two live presentations sound worse than one. They are not. Both
// confer exactly what the first did, both expire on the same 4-hour bound,
// and a caller that lost the reply has no way to use the one it never saw.
// Keying it would buy a dedup record against a duplicate that costs
// nothing, at the price of failing a retry the caller legitimately needs.
(trust_tasks::TASK_ROOMS_KEYS_PRESENT_0_2, RetrySafe),
// Reads group state and returns plaintext. Nothing is written, and a second
// execution is indistinguishable from one.
(trust_tasks::TASK_ROOMS_KEYS_OPEN_0_1, ReadOnly),
// Minting retains a private key. A retry after a lost reply mints a SECOND
// one and leaves a second private half behind for a Welcome that will
// consume at most one — a duplicate that costs real key material, which is
// exactly the case a key exists for.
(trust_tasks::TASK_ROOMS_KEYS_KEY_PACKAGE_0_1, Keyed),
// Joining is once. A retry after a lost reply finds the group already
// present and fails `alreadyJoined` — so without a key the caller cannot
// tell "my join was lost" from "my join worked and the reply was", and the
// conservative reading strands a member who is actually in.
(trust_tasks::TASK_ROOMS_KEYS_WELCOME_0_1, Keyed),
// Idempotent by specification: a replayed commit is success with the epoch
// unchanged. That property exists precisely so delivery can retry, and
// keying it would add a dedup record to something already deduplicated by
// the epoch it names.
(trust_tasks::TASK_ROOMS_KEYS_COMMIT_0_1, RetrySafe),
// Rungs are identified by epoch and one already held is never replaced, so a
// redelivery stores nothing and reports the same reachability. A lost reply
// costs the caller the answer, never the state.
(trust_tasks::TASK_ROOMS_KEYS_CHAIN_0_1, RetrySafe),
// Idempotent by epoch on both halves: a rung already held is never replaced,
// and re-fetching from the host yields the same rungs. A retry that arrives
// after the first succeeded stores nothing and reports the same reach.
(trust_tasks::TASK_ROOMS_KEYS_BACKFILL_0_1, RetrySafe),
// A read stores nothing and changes nothing at either end: the host serves
// the same record, and this agent opens it again. What a retry costs is one
// more disclosure of the presentation to the host, which is why it is safe
// rather than free.
// `Keyed`, and the reason is that "harmless duplicate" is not quite true
// here. A second anchor supersedes the first and says the same thing, so it
// converges on state — but each attempt appends a permanent witnessed log
// entry AND rotates the room DID's update key. Both are durable artefacts
// that persist and matter, which is what `Keyed` is for.
(trust_tasks::TASK_ROOMS_OWNER_ANCHOR_0_1, Keyed),
(trust_tasks::TASK_ROOMS_KEYS_READ_0_1, RetrySafe),
// Same, over a listing. The room may have moved between attempts, so two
// tries can differ — that is the room changing, not the task misbehaving,
// and `headVersion` is what tells them apart.
(trust_tasks::TASK_ROOMS_KEYS_BROWSE_0_1, RetrySafe),
// Sealing is a pure function of the key and the bytes, and it stores nothing:
// a lost reply costs the caller a round trip, never any state. Re-sealing the
// same body yields a different nonce, which is correct and changes nothing.
(trust_tasks::TASK_ROOMS_KEYS_SEAL_0_1, RetrySafe),
(trust_tasks::TASK_ROOMS_KEYS_LIST_0_1, RetrySafe),
// Issuance mints a NEW credential with a fresh id on every call, so a retry
// without a key leaves a second durable artefact that persists and matters —
// two memberships for one member, or two grants where the owner meant one.
// `Keyed` rather than `KeyedSecret`: a room credential is presented to a host
// on every operation, so it is not secret material, and replaying the cached
// response is exactly what a caller who lost the reply wants — the same
// credential rather than another one.
(trust_tasks::TASK_ROOMS_OWNER_INVITE_0_1, Keyed),
// Convergent, unlike its `Keyed` neighbours here: the issuance verbs each
// mint a fresh credential, so a second execution leaves a second durable
// artefact — this one mints nothing. A host keys a room by its `roomId`, so
// registering the same room twice converges on the one row rather than
// creating a second.
//
// A host MAY answer the repeat as a conflict rather than a no-op, so a
// retry can report an error over a registration that in fact succeeded.
// That is a worse *message*, not a worse world, which is the distinction
// this axis is about.
(trust_tasks::TASK_ROOMS_OWNER_REGISTER_0_1, RetrySafe),
(trust_tasks::TASK_ROOMS_OWNER_ISSUE_MEMBERSHIP_0_1, Keyed),
(trust_tasks::TASK_ROOMS_OWNER_ISSUE_AUTHORITY_0_2, Keyed),
(trust_tasks::TASK_VTA_MEMORY_DELETE_0_1, RetrySafe),
// ── Application state ───────────────────────────────────────────────
//
// `put` and `put-many` are `Keyed`, and the reason is worth stating
// because a future reader will be tempted to "optimise" them.
//
// A `put` carrying `expectedVersion` genuinely converges: the replay
// fails its own precondition and one record results. But the class is
// per URI, not per payload, and a `put` WITHOUT the precondition does
// not converge — the replay writes twice and takes two values of the
// namespace counter, so every consumer watching that namespace's change
// feed sees a change that never happened. Since one URI carries both
// shapes, the conservative reading this module already prescribes
// applies: where convergence is not obvious from the contract, classify
// `Keyed`. The `expectedVersion` path simply benefits twice.
//
// `put-many` is worse still: partial application in `independent` mode
// means a blind replay's conflicts differ from the first attempt's.
//
// `delete` converges — a second delete finds the tombstone, returns
// `existed: false`, and deliberately does NOT take a new counter value,
// so a watcher sees nothing.
(trust_tasks::TASK_VTA_APP_STATE_GET_1_0, ReadOnly),
(trust_tasks::TASK_VTA_APP_STATE_PUT_1_0, Keyed),
(trust_tasks::TASK_VTA_APP_STATE_LIST_1_0, ReadOnly),
(trust_tasks::TASK_VTA_APP_STATE_DELETE_1_0, RetrySafe),
(trust_tasks::TASK_VTA_APP_STATE_GET_MANY_1_0, ReadOnly),
(trust_tasks::TASK_VTA_APP_STATE_PUT_MANY_1_0, Keyed),
// ── Policy ──────────────────────────────────────────────────────────
(trust_tasks::TASK_POLICY_LIST_0_2, ReadOnly),
(trust_tasks::TASK_POLICY_GET_0_1, ReadOnly),
(trust_tasks::TASK_POLICY_UPSERT_0_2, RetrySafe),
(trust_tasks::TASK_POLICY_DELETE_0_1, RetrySafe),
// ─── Persona slice ───────────────────────────────────────────────────
//
// Writes are `Keyed`, following the `app-state` precedent and for its
// stated reason: without a precondition a replay writes twice and bumps
// the counter twice, so a watcher sees a change that never happened. The
// `expectedVersion` path simply benefits twice.
//
// Deletes are `RetrySafe` because they converge — a repeat finds a
// tombstone, returns `existed: false`, and deliberately takes no new
// counter value. Had it taken one, delete would have had to be `Keyed`
// like the writes.
//
// Two entries are worth reading twice. `disclosure/preview` looks like a
// read and is `Keyed`: it mints a single-use token that `present`
// consumes, so a replayed preview hands out a second authorisation to
// disclose. And `disclosure/present` is `Keyed` because a replay is a
// SECOND RELEASE OF PERSONAL DATA to a third party and a second permanent
// record of it — the one task in this family where a lost reply must never
// be retried blind.
(
trust_tasks::TASK_PERSONA_ATTRIBUTE_PUT_1_0,
RetrySafety::Keyed,
),
(
trust_tasks::TASK_PERSONA_ATTRIBUTE_LIST_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_ATTRIBUTE_DELETE_1_0,
RetrySafety::RetrySafe,
),
(
trust_tasks::TASK_PERSONA_PROFILE_PUT_1_0,
RetrySafety::Keyed,
),
(
trust_tasks::TASK_PERSONA_PROFILE_GET_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_PROFILE_LIST_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_PROFILE_DELETE_1_0,
RetrySafety::RetrySafe,
),
(trust_tasks::TASK_PERSONA_FACET_PUT_1_0, RetrySafety::Keyed),
(
trust_tasks::TASK_PERSONA_FACET_LIST_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_FACET_DELETE_1_0,
RetrySafety::RetrySafe,
),
(
trust_tasks::TASK_PERSONA_BINDING_SET_1_0,
RetrySafety::Keyed,
),
(
trust_tasks::TASK_PERSONA_BINDING_GET_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_BINDING_LIST_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_CONTACT_PUT_1_0,
RetrySafety::Keyed,
),
(
trust_tasks::TASK_PERSONA_CONTACT_GET_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_CONTACT_LIST_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_CONTACT_DELETE_1_0,
RetrySafety::RetrySafe,
),
(
trust_tasks::TASK_PERSONA_DISCLOSURE_PREVIEW_1_0,
RetrySafety::Keyed,
),
(
trust_tasks::TASK_PERSONA_DISCLOSURE_PRESENT_1_0,
RetrySafety::Keyed,
),
(
trust_tasks::TASK_PERSONA_DISCLOSURE_HISTORY_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_CORRELATION_ANALYZE_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_RENDERERS_LIST_1_0,
RetrySafety::ReadOnly,
),
// A compile-time table describing the agent's vocabulary. Reads nothing,
// writes nothing, and returns the same answer to every caller.
(
trust_tasks::TASK_PERSONA_CLAIM_TYPES_LIST_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_LOCAL_PROFILE_PUT_1_0,
RetrySafety::Keyed,
),
(
trust_tasks::TASK_PERSONA_LOCAL_PROFILE_GET_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_LOCAL_PROFILE_LIST_1_0,
RetrySafety::ReadOnly,
),
(
trust_tasks::TASK_PERSONA_LOCAL_PROFILE_DELETE_1_0,
RetrySafety::RetrySafe,
),
(
trust_tasks::TASK_PERSONA_LOCAL_BINDING_SET_1_0,
RetrySafety::Keyed,
),
];
/// The retry-safety class of `uri`, or `None` if it is not a task this VTA
/// serves.
///
/// `None` means "unknown task", never "safe" — a caller deciding whether to
/// retry should treat it as [`Keyed`](RetrySafety::Keyed) and supply a key. The
/// census test guarantees `None` cannot mean "we forgot to classify it".
pub fn retry_safety(uri: &str) -> Option<RetrySafety> {
RETRY_SAFETY
.iter()
.find(|(u, _)| *u == uri)
.map(|(_, s)| *s)
}
/// Every task that needs an idempotency key, in catalog order. Handy for
/// operator tooling and for the VTA's own dispatch-side table.
pub fn keyed_uris() -> Vec<&'static str> {
RETRY_SAFETY
.iter()
.filter(|(_, s)| s.needs_key())
.map(|(u, _)| *u)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// The census. A task cannot join the catalog without someone deciding what
/// a lost reply costs it — which is the entire value of the table.
#[test]
fn every_uri_is_classified() {
let classified: HashSet<&str> = RETRY_SAFETY.iter().map(|(u, _)| *u).collect();
let missing: Vec<_> = trust_tasks::ALL_URIS
.iter()
.filter(|u| !classified.contains(*u))
.collect();
assert!(
missing.is_empty(),
"these tasks have no retry-safety classification — add them to \
RETRY_SAFETY (when unsure, `Keyed` is the conservative answer): {missing:#?}"
);
}
/// The other direction: a stale entry for a URI the catalog dropped is dead
/// weight that reads like coverage.
#[test]
fn no_classification_without_a_task() {
let catalog: HashSet<&str> = trust_tasks::ALL_URIS.iter().copied().collect();
let orphans: Vec<_> = RETRY_SAFETY
.iter()
.map(|(u, _)| *u)
.filter(|u| !catalog.contains(u))
.collect();
assert!(
orphans.is_empty(),
"classified URIs that are no longer in ALL_URIS: {orphans:#?}"
);
}
#[test]
fn no_duplicate_entries() {
let mut seen = HashSet::new();
for (u, _) in RETRY_SAFETY {
assert!(seen.insert(*u), "duplicate classification for {u}");
}
}
/// The two questions a retry layer actually asks, kept consistent: anything
/// that needs a key is by definition not safe to blind-retry, and vice versa.
#[test]
fn needs_key_and_blind_retry_safe_partition_the_space() {
for class in [
RetrySafety::ReadOnly,
RetrySafety::RetrySafe,
RetrySafety::Keyed,
RetrySafety::KeyedSecret,
] {
assert_ne!(
class.is_blind_retry_safe(),
class.needs_key(),
"{class:?} is both or neither"
);
}
}
#[test]
fn secret_bearing_responses_are_never_replayable() {
assert!(!RetrySafety::KeyedSecret.response_is_replayable());
assert!(RetrySafety::Keyed.response_is_replayable());
}
/// The findings that opened the issue, pinned so a later edit cannot quietly
/// downgrade them.
#[test]
fn the_operations_that_prompted_this_are_keyed() {
for uri in [
trust_tasks::TASK_WEBVH_DIDS_CREATE_1_0,
trust_tasks::TASK_KEYS_CREATE_0_1,
trust_tasks::TASK_CONTEXTS_CREATE_1_0,
trust_tasks::TASK_ACL_SWAP_KEY_0_1,
] {
assert_eq!(
retry_safety(uri).map(|s| s.needs_key()),
Some(true),
"{uri} must stay keyed"
);
}
assert_eq!(
retry_safety(trust_tasks::TASK_PROVISION_INTEGRATION_0_3),
Some(RetrySafety::KeyedSecret)
);
}
/// Deliberately *not* a `trusttasks.org/spec/` URI. The workspace manifest
/// test treats every bound `spec/` URI as an assertion that the upstream
/// registry publishes it, so a plausible-looking fake here fails that check
/// rather than this one.
#[test]
fn unknown_uris_are_none() {
assert_eq!(retry_safety("https://example.invalid/not-a-task/0.1"), None);
}
#[test]
fn keyed_uris_are_a_subset_of_the_catalog() {
let catalog: HashSet<&str> = trust_tasks::ALL_URIS.iter().copied().collect();
let keyed = keyed_uris();
assert!(!keyed.is_empty());
for u in keyed {
assert!(catalog.contains(u), "{u} not in ALL_URIS");
}
}
}