vtc-service 0.12.0

Service for 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
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
// Helpers return an owned `TrustTaskOutcome` (status + serialised document
// bytes) rather than the large `Result<_, Response>` the VTA uses — the
// transport adapters render it for REST or DIDComm.
#![allow(clippy::result_large_err)]

//! Shared helpers for the VTC join-request Trust Task dispatcher.
//!
//! Mirrors `vta-service/src/trust_tasks/helpers.rs`:
//! - `TrustTaskOutcome` — the transport-neutral dispatch result.
//! - `parse_payload<T>` — typed payload extraction (→ `MalformedRequest`).
//! - `success_response` / `verdict_response` — `#response` document
//!   construction via `TrustTask::respond_with`.
//! - `reject_with` / `app_error_to_reject` / `error_response` —
//!   `trust-task-error` document construction (the framework reject path).
//! - `body_parse_error_response` — unrouted reject for a body that is not a
//!   Trust Task document at all.
//! - `verify_trust_task_proof` — the holder's `eddsa-jcs-2022` DI proof
//!   verifier for the REST path (an adapter over the shared
//!   `vti_common::auth`, re-exported from `vta_sdk::trust_task_proof` so a client
//!   verifies its replies with the same code a service verifies its requests).

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use serde_json::Value;
use trust_tasks_https::status_for_code;
use trust_tasks_rs::{
    ErrorPayload, ErrorResponse, RejectReason, TrustTask, TrustTaskCode, TypeUri,
};
use uuid::Uuid;
use vta_sdk::protocols::trust_task_reject_reasons as reasons;
use vti_common::error::AppError;

use crate::server::AppState;

use vta_sdk::protocols::join_requests::VerdictResponse;

/// The transport-neutral result of dispatching a Trust Task: the framework
/// HTTP status code plus the serialised result/error document bytes.
///
/// Both transports render from this one value — the REST route turns it into
/// an `axum::Response` via [`IntoResponse`]; the DIDComm handler reads
/// [`body`](Self::body) straight as the reply envelope. The body stays raw
/// bytes (not a `serde_json::Value`) so the wire output is byte-identical to
/// direct document serialisation (serde_json has no `preserve_order` here, so
/// a `Value` round-trip would alphabetise object keys).
#[derive(Debug)]
pub(crate) struct TrustTaskOutcome {
    pub(crate) status: StatusCode,
    pub(crate) body: Vec<u8>,
}

impl IntoResponse for TrustTaskOutcome {
    fn into_response(self) -> Response {
        (
            self.status,
            [(axum::http::header::CONTENT_TYPE, "application/json")],
            self.body,
        )
            .into_response()
    }
}

/// Parse a Trust Task document's `payload` field as the typed body `T`, or
/// return a `MalformedRequest` rejection response.
pub(crate) fn parse_payload<T: serde::de::DeserializeOwned>(
    doc: &TrustTask<Value>,
) -> Result<T, TrustTaskOutcome> {
    serde_json::from_value::<T>(doc.payload.clone()).map_err(|e| {
        reject_with(
            doc,
            RejectReason::MalformedRequest {
                reason: format!("payload parse: {e}"),
            },
        )
    })
}

