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
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
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
//! Peer identity vetting, community side: turning the vetting statements a join
//! presentation carries into the facts `join.rego` decides on.
//!
//! Design: OpenVTC `docs/design/vetting-process.md` §10.
//!
//! ## What the host establishes, and what policy decides
//!
//! The raw submit path verifies the presentation's holder binding and none of
//! the credentials inside it (see `join::orchestrate::presentation_from_vp`), so
//! nothing here trusts a statement because it arrived. For each Vetting
//! Statement in the VP — a DTG `StatementCredential` whose
//! `credentialSubject.predicate` is the criterion's `statementType`
//! (`https://registry.trustoverip.org/dtg/vsc/vetted/1`) — this module:
//!
//! 1. verifies it (`vta_sdk::vetting::statement::verify_statement` — proof by
//!    the issuer, v1 context and type, the `vetted/1` profile: `taskContext`,
//!    `taskDigestMultibase`, `issuerScope` at least `directed` — bounded
//!    window, strict `object.value`);
//! 2. accepts its predicate through the community's fail-closed accept list
//!    (`crate::endorsement_types::accept_list`) — a predicate the community
//!    has not registered never counts;
//! 3. binds it to the applicant (`credentialSubject.id` = the proven holder);
//! 4. asks whether the issuer is an **eligible vetter** of this community —
//!    a current member, who had already joined when they issued the statement,
//!    holding a vetter role grant ([`vetters`]; the community-issued VAC
//!    conferring `role:<eligibleVetters.role>` at the community's DID) that was
//!    recorded by then, unexpired then, and is not revoked now;
//! 5. counts the survivors with `vta_sdk::vetting::requirements::evaluate`, the
//!    same rule the applicant's client uses for its checklist.
//!
//! The result is [`VettingFacts`]. Policy reads `satisfied`,
//! `commitments_consistent`, `independence_ok` and `needs` and decides; the
//! host never admits on vetting by itself.
//!
//! Independence is established from evidence, not from statements merely being
//! separately signed (VTI-CMP-070): distinct vetters are distinct *members*.

pub mod auto_grant;
/// Why a hidden-vetting submission's challenge was refused, as the applicant is told it.
pub mod challenge_refusal;
/// The member of a join submission's `extensions` a hidden-vetting proof rides in.
///
/// Spelled here rather than imported so that [`redact_hidden_submission`] runs whether or not
/// this build implements the suite: a community that never verifies one still has no reason to
/// keep it. `vetting::pcs`'s tests pin it against the crate's own constant.
pub const HIDDEN_VETTING_MEMBER: &str = "hiddenVetting";

/// Replace a hidden-vetting proof with a digest of itself, once it has been decided.
///
/// A proof is evidence for exactly one decision, and after that it is a liability: it carries
/// the tags, and a tag is one discrete log from the vetter who made it (`docs/design/
/// vetting-hidden-vetters-pcs.md` §18). The facts row is what every reader downstream actually
/// uses; the submission is not read again.
///
/// What stays is enough to answer "was this decided on the evidence we think" — the suite, the
/// size, and a SHA-256 of the canonical bytes — and nothing that links a vetter to anything.
///
/// Returns true when there was a proof to redact.
pub fn redact_hidden_submission(extensions: &mut JsonValue) -> bool {
    let Some(obj) = extensions.as_object_mut() else {
        return false;
    };
    let Some(proof) = obj.get(HIDDEN_VETTING_MEMBER) else {
        return false;
    };
    let bytes = serde_json::to_vec(proof).unwrap_or_default();
    let suite = proof
        .get("suite")
        .and_then(JsonValue::as_str)
        .unwrap_or("unknown")
        .to_string();
    let digest = hex::encode(<sha2::Sha256 as sha2::Digest>::digest(&bytes));
    obj.insert(
        HIDDEN_VETTING_MEMBER.to_string(),
        serde_json::json!({
            "redacted": true,
            "suite": suite,
            "bytes": bytes.len(),
            "sha256": digest,
        }),
    );
    true
}

