vta-sdk 0.29.0

SDK for Verifiable Trust Agents operating in Verifiable Trust Communities
Documentation
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
//! Trust Task wire types for the VTC join-request ceremony family.
//!
//! Each body below is a [`trust_tasks_rs::TrustTask`] **payload**; the
//! document `type` is one of the `JOIN_REQUEST_*_TYPE` URIs. The success
//! reply is a framework `#response` document carrying a [`VerdictResponse`]
//! (the `request`/`submit` verb) or a read body (manifest/status);
//! failures are framework `trust-task-error` documents, never DIDComm
//! problem-reports.
//!
//! ## URI shape (canonical registry)
//!
//! ```text
//! https://trusttasks.org/spec/vtc/join-requests/{verb}/{maj}.{min}
//! ```
//!
//! These moved off the private `trusttasks.org/openvtc/vtc` authority
//! (SPEC §6.5) and onto the canonical registry, where the ceremony's
//! tasks are published as `spec/vtc/join-requests/*`. The path keeps the
//! `/spec/<slug>/<maj.min>` shape the framework requires, so the URIs
//! still parse as [`trust_tasks_rs::TypeUri`].
//!
//! The two `*_RECEIPT_TYPE` consts stay on the old authority on purpose:
//! a receipt is a fire-and-forget ack rather than a Trust Task response,
//! and the registry publishes no receipt task — emitting a canonical URI
//! for one would name a spec that does not exist. The success-response
//! variant is the same URI with a `#response` fragment (the
//! `*_RESPONSE_TYPE` consts), minted by `TrustTask::respond_with`.
//!
//! ## Transports
//!
//! - **REST**: the request body is the TrustTask document; routing is by
//!   the document `type`. The holder identity is the document `issuer` +
//!   `proof`; replay binding is `recipient` (= the VTC DID) + `expiresAt`.
//! - **DIDComm**: the message `type` IS the Trust Task URL and the message
//!   body is the TrustTask document; the authcrypt sender authenticates
//!   the holder (no separate signature needed).

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use uuid::Uuid;

/// Trust Task `type` for a join-request submission (the ceremony `request`
/// verb). Payload [`JoinRequestSubmitBody`]; response [`VerdictResponse`].
pub const JOIN_REQUEST_SUBMIT_TYPE: &str =
    "https://trusttasks.org/spec/vtc/join-requests/submit/0.2";

/// `#response` variant of [`JOIN_REQUEST_SUBMIT_TYPE`] — the type of the
/// success document `respond_with` mints; carries a [`VerdictResponse`].
pub const JOIN_REQUEST_SUBMIT_RESPONSE_TYPE: &str =
    "https://trusttasks.org/spec/vtc/join-requests/submit/0.2#response";

/// Reply `type` used by the **credential-exchange** join close-the-loop
/// (`credential-exchange/present`), which results in a join and echoes this
/// receipt. Distinct from the Trust Task `submit` conversion above; retained
/// for that not-yet-converted path. Carries a [`JoinRequestSubmitReceiptBody`].
/// Async acknowledgement sent by the VTC after a submit. Deliberately
/// still on the `openvtc/vtc/` authority: the canonical registry has no
/// receipt task — the ceremony's canonical surface is
/// submit/status/manifest plus their `#response` forms, and a
/// receipt is a separate fire-and-forget ack, not a Trust Task response.
/// Inventing `spec/vtc/join-requests/submit-receipt` here would mean
/// emitting a URI that resolves to nothing in the registry.
pub const JOIN_REQUEST_SUBMIT_RECEIPT_TYPE: &str =
    "https://trusttasks.org/spec/vtc/join-requests/submit-receipt/0.1";

/// Body of the credential-exchange join receipt (see
/// [`JOIN_REQUEST_SUBMIT_RECEIPT_TYPE`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct JoinRequestSubmitReceiptBody {
    pub request_id: Uuid,
    /// Status string — e.g. `"pending"` / `"approved"`.
    pub status: String,
}