/// Map an `AppError` into a routed Trust Task error response with the
/// appropriate framework reject code — the same taxonomy the VTA uses, and
/// the same 4xx distinction the VTC's REST boundary preserves:
///
/// - `Authentication` / `Unauthorized` / `Forbidden` / `StepUpRequired` →
///   `permission_denied`
/// - `Validation` / `TrustTaskMalformed` / `TrustTaskMissing` /
///   `InvalidCursor` → `malformed_request`
/// - `NotFound` / `Conflict` / `IdempotencyKeyConflict` / `Gone` →
///   `task_failed`, each discriminated by a `details.reason` from
///   [`vta_sdk::protocols::trust_task_reject_reasons`] so the client recovers
///   the typed variant
/// - everything else → `internal_error`, with the cause logged and **not**
///   sent
pub(crate) fn app_error_to_reject<P>(doc: &TrustTask<P>, err: &AppError) -> TrustTaskOutcome {
    // Not a refusal at all: the operation was parked for other administrators'
    // approval and will complete itself (VTI-APV-017). Every gate's caller
    // already propagates its outcome as an `AppError`, so the parked answer
    // rides the same path and is rendered here, once.
    if let AppError::ApprovalRequired { code, details } = err
        && *code == crate::admin_actions::ACTION_PARKED
    {
        return next_step_response(doc, details);
    }
    let message = err.to_string();
    let reason = match err {
        AppError::Authentication(_)
        | AppError::Unauthorized(_)
        | AppError::Forbidden(_)
        | AppError::StepUpRequired(_) => RejectReason::PermissionDenied { reason: message },
        AppError::Validation(_)
        | AppError::TrustTaskMalformed(_)
        | AppError::TrustTaskMissing
        | AppError::InvalidCursor => RejectReason::MalformedRequest { reason: message },
        // These have no standard code of their own — §8.3 defines no
        // `notFound` / `conflict` / `gone` — so all of them ride out under
        // `taskFailed`. That is the correct wire code and it is not enough on
        // its own: a caller cannot tell "the row you asked for is absent",
        // very often a *normal* state it knows how to handle, from "this
        // operation genuinely failed", and the distinction that REST keeps in
        // an HTTP status is simply lost.
        //
        // So the discriminator goes in `details.reason`, which is what
        // `VtaClient::trust_task_error` reads back into the same typed
        // `VtaError` variant the REST and problem-report paths produce. The
        // VTA has done this since its own gate went in; the VTC never did, so
        // every `NotFound`, `Conflict` and `Gone` this service produced
        // reached a Trust Task client as an opaque `Protocol(String)` — the
        // exact collapse `CLAUDE.md` names ("never collapse a Conflict into a
        // string"), and the same defect #1602 fixed from the client end for
        // the one code whose local part happens to be `notFound`.
        //
        // `Gone` is a terminal caller-visible outcome, not a server fault, so
        // it stays out of the `internal_error` fallback, which would tell the
        // client to retry a permanently-consumed resource.
        AppError::NotFound(_) => task_failed_because(message, reasons::NOT_FOUND),
        AppError::Conflict(_) | AppError::IdempotencyKeyConflict => {
            task_failed_because(message, reasons::CONFLICT)
        }
        AppError::Gone(_) => task_failed_because(message, reasons::GONE),
        // A decision the caller can still obtain — another party's consent. The
        // VTA's gate sends this as `taskFailed` with the reason in `details`,
        // which is what `VtaClient` reads back into `VtaError::ConsentRequired`,
        // so this door says it the same way (VTI-APV-002).
        AppError::ApprovalRequired { code, details } => {
            let mut details = details.clone();
            if let Some(map) = details.as_object_mut() {
                map.insert("reason".into(), Value::String((*code).to_string()));
            }
            RejectReason::TaskFailed {
                reason: (*code).to_string(),
                details: Some(details),
            }
        }
        // Framework 0.5.0, *What a `message` May Not Say*: a `message` MUST
        // NOT reveal consumer-internal state. Passing `err.to_string()` out
        // sent the cause verbatim — "vtc_did not configured",
        // "audit_writer not initialised", a fjall or serde failure — which
        // tells an unauthenticated caller the deployment's shape and which
        // internal invariant just broke.
        //
        // The producer needs one fact from an `internalError`: the failure was
        // not its document's doing, so re-sending may work. The cause is what
        // the *operator* needs, and it goes to the log where the operator is.
        // Every other arm above describes the caller's own request back to it,
        // which is not consumer-internal state, and passes through unchanged.
        other => {
            tracing::error!(cause = %other, "trust task failed with an internal error");
            RejectReason::InternalError {
                reason: OPAQUE_INTERNAL_ERROR.to_string(),
            }
        }
    };
    reject_with(doc, reason)
}