/// Hidden-vetter admission (ZKP), development branch `zkp-pcs`.
#[cfg(feature = "vetting-pcs")]
pub mod pcs;
/// The VTC-issued challenge a hidden submission is bound to.
#[cfg(feature = "vetting-pcs")]
pub mod pcs_challenge;
/// Event mode: the exception to the constant drip, and the gate that keeps it survivable.
#[cfg(feature = "vetting-pcs")]
pub mod pcs_event;
/// The community's minting half: vetter enrolment and the token drip.
#[cfg(feature = "vetting-pcs")]
pub mod pcs_issue;
/// The Trust Tasks that carry the community half.
#[cfg(feature = "vetting-pcs")]
pub mod pcs_tasks;
pub mod profiles;
pub mod revocation;
pub mod vetters;

use std::collections::BTreeMap;

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

use vta_sdk::protocols::vetting::{
    VETTED_PREDICATE, VettingRequirements, VettingRequirementsInvitation,
};
use vta_sdk::vetting::requirements::{StatementFacts, evaluate};
use vta_sdk::vetting::statement::verify_statement;
use vti_common::error::AppError;

use crate::endorsements::{VETTER_GRANT_ROW_TYPE, endorsements_for_subject};
use crate::members::storage::get_member;
use crate::routes::join_requests::manifest::ServedCriterion;
use crate::server::AppState;

/// The generic need a policy returns when vetting is incomplete. The host
/// expands it into the precise shortfall ([`expand_needs`]), which a visually
/// authored policy cannot express because its `with` is static.
pub const NEED_VETTING: &str = "vetting";

/// The need returned when the requirements demand an invitation and none was
/// presented.
pub const NEED_INVITATION: &str = "vetting:invitation";

/// Prefix of the needs that say why a hidden-vetting criterion's vetting did not count:
/// `vetting:hidden:<code>`, where `<code>` is a statement failure code
/// (`requirements-digest-mismatch`, `token-label-not-live`, `expired`, …) or one of
/// [`HIDDEN_NO_PROOF`] / [`HIDDEN_UNSUPPORTED`]. It is not a `Need` the SDK parses, so a client
/// that does not know it shows it as it came.
pub const NEED_HIDDEN_PREFIX: &str = "vetting:hidden:";

/// The criterion counts vetting from a hidden-vetting proof, and the submission carried none.
pub const HIDDEN_NO_PROOF: &str = "no-proof";

/// The criterion counts vetting from a hidden-vetting proof, and this build does not verify one.
pub const HIDDEN_UNSUPPORTED: &str = "unsupported";

/// A named vetting statement presented under a criterion that counts hidden vetting only.
pub const NAMED_ON_HIDDEN: &str = "named-statement-on-hidden-criterion";

/// What the host established about the vetting evidence of one join request.
/// Every member is a host verdict; policy reads it and decides.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VettingFacts {
    /// The criterion whose requirements were applied.
    pub criterion_id: String,
    /// That criterion's current `requirementsDigest`.
    pub requirements_digest: String,
    /// The applicant named this digest in its submission — it gathered its
    /// statements against the requirements as they stand now.
    pub applicant_digest_matches: bool,
    /// Every identity-vetting statement the presentation carried.
    pub statements: Vec<VettingStatementFact>,
    /// Distinct eligible vetters counted.
    pub distinct_counted_vetters: u32,
    /// Counted statements by method (`inPerson`, `video`, …).
    pub by_method: BTreeMap<String, u32>,
    /// All counted-eligible statements carry one identity commitment.
    pub commitments_consistent: bool,
    /// No declared-relationship cap is exceeded.
    pub independence_ok: bool,
    /// The requirements demand an invitation credential alongside.
    pub invitation_required: bool,
    /// Count, method floors, commitment consistency and independence all hold.
    pub satisfied: bool,
    /// What is still missing, in the `vetting:*` wire grammar.
    pub needs: Vec<String>,
}

/// One presented statement, as the host saw it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VettingStatementFact {
    /// The statement `id`, when it could be read.
    pub id: Option<String>,
    /// The issuer DID, when it could be read.
    pub issuer: Option<String>,
    /// Proof, type, window and body all verified.
    pub verified: bool,
    /// The issuer is an eligible vetter of this community.
    pub eligible: bool,
    /// The issuer has withdrawn the statement.
    pub revoked: bool,
    /// `inPerson` / `video` / `priorAcquaintance`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub method: Option<String>,
    /// The vetter's declared relationship to the applicant.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub declared_relationship: Option<String>,
    /// It counted toward the requirements.
    pub counted: bool,
    /// Why it did not count (stable codes).
    #[serde(default)]
    pub failures: Vec<String>,
}