/// Payload of the submit document. The applicant DID is the TrustTask
/// `issuer` (REST: document proof; DIDComm: authcrypt sender), not a body
/// field.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct JoinRequestSubmitBody {
    pub vp: JsonValue,
    #[serde(default)]
    pub registry_consent: bool,
    /// Omitted rather than sent as `null` when unset. The payload schema
    /// types this `object`, so a `null` is a type error and not an empty
    /// value; `extensions` is not required, so absent is how a client says
    /// it has none. A bare `#[serde(default)]` `JsonValue` serialises
    /// `Value::Null`, which is what a minimal client sent until #1099 — the
    /// same null-into-`Option` class that shipped `keys/create/0.1` broken.
    #[serde(default, skip_serializing_if = "JsonValue::is_null")]
    pub extensions: JsonValue,
}

// ---------------------------------------------------------------------------
// (Accept is gone. `join-requests/accept/0.1` was retired upstream, superseded
// by `vtc/members/vmc/0.1` with an optional `requestId` — the member closes
// their approved join by delivering their reciprocal VMC through the one
// credential-delivery path. See `protocols::members::MemberVmcBody`.)
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Manifest — pre-submit discovery (join-requests/manifest/1.0)
// ---------------------------------------------------------------------------

/// Trust Task `type` for a join-request manifest request: discover the
/// community's join evidence requirements. Public read; empty payload.
pub const JOIN_REQUEST_MANIFEST_TYPE: &str =
    "https://trusttasks.org/spec/vtc/join-requests/manifest/0.1";

/// `#response` variant of [`JOIN_REQUEST_MANIFEST_TYPE`] — carries a
/// [`JoinRequestManifestResponseBody`].
pub const JOIN_REQUEST_MANIFEST_RESPONSE_TYPE: &str =
    "https://trusttasks.org/spec/vtc/join-requests/manifest/0.1#response";

/// One community evidence requirement — a named DCQL Presentation
/// Definition the applicant may present against.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ManifestCriterion {
    pub id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub presentation_definition: JsonValue,
}

/// Manifest response: the community's join evidence requirements.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct JoinRequestManifestResponseBody {
    pub community_did: String,
    pub criteria: Vec<ManifestCriterion>,
}

// ---------------------------------------------------------------------------
// Status — applicant poll (join-requests/status/1.0)
// ---------------------------------------------------------------------------

/// Trust Task `type` for an applicant status poll. The applicant DID is
/// the TrustTask `issuer` (DIDComm authcrypt sender / REST document proof).
pub const JOIN_REQUEST_STATUS_TYPE: &str =
    "https://trusttasks.org/spec/vtc/join-requests/status/0.1";

/// `#response` variant of [`JOIN_REQUEST_STATUS_TYPE`] — carries a
/// [`JoinRequestStatusResponseBody`].
pub const JOIN_REQUEST_STATUS_RESPONSE_TYPE: &str =
    "https://trusttasks.org/spec/vtc/join-requests/status/0.1#response";

/// Body of the status message (DIDComm). Over REST the `{id}` is the
/// path segment; over DIDComm it travels here.
///
/// `request_id` is **optional**: omit it to ask "what is my open request?"
/// and the community resolves it from the authenticated applicant.
///
/// That is not a convenience. The id an applicant polls with is the
/// community's, minted on submit and learned from the first correlated
/// reply — so an applicant whose reply was lost holds only its own
/// placeholder and cannot name the request at all. Requiring the id made
/// this poll unusable in exactly the case it exists for: a join whose
/// answer went missing. The response always carries `request_id`, so one
/// id-less poll also repairs the applicant's record for every poll after.
///
/// At most one request per applicant is open at a time (the submit
/// dedup), so "my open request" is unambiguous.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct JoinRequestStatusBody {
    /// The community's request id, when the applicant knows it. `None`
    /// asks the community to resolve the applicant's open request.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub request_id: Option<Uuid>,
}