/// Map a [`TaskError`](crate::error::TaskError) into a routed rejection.
///
/// An undeclared error goes through [`app_error_to_reject`] unchanged. A
/// declared one goes out under its task's extended code (SPEC §8.5) — the
/// thing the generic mapping cannot do, and the reason `withdraw` (#1591) and
/// `supplement` (#1593) each grew a hand-written arm. A `NotFound` /
/// `Conflict` / `Gone` keeps its `details.reason` marker beside the code, so a
/// client that does not know the code still recovers the typed variant.
pub(crate) fn task_error_to_reject<P>(
    doc: &TrustTask<P>,
    err: &crate::error::TaskError,
) -> TrustTaskOutcome {
    use crate::error::TaskError;
    match err {
        TaskError::App(e) => app_error_to_reject(doc, e),
        // The same refusal `settle_signed_gate` answers a grant with.
        TaskError::StepUp { request, .. } => reject_with_code(
            doc,
            trust_tasks_rs::TrustTaskCode::Standard(trust_tasks_rs::StandardCode::PermissionDenied),
            "a passkey gesture bound to this operation is required",
            Some(crate::acl::bound_step_up::refusal_details(request)),
        ),
        TaskError::Declared { code, error } => {
            let message = error.to_string();
            let marker = match error {
                AppError::NotFound(_) => Some(reasons::NOT_FOUND),
                AppError::Conflict(_) | AppError::IdempotencyKeyConflict => Some(reasons::CONFLICT),
                AppError::Gone(_) => Some(reasons::GONE),
                _ => None,
            };
            let code = declared_code(code);
            match marker {
                Some(reason) => reject_with_code_because(doc, code, message, None, reason),
                None => reject_with_code(doc, code, message, None),
            }
        }
    }
}

/// The wire code for a [`TaskError::Declared`](crate::error::TaskError).
///
/// Usually a task-extended `<slug>:<local>`, but not always: where a task
/// declares no code for a refusal, an operation carries the framework's own —
/// `vtc/endorsement-types/register`'s `malformedRequest` for a `claimSchema`
/// that is not a schema — so its bearer route can put a code in the body.
/// Reading that as an extended code panicked the dispatcher the first time
/// such an operation was bound on the signed door (#1641 batch 4), so the code
/// is parsed the way the framework parses any code.
pub(crate) fn declared_code(code: &str) -> TrustTaskCode {
    code.parse()
        .unwrap_or_else(|e| panic!("declared code {code:?} is not a Trust Task code: {e}"))
}

/// A specification-extended error code, `<slug>:<local>`, as a framework code.
pub(crate) fn extended_code(code: &str) -> TrustTaskCode {
    let (slug, local) = code
        .rsplit_once(':')
        .expect("an extended code is <slug>:<local>");
    TrustTaskCode::Extended {
        slug: slug.to_string(),
        local: local.to_string(),
    }
}

/// A `taskFailed` carrying the `details.reason` discriminator a client reads
/// back into a typed [`vta_sdk::error::VtaError`]. Twin of `vta-service`'s
/// function of the same name — the two services must not disagree about how a
/// missing row reaches a caller.
fn task_failed_because(message: String, reason: &str) -> RejectReason {
    RejectReason::TaskFailed {
        reason: message,
        details: Some(serde_json::json!({ "reason": reason })),
    }
}

/// [`reject_with_code`] plus the `details.reason` discriminator.
///
/// An extended code is what a client *should* branch on, but only if it knows
/// that code. SPEC §8.5's fallback says one that does not treats the error as
/// `taskFailed` — at which point it is back to needing the marker to tell an
/// absent row from a failure. #1602 recovered `NotFound` for codes whose local
/// part is literally `notFound`; nothing recovers `Conflict` or `Gone`, so a
/// declared `:alreadyDecided` or `:requestAlreadyOpen` reached every client as
/// an opaque `Protocol(String)`.
///
/// Carrying both means a client that knows the code gets the precise reason
/// and one that does not still gets the right typed variant.
pub(crate) fn reject_with_code_because<P>(
    doc: &TrustTask<P>,
    code: TrustTaskCode,
    message: impl Into<String>,
    details: Option<Value>,
    reason: &str,
) -> TrustTaskOutcome {
    let details = match details {
        // Merge rather than replace: the spec'd annex members (`requestId`,
        // `status`) are what the applicant's client acts on.
        Some(Value::Object(mut map)) => {
            map.insert("reason".into(), Value::String(reason.to_string()));
            Value::Object(map)
        }
        // A non-object `details` is not a shape this service emits, and
        // silently dropping it would hide that. Nothing calls it that way.
        Some(other) => {
            tracing::warn!(
                "non-object `details` on a coded reject; the reason marker was added beside it"
            );
            serde_json::json!({ "reason": reason, "details": other })
        }
        None => serde_json::json!({ "reason": reason }),
    };
    reject_with_code(doc, code, message, Some(details))
}