/// Why [`vetting_facts`] could not count a submission.
#[derive(Debug)]
pub enum FactsError {
    /// The hidden-vetting challenge the proof is bound to was refused. Kept apart from
    /// [`Self::App`] so the join spine can answer it with its own code rather than as a malformed
    /// request.
    Challenge(challenge_refusal::ChallengeRefusal),
    /// Everything else, as before.
    App(AppError),
}

impl From<AppError> for FactsError {
    fn from(e: AppError) -> Self {
        Self::App(e)
    }
}

impl From<FactsError> for AppError {
    fn from(e: FactsError) -> Self {
        match e {
            FactsError::Challenge(r) => r.into(),
            FactsError::App(e) => e,
        }
    }
}

/// Build the vetting facts for a join presentation under `criterion` — the
/// criterion the submission is decided under — or `None` when that criterion
/// requires no vetting.
///
/// `cited` says the applicant named this criterion's `requirementsDigest` in
/// its submission: it gathered its statements against the requirements as they
/// stand. `extensions` is the submission's `extensions` object, where a
/// hidden-vetting proof rides.
#[cfg_attr(not(feature = "vetting-pcs"), allow(unused_variables))]
pub async fn vetting_facts(
    state: &AppState,
    applicant_did: &str,
    vp: &JsonValue,
    extensions: &JsonValue,
    criterion: &ServedCriterion,
    cited: bool,
    now: DateTime<Utc>,
) -> Result<Option<VettingFacts>, FactsError> {
    let Some(requirements) = criterion.stored.vetting.clone() else {
        return Ok(None);
    };
    let selected = Selected {
        criterion_id: criterion.stored.id.clone(),
        requirements,
        digest: criterion.digest.clone(),
        applicant_digest_matches: cited,
        hidden_vetting: criterion.stored.hidden_vetting.clone(),
    };
    let requirements = &selected.requirements;

    let community_did = state
        .config
        .read()
        .await
        .vtc_did
        .clone()
        .unwrap_or_default();

    // Hidden-vetter admission (development branch `zkp-pcs`): when the criterion publishes
    // anonymity parameters AND this submission carries a proof, the facts come from the proof
    // instead of from named statements. Everything downstream — `evaluate`, the needs
    // expansion, `join.rego` — is the same, because the facts are the same shape with each
    // vetter's tag where their DID would be.
    #[cfg(feature = "vetting-pcs")]
    if let Some(facts) = hidden_facts(
        state,
        &community_did,
        applicant_did,
        &selected,
        requirements,
        extensions,
        now,
    )
    .await?
    {
        return Ok(Some(facts));
    }
    // A criterion that publishes hidden-vetting parameters counts vetting from a hidden-vetting
    // proof and nothing else: never mix named and hidden vetting in one criterion
    // (`vetting-hidden-vetters-pcs.md` §12.1). A vetter who gave the applicant both a named
    // statement and a hidden attestation would count twice, and the community cannot tell the
    // two are one person. Reaching here means no proof was counted — the submission carried
    // none, or this build cannot read one — so its named statements are recorded, never counted.
    if selected.hidden_vetting.is_some() {
        return Ok(Some(hidden_without_proof(applicant_did, selected, vp, now)));
    }
    let resolver = state.trust_task_vm_resolver();
    // Fail closed: a statement counts only under a predicate this community
    // registered (vtc/endorsement-types/register/0.1).
    let accepted = crate::endorsement_types::accept_list(&state.endorsement_types_ks).await?;

    let mut to_count = Vec::new();
    let mut statements = Vec::new();
    for vc in vetting_credentials(vp, &requirements.statement_type) {
        let id = vc.get("id").and_then(JsonValue::as_str).map(str::to_string);
        let issuer = issuer_of(vc);
        let verified = match verify_statement(vc, now, &resolver).await {
            Ok(v) => v,
            Err(e) => {
                warn!(
                    applicant = %applicant_did,
                    statement = id.as_deref().unwrap_or("<no id>"),
                    error = %e,
                    cause = e.cause().unwrap_or(""),
                    "vetting statement did not verify — not counted"
                );
                statements.push(VettingStatementFact {
                    id,
                    issuer,
                    verified: false,
                    eligible: false,
                    revoked: false,
                    method: None,
                    declared_relationship: None,
                    counted: false,
                    failures: vec!["unverified".into()],
                });
                continue;
            }
        };

        let vetted = verified.value();
        let mut failures = Vec::new();
        if verified.subject() != applicant_did {
            failures.push("subject-not-applicant".to_string());
        }
        // `verify_statement` holds the statement to `vetted/1`; a criterion
        // counting another predicate counts none of these.
        if requirements.statement_type != VETTED_PREDICATE {
            failures.push("wrong-statement-type".to_string());
        }
        // A vetter's statement carries the vetter-only members
        // (`identityCommitment`, `cardDigestMultibase`, `declaredRelationship`).
        // One without them is a statement the community issued for itself
        // (registry `vetted/1`): evidence for personhood, never a vetter's
        // statement to count toward admission.
        let vetter = vetted.vetter_members();
        if vetter.is_none() {
            failures.push("not-a-vetter-statement".to_string());
        }
        let predicate_accepted =
            vta_sdk::vetting::dtg_shape(vc).is_ok_and(|parsed| accepted.accept(&parsed).is_ok());
        if !predicate_accepted {
            failures.push("predicate-not-accepted".to_string());
        }
        let eligible = vetter_eligible(
            state,
            verified.issuer(),
            &requirements.eligible_vetters.role,
            verified.valid_from(),
        )
        .await?;
        // A withdrawal notice counts only against a statement with the notice's
        // own issuer, id and digest, so nobody can withdraw a statement they did
        // not sign.
        let revoked = revocation::is_revoked(
            &state.vetting_revocations_ks,
            verified.issuer(),
            verified.id(),
            verified.digest_multibase(),
        )
        .await?;

        let fact = VettingStatementFact {
            id: Some(verified.id().to_string()),
            issuer: Some(verified.issuer().to_string()),
            verified: true,
            eligible,
            revoked,
            method: Some(vetted.method.to_string()),
            declared_relationship: vetted.declared_relationship.map(|r| r.to_string()),
            counted: false,
            failures,
        };
        // A statement about someone else, or of a type this criterion does not
        // count, is not evidence about this applicant at all — keep it out of the
        // count *and* out of the commitment-consistency check.
        if let (true, Some(vetter)) = (fact.failures.is_empty(), vetter) {
            to_count.push(StatementFacts {
                statement_id: verified.id().to_string(),
                vetter: verified.issuer().to_string(),
                method: vetted.method,
                claims_verified: vetted
                    .claims_verified
                    .iter()
                    .map(|c| c.as_str().to_owned())
                    .collect(),
                document_classes: vetted
                    .document_classes
                    .iter()
                    .map(|d| d.as_str().to_owned())
                    .collect(),
                declared_relationship: vetter.declared_relationship,
                identity_commitment: vetter.identity_commitment.to_owned(),
                valid_from: verified.valid_from(),
                community_matches: vetted.community == community_did,
                eligible,
                revoked,
            });
        }
        statements.push(fact);
    }

    let evaluation = evaluate(requirements, &to_count, now);
    for fact in &mut statements {
        let Some(id) = fact.id.as_deref() else {
            continue;
        };
        if evaluation.counted.iter().any(|c| c == id) {
            fact.counted = true;
        } else if let Some((_, reasons)) = evaluation.not_counted.iter().find(|(nc, _)| nc == id) {
            fact.failures
                .extend(reasons.iter().map(|r| r.code().to_string()));
        }
    }

    let needs: Vec<String> = evaluation.needs.iter().map(|n| n.to_wire()).collect();

    Ok(Some(VettingFacts {
        criterion_id: selected.criterion_id,
        requirements_digest: selected.digest,
        applicant_digest_matches: selected.applicant_digest_matches,
        statements,
        distinct_counted_vetters: u32::try_from(evaluation.distinct_vetters()).unwrap_or(u32::MAX),
        by_method: evaluation
            .by_method
            .iter()
            .map(|(m, n)| (m.to_string(), *n))
            .collect(),
        commitments_consistent: evaluation.commitments_consistent,
        independence_ok: evaluation.independence_ok,
        invitation_required: matches!(
            requirements.invitation,
            Some(VettingRequirementsInvitation::Required)
        ),
        satisfied: evaluation.satisfied(),
        needs,
    }))
}