/// Status response: the request's lifecycle, plus (when `deferred`) what
/// the applicant must present next.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct JoinRequestStatusResponseBody {
    pub request_id: Uuid,
    /// `pending` | `deferred` | `approved` | `rejected` | `withdrawn`.
    pub status: String,
    /// Outstanding requirements — present only for a `deferred` request.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub needs: Vec<String>,
    /// The DCQL the applicant should answer over `present` — present only
    /// for a `deferred` request.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub presentation_definition: Option<JsonValue>,
    /// Stable refusal code — present only for a `rejected` request.
    ///
    /// A policy auto-deny carries the `code` the `join.rego` `deny` verdict
    /// returned; an admin reject carries [`ADMIN_REJECT_CODE`], since there is
    /// no policy verdict to source one from.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
    /// Human-readable elaboration of the refusal — present only for a
    /// `rejected` request, and only when the decider supplied one (the policy's
    /// optional `reason`, or the operator's words on an admin reject).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    /// When the decision was taken.
    ///
    /// Deliberately **not** the response document's `issuedAt`, which is when
    /// *this* document was produced. For an admin reject the two diverge
    /// arbitrarily — the applicant may poll days after the decision — so the
    /// decision's own timestamp has to travel with it.
    ///
    /// `None` on a request decided before this field existed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub decided_at: Option<DateTime<Utc>>,
}

/// The refusal code on the **admin** reject path.
///
/// A policy auto-deny sources its code from the `deny` verdict `join.rego`
/// returned. An admin reject has no verdict behind it — the decision is the
/// operator's — so the code is this constant and the operator's words ride in
/// `reason`. Clients switch on it to tell "the community's rules refused you"
/// (retry once you satisfy them) from "a human refused you" (retrying changes
/// nothing).
pub const ADMIN_REJECT_CODE: &str = "admin-reject";

// ---------------------------------------------------------------------------
// Verdict — the shared `request`/`present` response envelope
// (docs/05-design-notes/vtc-ceremony-protocol.md §3)
// ---------------------------------------------------------------------------

/// The policy effect of a ceremony decision. A `deny` means the policy
/// **refused** a well-formed, verified request — it is NOT how framework
/// errors (invalid VIC, expired, malformed) are signalled; those are
/// `trust-task-error` documents (see the module docs).
/// Serialises **lowerCamelCase** — `requestMore`, not `request_more` — because
/// a verdict effect is a specification-defined decision value, which
/// SPEC §4.10 rule 4 says is lowerCamelCase, and
/// `vtc/_shared/0.1/ceremony#VerdictEffect` enumerates it that way.
///
/// The snake_case form is kept as a deserialisation alias on purpose. Rego is
/// where verdicts are *authored* — operator-written policy returns
/// `{"effect": "request_more", …}` — and snake_case is that language's idiom,
/// not a mistake to correct. The two vocabularies are allowed to differ; this
/// type is the boundary where the policy's spelling becomes the wire's, the
/// same split `EndorsementRow` and `GenerationRow` draw between storage and
/// wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum VerdictEffect {
    /// Admitted. `with` carries the granted role + obligations and the
    /// host-added `bundleRef` (sealed credential pointer) where issuance
    /// occurred.
    Allow,
    /// Policy refused. `with` carries `code` + `reason`.
    Deny,
    /// Parked for a human/quorum decision. `with` carries `queue` + `reason`.
    Refer,
    /// More evidence required. `with` carries `needs` +
    /// `presentationDefinition`; the applicant continues over `present`.
    #[serde(alias = "request_more")]
    RequestMore,
}

/// The effect-dependent detail of a [`Verdict`]. Modelled as one flat
/// struct with optional fields (rather than an enum) so the wire object is
/// stable and additive across ceremonies; only the fields relevant to the
/// `effect` are populated.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct VerdictWith {
    // effect = allow
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub obligations: Option<JsonValue>,
    /// The issued Verifiable Membership Credential, returned inline on an
    /// auto-admit `allow` over REST (over DIDComm it is delivered in a
    /// follow-up message and this is omitted).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vmc: Option<JsonValue>,
    /// The issued role VEC — same delivery story as [`Self::vmc`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role_vec: Option<JsonValue>,
    /// Sealed-transfer pointer to the issued credential bundle — added by
    /// the host on issuing ceremonies that seal, not emitted by the policy.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bundle_ref: Option<JsonValue>,
    // effect = deny
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    // effect = refer
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub queue: Option<String>,
    // effect = request_more
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub needs: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub presentation_definition: Option<JsonValue>,
}