/// What an `internalError` says instead of the cause.
///
/// It tells the producer the one thing it can act on — the failure was not its
/// document's fault — and nothing an unauthenticated caller could probe with.
/// Deliberately the same sentence `vta-service` uses.
pub(crate) const OPAQUE_INTERNAL_ERROR: &str =
    "the consumer could not complete this task; the request itself was accepted";

/// Framework 0.5.0, *Bounding `details`*: where a specification declares no
/// bound, 4096 bytes of JCS and 16 immediate members apply.
pub(crate) const DETAILS_MAX_JCS_BYTES: usize = 4096;
/// Companion to [`DETAILS_MAX_JCS_BYTES`].
const DETAILS_MAX_MEMBERS: usize = 16;

/// Drop a `details` annex that exceeds the framework's bound, keeping the code.
///
/// Twin of `vta-service`'s function of the same name and deliberately identical
/// — the bound is a framework rule, not a per-service policy, so the two must
/// not drift into different ideas of how much a rejection may carry.
///
/// An oversized `details` is **ignored, never grounds to discard the `code`**:
/// the code is what the receiving party actually needs, and dropping a whole
/// rejection because its annex was too long would turn a verbose explanation
/// into an unexplained failure.
fn bound_details(details: Option<Value>) -> Option<Value> {
    let details = details?;
    let too_many_members = details
        .as_object()
        .is_some_and(|o| o.len() > DETAILS_MAX_MEMBERS);
    let too_large = serde_json_canonicalizer::to_string(&details)
        .map(|jcs| jcs.len() > DETAILS_MAX_JCS_BYTES)
        // Uncanonicalisable is worse than oversized: it cannot be bounded, so
        // it does not go out.
        .unwrap_or(true);
    if too_many_members || too_large {
        tracing::warn!(
            members = details.as_object().map(serde_json::Map::len),
            "error `details` exceeds the framework bound and was dropped; the code still went out"
        );
        return None;
    }
    Some(details)
}

/// Build a routed rejection document for the given reason. The framework
/// computes the status code from the reject's standard code.
pub(crate) fn reject_with<P>(doc: &TrustTask<P>, reason: RejectReason) -> TrustTaskOutcome {
    // Bound `details` here rather than at each construction site: this is the
    // funnel every `RejectReason`-shaped rejection passes through, so a new
    // site cannot be added that skips the check.
    let reason = match reason {
        RejectReason::TaskFailed { reason, details } => RejectReason::TaskFailed {
            reason,
            details: bound_details(details),
        },
        other => other,
    };
    let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reason);
    error_response(routed)
}

/// Reject with an explicit [`TrustTaskCode`] and a `details` annex.
///
/// [`RejectReason`] carries `details` on `TaskFailed` alone, so a rejection
/// under any other standard code has no way to attach machine-readable data
/// through [`reject_with`]. The framework itself is not the limitation:
/// `ErrorPayload::new` takes any code and `TrustTask::reject_with` takes a
/// payload. This is the seam between the two, and the twin of `vta-service`'s
/// helper of the same name.
///
/// `details` passes through [`bound_details`] exactly as in [`reject_with`], so
/// this cannot become the construction site that skips the framework's bound.
pub(crate) fn reject_with_code<P>(
    doc: &TrustTask<P>,
    code: TrustTaskCode,
    message: impl Into<String>,
    details: Option<Value>,
) -> TrustTaskOutcome {
    let mut payload = ErrorPayload::new(code).with_message(message);
    if let Some(d) = bound_details(details) {
        payload = payload.with_details(d);
    }
    let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
    error_response(routed)
}

/// Build a routed `#response` document with the given payload and wrap it in
/// an HTTP 200 response.
pub(crate) fn success_response<P, R: Serialize>(
    doc: &TrustTask<P>,
    payload: R,
) -> TrustTaskOutcome {
    let response_doc = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
    let body = match serde_json::to_vec(&response_doc) {
        Ok(b) => b,
        Err(e) => {
            tracing::error!(error = %e, "failed to serialise Trust Task success document");
            return reject_with(
                doc,
                RejectReason::InternalError {
                    reason: format!("response serialisation: {e}"),
                },
            );
        }
    };
    TrustTaskOutcome {
        status: StatusCode::OK,
        body,
    }
}