/// The facts for a submission under a hidden-vetting criterion that carried no countable proof.
///
/// Never satisfied. Each named statement the presentation carried is recorded as not counted,
/// unverified, under [`NAMED_ON_HIDDEN`], so the operator sees what arrived and why it was
/// refused; the applicant is told the one thing that would count — a proof — rather than "one
/// more statement", which on this criterion never would.
fn hidden_without_proof(
    applicant_did: &str,
    selected: Selected,
    vp: &JsonValue,
    now: DateTime<Utc>,
) -> VettingFacts {
    let reason = if cfg!(feature = "vetting-pcs") {
        HIDDEN_NO_PROOF
    } else {
        HIDDEN_UNSUPPORTED
    };
    let statements: Vec<VettingStatementFact> =
        vetting_credentials(vp, &selected.requirements.statement_type)
            .map(|vc| VettingStatementFact {
                id: vc.get("id").and_then(JsonValue::as_str).map(str::to_string),
                issuer: issuer_of(vc),
                verified: false,
                eligible: false,
                revoked: false,
                method: None,
                declared_relationship: None,
                counted: false,
                failures: vec![NAMED_ON_HIDDEN.to_string()],
            })
            .collect();
    tracing::info!(
        applicant = %applicant_did,
        criterion = %selected.criterion_id,
        digest = %selected.digest,
        reason,
        named_statements_refused = statements.len(),
        "criterion counts hidden vetting only, and no hidden-vetting proof was counted for this \
         submission"
    );
    // Nothing counted: the count's own verdict on the empty set, so the consistency members read
    // as any other unmet criterion's do.
    let evaluation = evaluate(&selected.requirements, &[], now);
    VettingFacts {
        criterion_id: selected.criterion_id,
        requirements_digest: selected.digest,
        applicant_digest_matches: selected.applicant_digest_matches,
        statements,
        distinct_counted_vetters: 0,
        by_method: BTreeMap::new(),
        commitments_consistent: evaluation.commitments_consistent,
        independence_ok: evaluation.independence_ok,
        invitation_required: matches!(
            selected.requirements.invitation,
            Some(VettingRequirementsInvitation::Required)
        ),
        satisfied: false,
        needs: vec![format!("{NEED_HIDDEN_PREFIX}{reason}")],
    }
}

