vta-sdk 0.49.0

SDK for Verifiable Trust Agents operating in Verifiable Trust Communities
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
//! 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,
    /// Answers to the manifest's `requestedAttributes`: what the applicant
    /// tells the community about themselves. Self-asserted, bound to the
    /// applicant by the document proof. Omitted when empty.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub attributes: Vec<JoinRequestAttribute>,
}

/// One answer to a requested attribute on
/// `vtc/join-requests/submit/0.2` — a claim type and the value the applicant
/// gives for it.
///
/// No `deny_unknown_fields`, and no `ext` either, because the published item
/// is closed and defines neither: an `ext` here would let a client send a
/// member the schema refuses. The dispatcher validates the whole payload
/// against that schema before this type is read, and that is what refuses an
/// extra member.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct JoinRequestAttribute {
    #[serde(rename = "type")]
    pub claim_type: String,
    pub value: 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)
// ---------------------------------------------------------------------------

/// The published `vtc/join-requests/manifest` types, generated from the
/// specification: [`manifest::v0_1`] (criteria) and [`manifest::v0_2`]
/// (criteria with their vetting requirements and `requirementsDigest`, and the
/// community's branding). Their `Payload` and `Response` are the manifest's wire
/// types; nothing in this crate restates them.
///
/// `manifest::v0_2::CommunityBranding` is also the body of the VTC's admin
/// `GET`/`PUT /v1/community/branding`: what an admin stores is exactly what the
/// manifest publishes.
pub use trust_tasks_rs::specs::vtc::join_requests::manifest;

/// The generated `vtc/join-requests/withdraw` module.
///
/// Re-exported rather than mirrored by hand: the payload has a generated module
/// (`trust-tasks-rs` 0.21.5, from spec PR #518), and the workspace rule is that
/// a task with one never gets a local copy — `vta-sdk`'s
/// `generated_wire_types_census` enforces it. `SelfRemoveBody` and its
/// neighbours below are hand-written because they predate their generated
/// modules; that list only shrinks.
pub use trust_tasks_rs::specs::vtc::join_requests::withdraw;

/// Trust Task `type` for withdrawing a join request: the applicant closes
/// their own open request.
///
/// Taken from the generated type rather than written as a literal, so the
/// constant cannot drift from the spec that defines it.
pub const JOIN_REQUEST_WITHDRAW_TYPE: &str =
    <withdraw::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;

/// Trust Task `type` for the withdraw response.
pub const JOIN_REQUEST_WITHDRAW_RESPONSE_TYPE: &str =
    <withdraw::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;

/// The generated `vtc/join-requests/supplement` module.
///
/// Re-exported rather than mirrored by hand, for the same reason
/// [`withdraw`] is: the task has a generated module (`trust-tasks-rs` 0.21.6,
/// from spec PR #526).
pub use trust_tasks_rs::specs::vtc::join_requests::supplement;

/// Trust Task `type` for supplementing a join request: the applicant answers a
/// community's request for more evidence against the request they already have
/// open, rather than opening a second one.
pub const JOIN_REQUEST_SUPPLEMENT_TYPE: &str =
    <supplement::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;

/// Trust Task `type` for the supplement response. The payload is the same
/// `{requestId, verdict}` [`VerdictResponse`] a submission returns — a
/// supplement has exactly the outcomes a submission has.
pub const JOIN_REQUEST_SUPPLEMENT_RESPONSE_TYPE: &str =
    <supplement::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;

/// Extended error code: the caller has no open request to supplement.
///
/// Also the answer when a supplied `requestId` names somebody else's request —
/// conflated deliberately, so the task cannot be used to probe which request
/// ids exist on this community.
pub const JOIN_REQUEST_SUPPLEMENT_ERR_NOT_FOUND: &str =
    supplement::v0_1::error_codes::NOT_FOUND.code;

/// Extended error code: the request is open, but the community has asked this
/// applicant for nothing — it is queued for a decision the community owes.
///
/// Distinct from [`JOIN_REQUEST_SUPPLEMENT_ERR_NOT_FOUND`] because the request
/// does exist and is theirs; the applicant simply has no question to answer
/// yet. Accepting evidence into it would replace what a maintainer is
/// mid-review on.
pub const JOIN_REQUEST_SUPPLEMENT_ERR_NOT_AWAITING_EVIDENCE: &str =
    supplement::v0_1::error_codes::NOT_AWAITING_EVIDENCE.code;