/// `trust-task-next-step/0.1` — the reserved framework answer (SPEC §8.6) for
/// an operation understood but not completable in isolation: here, one parked
/// in the action list. Addressed back to the requester on the request's
/// thread, like a `#response`, and answered `202 Accepted`: neither a result
/// nor a failure.
pub(crate) fn next_step_response<P>(doc: &TrustTask<P>, details: &Value) -> TrustTaskOutcome {
    let payload =
        crate::admin_actions::next_step_payload(&doc.id, &doc.type_uri.to_string(), details);
    let mut next = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
    next.type_uri = super::action_tasks::NEXT_STEP_TYPE
        .parse()
        .expect("the next-step Type URI parses");
    match serde_json::to_vec(&next) {
        Ok(body) => TrustTaskOutcome {
            status: StatusCode::ACCEPTED,
            body,
        },
        Err(e) => reject_with(
            doc,
            RejectReason::InternalError {
                reason: format!("next-step serialisation: {e}"),
            },
        ),
    }
}

/// The courtesy acknowledgement of a fire-and-forget task (SPEC §4.4.2): the
/// originating type with `#response` and a payload of exactly `{}`.
///
/// Only for a task whose specification defines **no** success response — §4.4.2
/// item 4 forbids it beside one that does. It attests arrival and nothing more;
/// the producer must not rely on it.
pub(crate) fn acknowledge<P>(doc: &TrustTask<P>) -> TrustTaskOutcome {
    success_response(doc, serde_json::Map::new())
}

/// Convenience wrapper over [`success_response`] for the `request`/`present`
/// verbs, whose response payload is always a [`VerdictResponse`].
pub(crate) fn verdict_response(
    doc: &TrustTask<Value>,
    verdict: VerdictResponse,
) -> TrustTaskOutcome {
    success_response(doc, verdict)
}

/// Wrap a routed [`ErrorResponse`] in an outcome with the right status code
/// per the framework's status table.
pub(crate) fn error_response(err_doc: ErrorResponse) -> TrustTaskOutcome {
    let status = StatusCode::from_u16(status_for_code(&err_doc.payload.code))
        .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
    let body = serde_json::to_vec(&err_doc).unwrap_or_default();
    TrustTaskOutcome { status, body }
}

/// The framework's error-document Type URI — the one `TrustTask::reject_with`
/// stamps on every *routed* rejection this service emits.
///
/// Named here because `trust-tasks-rs` keeps `trust_task_error_type_uri()`
/// `pub(crate)`, so the only unrouted path — where there is no request document
/// to reject from — has to write the value out. It said `0.1` while every
/// routed reject went out as `0.3` (the framework has emitted `0.3` since its
/// own 0.3 release, for the §8.2 `inResponseTo` member that `0.2`'s
/// `additionalProperties: false` payload schema cannot admit). One service
/// emitting two versions is a trap for exactly the consumer that pins one of
/// them.
///
/// Now `0.5`, tracking `trust-tasks-rs` 0.9. The framework moved twice for the
/// same reason it moved to `0.3`: a new standard code the older payload
/// schema's `code` enum does not list and whose extended-code pattern does not
/// match, so a document carrying it would fail to validate as the older
/// version. `0.4` carries `idConflict`, `0.5` carries `cancelled` (SPEC §8.3).
/// SPEC §5.2 forward-minor compatibility means a consumer pinned to `0.3`
/// SHOULD still accept these.
///
/// Pinned by `unrouted_and_routed_errors_agree_on_the_type_uri` below, which
/// compares it against a real `reject_with`, so a framework bump fails a test
/// rather than splitting this service into two dialects — which is exactly how
/// this bump was caught.
pub(crate) fn framework_error_type_uri() -> TypeUri {
    "https://trusttasks.org/spec/trust-task-error/0.5"
        .parse()
        .expect("framework error Type URI parses")
}

/// Build a framework error document for a body-parse failure.
/// Unrouted (no issuer / recipient) — the framework permits this on
/// malformed-body failures since the producer can correlate on the response
/// `id`.
pub(crate) fn body_parse_error_response(reason: &str) -> TrustTaskOutcome {
    let reject = RejectReason::MalformedRequest {
        reason: format!("body did not parse as a Trust Task document: {reason}"),
    };
    unrouted_error_response(reject.into())
}