/// Why a hidden-vetting proof's attestations did not count, as the `needs` codes an applicant
/// is sent: each distinct failure code of a statement that was not counted, in the order first
/// seen.
///
/// `issuer-not-vetter` is dropped when a more specific code is present. On the hidden path the
/// verifier marks a statement ineligible *because* of its own finding (a stale token label, a
/// digest from another version of the criterion), and the counting rule then reports that
/// ineligibility as `issuer-not-vetter` — which would tell the applicant their vetter is not a
/// vetter, when the vetter is fine and the parameters moved.
#[cfg_attr(not(feature = "vetting-pcs"), allow(dead_code))]
fn hidden_reasons(statements: &[VettingStatementFact]) -> Vec<String> {
    let mut reasons: Vec<String> = Vec::new();
    for fact in statements.iter().filter(|f| !f.counted) {
        for failure in &fact.failures {
            if !reasons.contains(failure) {
                reasons.push(failure.clone());
            }
        }
    }
    if reasons.iter().any(|r| r != "issuer-not-vetter") {
        reasons.retain(|r| r != "issuer-not-vetter");
    }
    reasons
}

/// The hidden-vetter path (development branch `zkp-pcs`).
///
/// `Ok(None)` when this criterion publishes no anonymity parameters, or when the submission
/// carries no proof. The caller then refuses to count named statements under a hidden
/// criterion ([`hidden_without_proof`]): a criterion is named or hidden, never both.
#[cfg(feature = "vetting-pcs")]
async fn hidden_facts(
    state: &AppState,
    community_did: &str,
    applicant_did: &str,
    selected: &Selected,
    requirements: &VettingRequirements,
    extensions: &JsonValue,
    now: DateTime<Utc>,
) -> Result<Option<VettingFacts>, FactsError> {
    let Some(raw) = selected.hidden_vetting.clone() else {
        return Ok(None);
    };
    let config: crate::vetting::pcs::HiddenVettingConfig = serde_json::from_value(raw)
        .map_err(|e| AppError::Validation(format!("hidden-vetting parameters: {e}")))?;
    let decision = crate::vetting::pcs::decide(
        state,
        community_did,
        applicant_did,
        requirements,
        &selected.digest,
        &config,
        extensions,
        now,
    )
    .await
    .map_err(|e| match e {
        // The applicant is told which way, under a code of its own.
        crate::vetting::pcs::DecideError::Challenge(r) => FactsError::Challenge(r),
        crate::vetting::pcs::DecideError::Store(e) => FactsError::App(e),
        crate::vetting::pcs::DecideError::Proof(e) => {
            FactsError::App(AppError::Validation(format!("hidden vetting: {e}")))
        }
    })?;
    let Some(decision) = decision else {
        return Ok(None);
    };
    let evaluation = &decision.evaluation;
    let mut facts = VettingFacts {
        criterion_id: selected.criterion_id.clone(),
        requirements_digest: selected.digest.clone(),
        applicant_digest_matches: selected.applicant_digest_matches,
        statements: decision
            .statements
            .iter()
            .map(|s| VettingStatementFact {
                id: Some(s.id.clone()),
                // The tag, not a DID: distinct tags are distinct vetters (design §2), and
                // that is all the community learns.
                issuer: Some(s.issuer.clone()),
                verified: s.verified,
                eligible: s.eligible,
                revoked: s.revoked,
                method: Some(s.method.to_string()),
                declared_relationship: Some(s.declared_relationship.to_string()),
                counted: s.counted,
                failures: s.failures.clone(),
            })
            .collect(),
        distinct_counted_vetters: u32::try_from(evaluation.distinct_vetters()).unwrap_or(u32::MAX),
        by_method: evaluation
            .by_method
            .iter()
            .map(|(m, n)| (m.to_string(), *n))
            .collect(),
        commitments_consistent: evaluation.commitments_consistent,
        independence_ok: evaluation.independence_ok,
        invitation_required: matches!(
            requirements.invitation,
            Some(VettingRequirementsInvitation::Required)
        ),
        satisfied: evaluation.satisfied(),
        needs: evaluation.needs.iter().map(|n| n.to_wire()).collect(),
    };
    // R6.4: say on the operator's log what the proof counted for, and when it fell short, why
    // — failure codes and counts only, never a tag. And tell the applicant the same codes, so
    // "one more statement" is not all they hear about a proof that arrived and was refused.
    let reasons = hidden_reasons(&facts.statements);
    tracing::info!(
        applicant = %applicant_did,
        criterion = %facts.criterion_id,
        digest = %facts.requirements_digest,
        attestations = facts.statements.len(),
        counted = facts.statements.iter().filter(|s| s.counted).count(),
        distinct_vetters = facts.distinct_counted_vetters,
        satisfied = facts.satisfied,
        needs = ?facts.needs,
        not_counted_because = ?reasons,
        "hidden-vetting proof verified and counted"
    );
    if !facts.satisfied {
        facts
            .needs
            .extend(reasons.iter().map(|r| format!("{NEED_HIDDEN_PREFIX}{r}")));
    }
    Ok(Some(facts))
}