/// Extended error code: the request has been approved, rejected or withdrawn,
/// so there is no open decision left to supplement.
pub const JOIN_REQUEST_SUPPLEMENT_ERR_ALREADY_DECIDED: &str =
    supplement::v0_1::error_codes::ALREADY_DECIDED.code;

/// Extended error code: the applicant already has an open request, so this
/// submit is a duplicate rather than a new application.
///
/// Consumer-minted, not declared by `vtc/join-requests/submit` — SPEC.md §8.5
/// permits a consumer to mint a namespaced code for an invariant the spec did
/// not enumerate, and the namespace is the slug of the request being
/// processed. A client that does not recognise it treats the error as
/// `taskFailed` by the same rule, so adding it breaks no existing caller.
///
/// The error carries a `details` annex of `{requestId, status}`: `status` is
/// what tells the applicant whether the open request is waiting on the
/// community (`pending`) or on them (`deferred`), and `requestId` is what they
/// pass to [`JOIN_REQUEST_WITHDRAW_TYPE`] to clear it.
///
/// The one literal left in this file: because the specification does not
/// declare it, there is no generated `error_codes` constant to read. The VTC's
/// error-code census lists it as consumer-minted and fails the day the
/// specification declares it, asking for the generated constant instead.
pub const JOIN_REQUEST_SUBMIT_ERR_REQUEST_ALREADY_OPEN: &str =
    "vtc/join-requests/submit:requestAlreadyOpen";

/// `vtc/join-requests/submit:attributesMissing` — a required requested
/// attribute was not answered. `details.types` names them.
pub const JOIN_REQUEST_SUBMIT_ERR_ATTRIBUTES_MISSING: &str =
    trust_tasks_rs::specs::vtc::join_requests::submit::v0_2::error_codes::ATTRIBUTES_MISSING.code;

/// `vtc/join-requests/submit:attributesUnrequested` — an answer named a type
/// the manifest does not request; refused, not stored. `details.types` names
/// them.
pub const JOIN_REQUEST_SUBMIT_ERR_ATTRIBUTES_UNREQUESTED: &str =
    trust_tasks_rs::specs::vtc::join_requests::submit::v0_2::error_codes::ATTRIBUTES_UNREQUESTED
        .code;

/// Extended error code: the caller has no open request to withdraw.
///
/// Also the answer when a supplied `requestId` names a request belonging to
/// somebody else. The spec conflates the two deliberately — distinguishing
/// them would let a caller probe whether a given id exists on this community —
/// and the code's own registry entry records that reasoning.
pub const JOIN_REQUEST_WITHDRAW_ERR_NOT_FOUND: &str = withdraw::v0_1::error_codes::NOT_FOUND.code;

/// Extended error code: the request has already been approved, rejected or
/// withdrawn, so there is nothing left to close.
///
/// Distinct from [`JOIN_REQUEST_WITHDRAW_ERR_NOT_FOUND`] because an applicant
/// *is* entitled to the outcome of their own request, and because no retry
/// will change it.
pub const JOIN_REQUEST_WITHDRAW_ERR_ALREADY_DECIDED: &str =
    withdraw::v0_1::error_codes::ALREADY_DECIDED.code;

/// 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 =
    <manifest::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;

/// `#response` variant of [`JOIN_REQUEST_MANIFEST_TYPE`] — carries a
/// [`manifest::v0_1::Response`].
pub const JOIN_REQUEST_MANIFEST_RESPONSE_TYPE: &str =
    <manifest::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;

/// Manifest 0.2: 0.1 plus an optional `vetting` requirements object and a
/// `requirementsDigest` on each criterion, and the community's `branding`. A
/// community answers both versions; a 0.1 reader simply does not see the new
/// members.
pub const JOIN_REQUEST_MANIFEST_0_2_TYPE: &str =
    <manifest::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;

/// `#response` variant of [`JOIN_REQUEST_MANIFEST_0_2_TYPE`] — carries a
/// [`manifest::v0_2::Response`].
pub const JOIN_REQUEST_MANIFEST_0_2_RESPONSE_TYPE: &str =
    <manifest::v0_2::Response as trust_tasks_rs::Payload>::TYPE_URI;

// ---------------------------------------------------------------------------
// 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,
}