/// A refusal of a body that was never parsed into a document — so there is no
/// request to reject *from*: no issuer to name, no thread and no ceremony to
/// carry forward. [`body_parse_error_response`] and the per-type size gate
/// ([`super::size`]) are the two callers.
pub(crate) fn unrouted_error_response(payload: ErrorPayload) -> TrustTaskOutcome {
    let type_uri: TypeUri = framework_error_type_uri();
    let err = ErrorResponse {
        id: format!("urn:uuid:{}", Uuid::new_v4()),
        thread_id: None,
        // Unrouted: there is no parent thread to name either, for the same
        // reason there is no issuer — the body never parsed.
        parent_thread_id: None,
        type_uri,
        issuer: None,
        recipient: None,
        issued_at: Some(chrono::Utc::now()),
        expires_at: None,
        payload,
        context: None,
        // No ceremony, for the same reason as `parent_thread_id` above: SPEC
        // §7.1 carries the member forward from the request so a rejection stays
        // inside the enactment it belonged to, and here there is no request to
        // carry it from — the body never parsed into one. The *routed* rejects
        // get this right for free, because `reject_with` copies it.
        ceremony: None,
        proof: None,
        extra: Default::default(),
    };
    error_response(err)
}

/// Verify the holder's `eddsa-jcs-2022` Data-Integrity proof on `doc` and
/// return the proven signer DID — the base DID (before `#`) of the proof's
/// `verificationMethod`.
///
/// Thin adapter over [`vti_common::auth::verify_trust_task_proof`],
/// the single implementation both services share. This used to be a *port* of
/// the VTA's copy; a proof means the same thing at both ends of the mesh, so a
/// second implementation was only ever a chance for the two to disagree. Only
/// the error mapping is local — the join dispatcher renders `AppError`.
///
/// The signature is verified over the document with its `proof` removed
/// (`eddsa-jcs-2022` canonicalises the proofless document via JCS). The
/// returned DID is *proven*, not merely claimed — binding it to an expected
/// identity is the caller's job. `did:key` resolution is local (no network).
///
/// Typed, so it verifies a re-serialisation of `doc`: for a document this
/// service built. A document that arrived over the network is verified as
/// received, with [`verify_received_trust_task_proof`] (VTI-45).
pub(crate) async fn verify_trust_task_proof(
    state: &AppState,
    doc: &TrustTask<Value>,
) -> Result<String, AppError> {
    vti_common::auth::verify_trust_task_proof_with(doc, &state.trust_task_vm_resolver())
        .await
        .map_err(|e| AppError::Unauthorized(format!("Trust Task {e}")))
}

/// [`verify_trust_task_proof`] over the document **as received** — `received`
/// is the JSON the inbound bytes parsed to, unmodified. Only this form sees the
/// members exactly as the producer signed them: a typed `TrustTask` rewrites
/// timestamps (`.000Z`, `+00:00`) and drops `null` optional members, either of
/// which refuses a correctly signed document (VTI-45).
pub(crate) async fn verify_received_trust_task_proof(
    state: &AppState,
    received: &Value,
) -> Result<String, AppError> {
    vti_common::auth::verify_trust_task_proof_value(received, &state.trust_task_vm_resolver())
        .await
        .map_err(|e| AppError::Unauthorized(format!("Trust Task {e}")))
}

/// Verify a human approver's own decision (a `task-consent/decision` or a
/// step-up `approve-response`) — **as received**, see
/// [`verify_received_trust_task_proof`] — and return the proven signer DID.
///
/// [`verify_trust_task_proof`], plus the rule every approval verifier in the
/// mesh holds (the VTA, the did-hosting RP's `verify_approval`): the proof is
/// made for `assertionMethod`, by a key the signer lists under
/// `assertionMethod`. An approval is the approver's attestation, not an
/// operational message, so a proof made for `authentication` is refused. A
/// signer DID that does not resolve is refused, never passed through.
pub(crate) async fn verify_received_approval_proof(
    state: &AppState,
    received: &Value,
    type_uri: &str,
) -> Result<String, AppError> {
    vti_common::auth::verify_approval_proof_value(received, &state.trust_task_vm_resolver())
        .await
        .map_err(|e| {
            // The cause stays in the operator's log; the wire gets `Display`.
            tracing::warn!(
                type_uri,
                error = %e,
                cause = e.cause().unwrap_or_default(),
                "approval refused at its proof"
            );
            AppError::Unauthorized(format!("Trust Task {e}"))
        })
}