/// Replace a policy's generic [`NEED_VETTING`] with the precise shortfall the
/// facts record. A policy authored in the visual editor can only return a
/// static `needs` list; this is where it becomes something an applicant can act
/// on. Leaves `needs` untouched when there are no vetting facts, or when the
/// facts record no specific shortfall (e.g. only an independence concern).
pub fn expand_needs(needs: &mut Vec<String>, facts: Option<&VettingFacts>) {
    let Some(facts) = facts else {
        return;
    };
    if facts.needs.is_empty() {
        return;
    }
    if let Some(pos) = needs.iter().position(|n| n == NEED_VETTING) {
        needs.splice(pos..=pos, facts.needs.iter().cloned());
    }
}

/// The criterion a submission is evaluated under, as the vetting count needs it.
#[derive(Debug, Clone)]
struct Selected {
    criterion_id: String,
    requirements: VettingRequirements,
    digest: String,
    applicant_digest_matches: bool,
    /// Set: the criterion counts vetting from a hidden-vetting proof only.
    hidden_vetting: Option<JsonValue>,
}

/// Vetting Statements in a VP's `verifiableCredential`: statements whose
/// `credentialSubject.predicate` is the criterion's `statement_type` or the
/// registry's `vetted/1`, compared byte for byte. Picked by predicate, never by
/// a type string; everything else in the presentation is left to the other
/// facts.
fn vetting_credentials<'a>(
    vp: &'a JsonValue,
    statement_type: &'a str,
) -> impl Iterator<Item = &'a JsonValue> {
    vp.get("verifiableCredential")
        .and_then(JsonValue::as_array)
        .into_iter()
        .flatten()
        .filter(move |vc| {
            let predicate = vc
                .pointer("/credentialSubject/predicate")
                .and_then(JsonValue::as_str);
            predicate == Some(statement_type) || predicate == Some(VETTED_PREDICATE)
        })
}