/// A ceremony decision: the policy `effect` plus its effect-dependent
/// detail. Shared by every `request`/`present` response across ceremony
/// families.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Verdict {
    pub effect: VerdictEffect,
    pub with: VerdictWith,
}

/// The `request`/`present` success-response **payload** (rides inside the
/// `#response` TrustTask document, whose `threadId` carries the ceremony
/// thread). `requestId` is the persisted `JoinRequest` id.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct VerdictResponse {
    pub request_id: Uuid,
    pub verdict: Verdict,
}

impl VerdictResponse {
    /// `allow` verdict for an auto-admitted applicant, with the issued
    /// credentials returned inline (REST) or omitted (DIDComm follow-up).
    pub fn allow(
        request_id: Uuid,
        role: Option<String>,
        vmc: Option<JsonValue>,
        role_vec: Option<JsonValue>,
    ) -> Self {
        Self {
            request_id,
            verdict: Verdict {
                effect: VerdictEffect::Allow,
                with: VerdictWith {
                    role,
                    vmc,
                    role_vec,
                    ..Default::default()
                },
            },
        }
    }

    /// `deny` verdict — the policy refused a well-formed, verified request.
    ///
    /// `code` is the policy's stable refusal code; `reason` its optional
    /// elaboration. A constructor rather than a hand-assembled struct because
    /// this shape now has two producers — the correlated ceremony reply and the
    /// status poll's projection — and hand-assembling it in both is how they
    /// drift.
    pub fn deny(request_id: Uuid, code: impl Into<String>, reason: Option<String>) -> Self {
        Self {
            request_id,
            verdict: Verdict {
                effect: VerdictEffect::Deny,
                with: VerdictWith {
                    code: Some(code.into()),
                    reason,
                    ..Default::default()
                },
            },
        }
    }

    /// `refer` verdict — the request was persisted `Pending` for a human
    /// admin decision (`approve`/`reject`).
    pub fn refer(request_id: Uuid, queue: impl Into<String>, reason: impl Into<String>) -> Self {
        Self {
            request_id,
            verdict: Verdict {
                effect: VerdictEffect::Refer,
                with: VerdictWith {
                    queue: Some(queue.into()),
                    reason: Some(reason.into()),
                    ..Default::default()
                },
            },
        }
    }
}

// ---------------------------------------------------------------------------
// Self-remove (M1.11.1 DIDComm twin)
// ---------------------------------------------------------------------------

/// DIDComm `type` for a member-side self-removal.
pub const MEMBER_SELF_REMOVE_TYPE: &str = "https://trusttasks.org/spec/vtc/members/self-remove/0.1";

/// VTC's reply with the resolved disposition + audit hint.
/// Async acknowledgement for a self-remove. Stays on `openvtc/vtc/`
/// for the same reason as [`JOIN_REQUEST_SUBMIT_RECEIPT_TYPE`] — no
/// canonical receipt task exists.
pub const MEMBER_SELF_REMOVE_RECEIPT_TYPE: &str =
    "https://trusttasks.org/spec/vtc/members/self-remove-receipt/0.1";

/// Body of the self-remove message. `did` comes from the DIDComm
/// `from` field — the caller's authcrypt sender authenticates
/// them. Disposition optional; falls back to the Member's stored
/// `departure_preference` and then to PolicyDefault→Tombstone.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct SelfRemoveBody {
    /// Absent when unset, never `null`. `vtc/members/self-remove/0.1` types
    /// `disposition` as a `"string"` constrained to the disposition enum, so a
    /// serialized `None` is rejected as malformed instead of falling through to
    /// the stored preference the way an omitted member does.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub disposition: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct SelfRemoveReceiptBody {
    pub did: String,
    /// Resolved disposition (`"purge"` | `"tombstone"` |
    /// `"historical"`). `policydefault` is never returned —
    /// the daemon resolves it before responding.
    pub disposition: String,
    pub removed: bool,
}