/// Whether `type_uri` is a human approver's own decision, whose proof the
/// spine holds to [`verify_received_approval_proof`] rather than
/// [`verify_trust_task_proof`].
pub(crate) fn is_approval_type(type_uri: &str) -> bool {
    type_uri == super::STEP_UP_APPROVE_RESPONSE_TYPE
        || type_uri == super::STEP_UP_APPROVE_RESPONSE_V0_5_TYPE
        || type_uri == super::STEP_UP_APPROVE_RESPONSE_V0_6_TYPE
        || type_uri == crate::acl::admin_consent::DECISION_TYPE
        || type_uri == crate::acl::admin_consent::DECISION_V0_2_TYPE
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// A probe request to reject. Deliberately typed with a URI this crate
    /// **already binds** (`acl/list/0.1`): `trust_task_manifest`'s census scans
    /// this source tree for `trusttasks.org/spec/` literals and asserts every
    /// one is served by the registry, so inventing a plausible-looking type
    /// here — even in a test — adds a binding the registry has never published
    /// and fails the build. Which is the census working: a URI written down is
    /// a claim about what the registry serves, wherever it is written.
    fn doc() -> TrustTask<Value> {
        let uri: TypeUri = "https://trusttasks.org/spec/acl/list/0.1"
            .parse()
            .expect("acl/list Type URI parses");
        TrustTask::new("urn:uuid:test", uri, json!({}))
    }

    /// Framework 0.5.0, *What a `message` May Not Say*: an `internalError`
    /// must not carry consumer-internal state. This service used to send
    /// `err.to_string()` verbatim, so an unauthenticated caller learned which
    /// internal invariant broke and, with it, the deployment's shape.
    ///
    /// The cause still has to reach the operator, so it goes to the log. This
    /// test only pins that it does not reach the wire.
    #[test]
    fn an_internal_error_does_not_send_its_cause() {
        let secret = "vtc_did not configured";
        let out = app_error_to_reject(&doc(), &AppError::Internal(secret.into()));
        let body = String::from_utf8(out.body).expect("the reject body is UTF-8");

        assert!(
            !body.contains(secret),
            "the cause must not reach the wire: {body}"
        );
        assert!(
            body.contains(OPAQUE_INTERNAL_ERROR),
            "and the opaque sentence must: {body}"
        );

        // A second cause of a different shape, because the leak was in a
        // catch-all arm rather than in `Internal` alone.
        let out = app_error_to_reject(
            &doc(),
            &AppError::SecretStore("keyring backend unavailable at /run/user/1000".into()),
        );
        let body = String::from_utf8(out.body).expect("the reject body is UTF-8");
        assert!(!body.contains("keyring backend"), "{body}");
        assert!(body.contains(OPAQUE_INTERNAL_ERROR), "{body}");
    }

    /// The three caller-visible outcomes carry the marker a client reads back
    /// into a typed `VtaError`, and still say what happened in prose — they
    /// describe the caller's own request, which is not internal state.
    #[test]
    fn caller_visible_failures_carry_their_reason_marker() {
        for (err, want) in [
            (AppError::NotFound("no such row".into()), reasons::NOT_FOUND),
            (AppError::Conflict("already open".into()), reasons::CONFLICT),
            (AppError::Gone("withdrawn".into()), reasons::GONE),
            (AppError::IdempotencyKeyConflict, reasons::CONFLICT),
        ] {
            let out = app_error_to_reject(&doc(), &err);
            let body: Value = serde_json::from_slice(&out.body).expect("reject body is JSON");
            assert_eq!(
                body.pointer("/payload/details/reason")
                    .and_then(Value::as_str),
                Some(want),
                "{err:?} must carry {want}: {body}"
            );
        }
    }

    /// The unrouted body-parse error must claim the same document type as a
    /// routed one. It cannot ask the framework — `trust_task_error_type_uri()`
    /// is `pub(crate)` there — so it names the version, and this compares that
    /// against what `reject_with` actually stamps. A framework bump fails here
    /// instead of splitting this service into two dialects, which is how the
    /// unrouted path came to say `0.1` while every routed reject said `0.3`.
    #[test]
    fn unrouted_and_routed_errors_agree_on_the_type_uri() {
        let routed = doc().reject_with(
            "urn:uuid:routed",
            RejectReason::InternalError {
                reason: "probe".into(),
            },
        );
        assert_eq!(
            framework_error_type_uri(),
            routed.type_uri,
            "the unrouted body-parse error names a different document type than \
             the framework stamps on a routed rejection"
        );
    }

    /// …and the bytes on the wire carry it, not just the value we compute.
    #[test]
    fn the_body_parse_error_goes_out_as_a_framework_error_document() {
        let outcome = body_parse_error_response("not json");
        let doc: Value = serde_json::from_slice(&outcome.body).expect("error doc parses");
        assert_eq!(
            doc["type"].as_str().expect("type present"),
            framework_error_type_uri().to_string()
        );
    }

    /// The `did:key` for a one-byte test seed, plus the private-key
    /// multibase `vta_sdk::trust_task_sign::build_signed` accepts.
    fn did_key_from_seed(seed_byte: u8) -> (String, String) {
        use ed25519_dalek::SigningKey;
        let seed = [seed_byte; 32];
        let sk = SigningKey::from_bytes(&seed);
        let did = format!(
            "did:key:{}",
            vta_sdk::did_key::ed25519_multibase_pubkey(&sk.verifying_key().to_bytes())
        );
        let mut buf = vec![0x80, 0x26];
        buf.extend_from_slice(&seed);
        (did, multibase::encode(multibase::Base::Base58Btc, &buf))
    }

    /// This is the join dispatcher's holder-binding check on an
    /// unauthenticated request — one of the ~12 inbound routes FTL-29595 fix
    /// direction 3 must leave byte-identical: a resolver failure and an
    /// actual bad signature must render the same `AppError::Unauthorized`,
    /// or an anonymous caller learns whether a DID resolves at all.
    #[tokio::test]
    async fn a_resolver_failure_and_a_bad_signature_render_identically() {
        let tv = crate::test_support::build_test_vtc().await;

        // Resolver failure: a `did:webvh` verification method against a
        // did:key-only resolver (this test VTC has none configured) —
        // refused before any signature check runs.
        let resolver_fail_doc: TrustTask<Value> = serde_json::from_value(json!({
            "id": "urn:uuid:00000000-0000-4000-8000-000000000020",
            "type": "https://trusttasks.org/spec/acl/list/0.1",
            "issuer": "did:webvh:QmScid:example.com:glenn",
            "recipient": "did:webvh:vtc.example.com:abc",
            "payload": {},
            "proof": {
                "type": "DataIntegrityProof",
                "cryptosuite": "eddsa-jcs-2022",
                "proofPurpose": "assertionMethod",
                "verificationMethod": "did:webvh:QmScid:example.com:glenn#key-0",
                "created": "2026-01-01T00:00:00Z",
                "proofValue": "z2aBcD"
            }
        }))
        .expect("well-formed document");

        // Bad signature: a real did:key, signed, then corrupted.
        let (signer_did, secret_mb) = did_key_from_seed(31);
        let signed = vta_sdk::trust_task_sign::build_signed(
            "https://trusttasks.org/spec/acl/list/0.1",
            json!({}),
            &signer_did,
            &secret_mb,
            "did:webvh:vtc.example.com:abc",
        )
        .await
        .expect("build a validly-signed document");
        let mut bad_sig_doc: TrustTask<Value> =
            serde_json::from_str(&signed).expect("signed doc parses");
        let proof = bad_sig_doc.proof.as_mut().expect("document is signed");
        let last = proof.proof_value.pop().expect("non-empty proofValue");
        proof.proof_value.push(if last == '1' { '2' } else { '1' });

        let resolver_failure = verify_trust_task_proof(&tv.state, &resolver_fail_doc).await;
        let bad_signature = verify_trust_task_proof(&tv.state, &bad_sig_doc).await;

        match (resolver_failure, bad_signature) {
            (Err(AppError::Unauthorized(a)), Err(AppError::Unauthorized(b))) => {
                assert_eq!(
                    a, b,
                    "a resolver failure must render exactly as a bad signature does"
                );
            }
            other => panic!("expected both to be Unauthorized errors, got {other:?}"),
        }
    }
}