fn issuer_of(vc: &JsonValue) -> Option<String> {
    match vc.get("issuer")? {
        JsonValue::String(s) => Some(s.clone()),
        JsonValue::Object(o) => o.get("id")?.as_str().map(str::to_string),
        _ => None,
    }
}

/// Is `issuer` an eligible vetter: a current member, whose membership predates
/// the statement, holding a role grant the community recorded for `role` —
/// issued during this membership and by the statement's `validFrom`, unexpired
/// then, and not revoked since ([`vetters::grant_covers`]).
///
/// Revocation is read as it stands now, not as it stood at issuance: a grant
/// withdrawn after a statement was signed stops that statement counting. That
/// errs toward not admitting, and it is what an operator withdrawing a vetter
/// they no longer trust means.
pub(crate) async fn vetter_eligible(
    state: &AppState,
    issuer: &str,
    role: &str,
    issued_at: DateTime<Utc>,
) -> Result<bool, AppError> {
    let Some(member) = get_member(&state.members_ks, issuer).await? else {
        return Ok(false);
    };
    // `issued_at` and a grant's `created_at` are second-precision credential
    // timestamps; compare them with the second the member joined.
    if member.removed_at.is_some() || vetters::joined_at_second(&member) > issued_at {
        return Ok(false);
    }
    let grants =
        endorsements_for_subject(&state.endorsements_ks, issuer, VETTER_GRANT_ROW_TYPE).await?;
    Ok(grants.iter().any(|g| {
        vetters::recorded_during_membership(g, &member) && vetters::grant_covers(g, role, issued_at)
    }))
}

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

    /// A decided proof is replaced by a digest of itself: enough to say what was decided on,
    /// nothing that links a vetter.
    #[test]
    fn a_decided_hidden_proof_is_redacted_to_a_digest() {
        let mut extensions = json!({
            "requirementsDigest": "zQmDigest",
            HIDDEN_VETTING_MEMBER: {
                "suite": "ps-ddh-bls12381",
                "id": "z7Applicant",
                "proof": "zTheWholeProofWithTagsInside",
                "statements": [{ "meta": {}, "token": {} }],
            },
        });
        assert!(redact_hidden_submission(&mut extensions));

        let left = &extensions[HIDDEN_VETTING_MEMBER];
        assert_eq!(left["redacted"], json!(true));
        assert_eq!(left["suite"], json!("ps-ddh-bls12381"));
        assert!(left["bytes"].as_u64().unwrap() > 0);
        assert_eq!(left["sha256"].as_str().unwrap().len(), 64);
        // Nothing of the proof survives.
        let text = serde_json::to_string(&extensions).unwrap();
        assert!(!text.contains("zTheWholeProofWithTagsInside"), "{text}");
        assert!(!text.contains("z7Applicant"), "{text}");
        // Everything beside it does.
        assert_eq!(extensions["requirementsDigest"], json!("zQmDigest"));
    }

    /// A named-path submission has nothing to redact, and is left exactly as it was.
    #[test]
    fn redaction_leaves_a_named_submission_alone() {
        let before = json!({ "requirementsDigest": "zQmDigest" });
        let mut after = before.clone();
        assert!(!redact_hidden_submission(&mut after));
        assert_eq!(before, after);
        // And a submission that is not an object at all is not a panic.
        let mut odd = json!("not an object");
        assert!(!redact_hidden_submission(&mut odd));
    }

    #[cfg(feature = "vetting-pcs")]
    fn hidden_fact(counted: bool, failures: &[&str]) -> VettingStatementFact {
        VettingStatementFact {
            id: Some("s".into()),
            issuer: Some("zMasked".into()),
            verified: true,
            eligible: failures.is_empty(),
            revoked: false,
            method: None,
            declared_relationship: None,
            counted,
            failures: failures.iter().map(|f| (*f).to_string()).collect(),
        }
    }

    /// The reasons a hidden proof did not count are the specific codes, once each, and never
    /// the counting rule's `issuer-not-vetter` echo of an ineligibility the verifier caused.
    #[cfg(feature = "vetting-pcs")]
    #[test]
    fn hidden_reasons_name_the_specific_cause() {
        let reasons = hidden_reasons(&[
            hidden_fact(
                false,
                &["requirements-digest-mismatch", "issuer-not-vetter"],
            ),
            hidden_fact(false, &["token-label-not-live", "issuer-not-vetter"]),
            hidden_fact(false, &["requirements-digest-mismatch"]),
            hidden_fact(true, &[]),
        ]);
        assert_eq!(
            reasons,
            ["requirements-digest-mismatch", "token-label-not-live"]
        );
        // With nothing more specific, the counting rule's own code is all there is, and stays.
        assert_eq!(
            hidden_reasons(&[hidden_fact(false, &["issuer-not-vetter"])]),
            ["issuer-not-vetter"]
        );
        assert!(hidden_reasons(&[hidden_fact(true, &[])]).is_empty());
    }

    /// The hidden needs are outside the SDK's `Need` grammar on purpose, so a client that does
    /// not know them shows them as they came rather than misreading them as a count.
    #[test]
    fn a_hidden_need_is_not_a_count() {
        for code in [
            HIDDEN_NO_PROOF,
            HIDDEN_UNSUPPORTED,
            "requirements-digest-mismatch",
        ] {
            let need = format!("{NEED_HIDDEN_PREFIX}{code}");
            assert!(vta_sdk::vetting::requirements::Need::parse(&need).is_none());
        }
    }

    fn facts_with_needs(needs: &[&str]) -> VettingFacts {
        VettingFacts {
            criterion_id: "a".into(),
            requirements_digest: "za".into(),
            applicant_digest_matches: true,
            statements: vec![],
            distinct_counted_vetters: 1,
            by_method: BTreeMap::new(),
            commitments_consistent: true,
            independence_ok: true,
            invitation_required: false,
            satisfied: false,
            needs: needs.iter().map(|s| s.to_string()).collect(),
        }
    }

    #[test]
    fn a_generic_vetting_need_becomes_the_precise_shortfall() {
        let mut needs = vec!["agreed:code-of-conduct".into(), NEED_VETTING.into()];
        let f = facts_with_needs(&["vetting:statements:1", "vetting:method:inPerson:1"]);
        expand_needs(&mut needs, Some(&f));
        assert_eq!(
            needs,
            vec![
                "agreed:code-of-conduct",
                "vetting:statements:1",
                "vetting:method:inPerson:1"
            ]
        );
    }

    #[test]
    fn needs_are_left_alone_without_facts_or_a_specific_shortfall() {
        let mut needs = vec![NEED_VETTING.to_string()];
        expand_needs(&mut needs, None);
        assert_eq!(needs, vec![NEED_VETTING]);
        expand_needs(&mut needs, Some(&facts_with_needs(&[])));
        assert_eq!(needs, vec![NEED_VETTING]);
    }

    #[test]
    fn only_vetting_statements_are_picked_out_of_a_presentation() {
        let vp = json!({
            "verifiableCredential": [
                { "credentialSubject": { "predicate": VETTED_PREDICATE } },
                { "credentialSubject": { "predicate": dtg_credentials::ENDORSES_V1 } },
                { "credentialSubject": { "predicate": "dtg:vetted" } },
                { "type": ["VerifiableCredential", "InvitationCredential"] },
                "eyJhbGciOi.jwt.vc"
            ]
        });
        assert_eq!(vetting_credentials(&vp, VETTED_PREDICATE).count(), 1);
    }
}