openvtc-core 0.4.0

OpenVTC Core Library
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
//! The attribute pool — the attributes themselves, held once and projected many
//! times. **Holder-scoped**: above every trust context, and never readable
//! from inside one.
//!
//! # Values are opt-in, at every layer
//!
//! [`list`] takes `include_values` and defaults callers towards `false` for the
//! same reason the Trust Task does: "how many phone numbers do I hold" and "read
//! me the holder's identity" are different questions, and only one of them needs
//! plaintext. A picker needs type and label; a panel that renders values needs
//! the operator to have asked.
//!
//! # What this module will and will not author
//!
//! [`put`] writes **self-asserted** attributes only, and [`AttributeDraft`]
//! has no `provenance` field at all — the constraint is structural rather than
//! a default someone can pass a different value for.
//!
//! A credential-backed attribute names a `credentialId` and a `claimPath` into
//! it, and its value is re-derived from the credential rather than stored: an
//! editor that let a holder type over one would be manufacturing an attested
//! claim out of a typed string, which is precisely the escalation provenance
//! exists to prevent. A generated attribute is minted by the agent per verifier
//! and has no plaintext to edit at all. Both are returned by [`list`] — a
//! holder must see everything they hold — and both are refused by [`put`],
//! which returns [`AttributeEdit::refusal`] naming why. Changing one is a `pnm`
//! operation against its source.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use vta_sdk::client::VtaClient;
use vta_sdk::protocols::persona::{Provenance, ValueType};

use crate::errors::OpenVTCError;
use crate::persona::claim_types::{ClaimTypeDefaults, Registry};

/// Where an attribute's value came from, reduced to what a panel can act on.
///
/// The SDK's [`Provenance`] carries the credential id, claim path and proof
/// rung. None of those are editable here and all of them are long, so this
/// keeps the distinction that governs behaviour — may the holder retype the
/// value? — and drops the rest.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvenanceKind {
    /// The holder typed it. The only kind this module authors.
    #[default]
    SelfAsserted,
    /// Backed by a credential the holder holds; the value is derived from it.
    CredentialBacked,
    /// Minted by the agent, usually per verifier (a relay address, an alias).
    Generated,
    /// Taken from a source the holder connected or supplied — a profile, a CV
    /// — that nobody signed. Not editable here: rewriting it would make a
    /// derived value self-asserted by an edit the holder did not mean as one.
    Derived,
}

impl ProvenanceKind {
    /// Parse the `provenance` member of a wire attribute.
    ///
    /// An unrecognised discriminator resolves to [`CredentialBacked`], not
    /// [`SelfAsserted`]: this value gates editing, and the safe answer for a
    /// provenance a newer VTA introduced is "this build does not know how to
    /// author it". Guessing the other way would let a future attested kind be
    /// overwritten with a typed string by a build that never heard of it.
    ///
    /// [`CredentialBacked`]: ProvenanceKind::CredentialBacked
    /// [`SelfAsserted`]: ProvenanceKind::SelfAsserted
    pub(crate) fn parse_wire(value: Option<&Value>) -> Self {
        match value.and_then(|p| p.get("kind")).and_then(Value::as_str) {
            Some("selfAsserted") => Self::SelfAsserted,
            Some("generated") => Self::Generated,
            Some("derived") => Self::Derived,
            _ => Self::CredentialBacked,
        }
    }

    /// Whether this build may rewrite the attribute's value. See the module
    /// header for why only one kind qualifies.
    #[must_use]
    pub fn is_editable_here(self) -> bool {
        matches!(self, Self::SelfAsserted)
    }

    /// What a person reads for this provenance
    /// (`design-docs/persona-vocabulary.md`). The spec's words stay in the
    /// type; they are kept off the screen.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            Self::SelfAsserted => "you said so",
            Self::CredentialBacked => "credential",
            Self::Generated => "made per verifier",
            Self::Derived => "from a source you connected",
        }
    }

    /// Whether this value links the holder across everyone who sees it, said in
    /// the words the table uses.
    ///
    /// Always shown beside the label, because it is the half people miss: a
    /// credential is *provable* and carries the same issuer signature to every
    /// verifier, which is the more consequential of the two attributes and the less
    /// obvious one. Severity inverts intuition here — a credential shown whole
    /// links more than a value the holder simply asserted — so the words must
    /// not hide it.
    #[must_use]
    pub fn linkage(self) -> Option<&'static str> {
        match self {
            // Passed on, never proven, and no signature to join on.
            Self::SelfAsserted => None,
            // Nobody signed it either: it links exactly as a typed value does,
            // when it is reused.
            Self::Derived => None,
            Self::CredentialBacked => Some("same signature everywhere — links you"),
            Self::Generated => Some("different for everyone — cannot link you"),
        }
    }
}

/// One attribute in the holder's pool, as a panel needs it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PoolAttribute {
    /// Stable identifier — what a profile entry references.
    pub attribute_id: String,
    /// Vocabulary token: `name.legal`, `phone.mobile`. The store's own.
    pub claim_type: String,
    /// The holder's own words for their own picker. Never disclosed.
    pub label: Option<String>,
    /// `string` / `number` / `boolean` / `date` / `object`, as the VTA spells it.
    pub value_type: String,
    /// The value, present only when the read asked for values **and** the store
    /// could produce one.
    pub value: Option<Value>,
    pub provenance: ProvenanceKind,
    /// A credential-backed value that could not be re-derived. Distinct from an
    /// absent [`value`](Self::value), which usually just means the read did not
    /// ask for one.
    pub stale: bool,
    /// Why it went stale — `expired`, `revoked`, and so on, as the agent says
    /// it. Shown beside the word, never instead of it: "stale" alone tells a
    /// holder something is wrong without telling them what.
    pub stale_reason: Option<String>,
    /// Optimistic-concurrency token, passed back on edit so two editors cannot
    /// silently overwrite each other.
    pub version: u64,
    pub updated_at: String,
    /// Vault ids of credentials in which someone endorses this value.
    /// Inventory, not evidence: it never changes the provenance. Carried so an
    /// edit sends them back — a put replaces the record.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub endorsements: Vec<String>,
}

impl PoolAttribute {
    fn from_wire(value: &Value) -> Self {
        let str_field = |key: &str| {
            value
                .get(key)
                .and_then(Value::as_str)
                .map(str::to_string)
                .unwrap_or_default()
        };
        Self {
            attribute_id: str_field("attributeId"),
            claim_type: str_field("type"),
            label: value
                .get("label")
                .and_then(Value::as_str)
                .map(str::to_string),
            value_type: str_field("valueType"),
            value: value.get("value").cloned(),
            provenance: ProvenanceKind::parse_wire(value.get("provenance")),
            stale: value.get("stale").and_then(Value::as_bool).unwrap_or(false),
            stale_reason: value
                .get("staleReason")
                .and_then(Value::as_str)
                .map(str::to_string),
            version: value.get("version").and_then(Value::as_u64).unwrap_or(0),
            updated_at: str_field("updatedAt"),
            endorsements: value
                .get("endorsements")
                .and_then(Value::as_array)
                .map(|ids| {
                    ids.iter()
                        .filter_map(|id| id.as_str().map(str::to_string))
                        .collect()
                })
                .unwrap_or_default(),
        }
    }

    /// The name to show: the holder's label, else the vocabulary token. Never
    /// empty, so a row cannot render as a blank line.
    #[must_use]
    pub fn display_name(&self) -> &str {
        match self.label.as_deref() {
            Some(label) if !label.trim().is_empty() => label,
            _ if !self.claim_type.is_empty() => &self.claim_type,
            _ => "(unnamed attribute)",
        }
    }

    /// What this attribute's claim type says about showing its value.
    #[must_use]
    pub fn claim_defaults(&self, registry: &Registry) -> ClaimTypeDefaults {
        registry.resolve(&self.claim_type)
    }

    /// Whether [`display_value`](Self::display_value) is showing a reduced form
    /// of a value we are holding.
    ///
    /// The caller needs this to say *masked* rather than let the row read as
    /// empty: `••••••••` and "(no value)" are one glance apart, and one of them
    /// is a wrong answer about what the holder holds.
    #[must_use]
    pub fn is_masked(&self, registry: &Registry) -> bool {
        !self.stale && self.value.is_some() && self.claim_defaults(registry).masks_by_default()
    }

    /// Whether this row is valueless *because it is sensitive and the listing
    /// did not fetch sensitive values*, rather than genuinely absent.
    ///
    /// The pane uses this to offer `s` (a per-attribute reveal that fetches the
    /// one value) and to render "sensitive — press s" instead of "(no value)":
    /// `••••••••`, "(no value)" and a withheld card number are one glance apart,
    /// and confusing them misinforms the holder about what they hold. Only
    /// meaningful once values were requested (`values_requested`): in picker mode
    /// nothing has a value and none of it is "withheld".
    #[must_use]
    pub fn is_withheld_sensitive(&self, registry: &Registry, values_requested: bool) -> bool {
        values_requested
            && !self.stale
            && self.value.is_none()
            && self.claim_defaults(registry).is_sensitive()
    }

    /// The value as one line, or the reason there is none — masked when its
    /// claim type asks for that.
    ///
    /// Three readings kept apart on purpose, because collapsing any two of them
    /// misinforms the holder about their own data: we did not ask; we asked and
    /// the source could not answer; here it is. Masking adds a fourth — *we
    /// have it and are not painting it* — which is why it is
    /// [`is_masked`](Self::is_masked) rather than a fourth string here.
    #[must_use]
    pub fn display_value(&self, registry: &Registry, values_requested: bool) -> String {
        self.value_line(registry, values_requested, false)
    }

    /// The same line with the mask lifted, for a holder who asked for this one
    /// value.
    ///
    /// A separate method rather than a `reveal: bool` on
    /// [`display_value`](Self::display_value), so that reading a masked value
    /// in the clear is something a call site had to *name*. A boolean
    /// gets passed through, and the caller that ends up passing `true` is
    /// rarely the one that meant to.
    #[must_use]
    pub fn revealed_value(&self, registry: &Registry, values_requested: bool) -> String {
        self.value_line(registry, values_requested, true)
    }

    fn value_line(&self, registry: &Registry, values_requested: bool, reveal: bool) -> String {
        if self.stale {
            return match &self.stale_reason {
                Some(reason) => format!("stale · {reason} — can no longer be proven"),
                None => "stale — can no longer be proven".to_string(),
            };
        }
        let shown = |text: String| {
            if reveal {
                text
            } else {
                self.claim_defaults(registry).render(&text)
            }
        };
        match &self.value {
            Some(Value::String(s)) => shown(s.clone()),
            Some(other) => shown(other.to_string()),
            None if values_requested => "(no value)".to_string(),
            None => "(hidden)".to_string(),
        }
    }
}

/// What [`put`] needs to write a self-asserted attribute.
///
/// Carries no provenance: see the module header.
#[derive(Clone, Debug)]
pub struct AttributeDraft {
    /// `None` creates; `Some` updates that attribute.
    pub attribute_id: Option<String>,
    /// The version the editor was opened against, so a concurrent edit is
    /// refused rather than silently overwritten. `None` when creating.
    pub expected_version: Option<u64>,
    pub claim_type: String,
    pub label: Option<String>,
    pub value: Value,
    pub value_type: ValueType,
    /// The endorsements the attribute already has, sent back unchanged. This
    /// editor has no control for them; dropping them would lose a vouch by
    /// fixing a label.
    pub endorsements: Vec<String>,
}

impl Default for AttributeDraft {
    /// An empty, self-asserted string attribute — what an editor opens on.
    /// [`ValueType`] has no `Default` of its own, and `String` is the only
    /// honest choice here: it is the one type a blank text field can hold
    /// without the form having to guess what the holder meant.
    fn default() -> Self {
        Self {
            attribute_id: None,
            expected_version: None,
            claim_type: String::new(),
            label: None,
            value: Value::Null,
            value_type: ValueType::String,
            endorsements: Vec::new(),
        }
    }
}

/// The outcome of an attempted write.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AttributeEdit {
    /// The attribute the VTA created or updated.
    Written(String),
    /// This build declined to author the change, with the reason to show.
    Refused(String),
}

impl AttributeEdit {
    /// The refusal a non-self-asserted attribute earns, worded for the panel.
    #[must_use]
    pub fn refusal(kind: ProvenanceKind) -> Self {
        Self::Refused(match kind {
            ProvenanceKind::CredentialBacked => {
                "This attribute comes from a credential — typing over it would turn something \
                 provable into something you said. Change it at its source, or replace the \
                 credential."
                    .to_string()
            }
            ProvenanceKind::Generated => {
                "Your agent makes this one per verifier — a different value for everyone, so \
                 there is no single value to edit."
                    .to_string()
            }
            ProvenanceKind::Derived => {
                "This one was taken from a source you connected — typing over it here would \
                 make it something you said instead. Change it at the source and take it again."
                    .to_string()
            }
            ProvenanceKind::SelfAsserted => {
                "You said this one, so it is editable; nothing should have refused it.".to_string()
            }
        })
    }
}

/// Enumerate the pool.
///
/// Two independent escalations, both the holder's to make:
///
/// - `include_values` — the difference between a picker and a read of the
///   holder's identity (see the module header).
/// - `include_sensitive` — vta-sdk's *second* escalation, and it only ever
///   widens the first. It is kept separate on purpose. The bulk listing behind
///   the pane's `show_values` (`v`) passes `false`, so it does **not** carry
///   every `sensitivity: high` value (card numbers, passport ids) into this
///   process; a `sensitivity: high` attribute then comes back valueless, which
///   the pane renders as "sensitive — press s" rather than "(no value)" (it
///   knows the type is sensitive). The per-attribute reveal ([`reveal`]) fetches
///   that one value on its own with `include_sensitive: true`, so the default
///   read stays lean and the reveal stays truthful.
pub async fn list(
    client: &VtaClient,
    include_values: bool,
    include_sensitive: bool,
) -> Result<Vec<PoolAttribute>, OpenVTCError> {
    let value = client
        .persona_attribute_list(None, include_values, include_sensitive, None, None, None)
        .await
        .map_err(|e| OpenVTCError::Vta(format!("persona attribute list failed: {e}")))?;

    let mut attributes: Vec<PoolAttribute> = value
        .get("attributes")
        .and_then(Value::as_array)
        .map(|rows| rows.iter().map(PoolAttribute::from_wire).collect())
        .unwrap_or_default();
    // Display order the holder can predict. The store returns insertion order,
    // which is the order things happened to be typed in — fine for a machine,
    // useless for finding "the work email" in a list of thirty.
    attributes.sort_by(|a, b| {
        a.claim_type
            .cmp(&b.claim_type)
            .then_with(|| a.display_name().cmp(b.display_name()))
    });
    Ok(attributes)
}

/// Fetch one attribute's value, sensitive values included, for the per-attribute
/// reveal (`s`) — so the holder gets the one value they asked for without the
/// bulk [`list`] having carried every sensitive value into memory.
///
/// One task, one attribute. This used to be a `typePrefix`-scoped listing
/// filtered here to `attribute_id`, because the family had no by-id read: to
/// show one email address the agent decrypted every email address the holder
/// has and sent them all, and its audit trail recorded a listing of the pool
/// rather than a decision about one value. `persona/attribute/get` is that
/// read, and the claim type is no longer a parameter because nothing needs it.
///
/// Returns `Ok(None)` when the store no longer has it — deleted, or renamed
/// out from under the pane.
pub async fn reveal(
    client: &VtaClient,
    attribute_id: &str,
) -> Result<Option<PoolAttribute>, OpenVTCError> {
    match client
        .persona_attribute_get(attribute_id, true, true, None)
        .await
    {
        Ok(response) => Ok(Some(PoolAttribute::from_wire(
            &serde_json::to_value(&response.attribute)
                .map_err(|e| OpenVTCError::Vta(format!("persona attribute get: {e}")))?,
        ))),
        // `notFound` is the answer to "is it still there", not a failure: the
        // pane asks about a row the holder is looking at, and the row can have
        // gone since it was drawn.
        Err(e) if format!("{e}").contains("notFound") => Ok(None),
        Err(e) => Err(OpenVTCError::Vta(format!(
            "persona attribute reveal failed: {e}"
        ))),
    }
}

/// Create or update a self-asserted attribute.
///
/// A create is a `put` with no `attributeId`; the VTA mints one and returns it.
pub async fn put(client: &VtaClient, draft: AttributeDraft) -> Result<AttributeEdit, OpenVTCError> {
    let response = client
        .persona_attribute_put(
            &draft.claim_type,
            draft.value,
            draft.value_type,
            Provenance::SelfAsserted,
            draft.label.as_deref(),
            draft.endorsements,
            draft.attribute_id.as_deref(),
            draft.expected_version,
        )
        .await
        .map_err(|e| OpenVTCError::Vta(format!("persona attribute write failed: {e}")))?;

    Ok(AttributeEdit::Written(
        response
            .get("attributeId")
            .and_then(Value::as_str)
            .map(str::to_string)
            .or(draft.attribute_id)
            .unwrap_or_default(),
    ))
}

/// Remove an attribute.
///
/// Without `cascade` the VTA refuses while a profile still references it — the
/// alternative is profiles quietly presenting a dangling reference. The caller
/// is expected to surface that refusal and ask, rather than retrying with
/// `cascade` on the holder's behalf: cascading edits every profile that used
/// the attribute, and that is not a consequence to infer from a `d` keypress.
pub async fn delete(
    client: &VtaClient,
    attribute_id: &str,
    cascade: bool,
) -> Result<(), OpenVTCError> {
    client
        .persona_attribute_delete(attribute_id, cascade, None)
        .await
        .map_err(|e| OpenVTCError::Vta(format!("persona attribute delete failed: {e}")))?;
    Ok(())
}

/// What a purge took away, and what it cost.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Purged {
    /// The versions actually removed. Empty when none named was still held.
    pub versions: Vec<u64>,
    /// Faces that pinned one of them and now present nothing for that entry.
    ///
    /// The count is the point: a purge that quietly shortened what three faces
    /// show is exactly the surprise this family exists to prevent, so a caller
    /// is expected to say so rather than report a bare "done".
    pub stale_pins: usize,
}

/// Forget earlier versions of one attribute.
///
/// `versions: None` purges every version but the current one. Irreversible —
/// retention by reference is what makes "what did I show them in March"
/// answerable, and this is the holder's explicit override of it.
pub async fn purge_versions(
    client: &VtaClient,
    attribute_id: &str,
    versions: Option<&[u64]>,
) -> Result<Purged, OpenVTCError> {
    let value = client
        .persona_attribute_purge_version(attribute_id, versions)
        .await
        .map_err(|e| OpenVTCError::Vta(format!("persona attribute purge failed: {e}")))?;
    Ok(Purged {
        versions: value.purged.iter().map(|v| v.0.get()).collect(),
        stale_pins: value.stale_pins.len(),
    })
}

/// Parse the `valueType` string a [`PoolAttribute`] carries back into the typed
/// form [`put`] needs. Unknown types read as [`ValueType::String`], which is
/// what an editor can actually offer a text field for.
#[must_use]
pub fn value_type_from_str(s: &str) -> ValueType {
    match s {
        "number" => ValueType::Number,
        "boolean" => ValueType::Boolean,
        "date" => ValueType::Date,
        "object" => ValueType::Object,
        _ => ValueType::String,
    }
}

/// Turn typed text into the JSON value its declared type calls for.
///
/// Returns the parse failure as text a form can show next to the field. A
/// `number` field that quietly stored `"12"` as a string would present a value
/// no predicate proof could ever compare against.
pub fn parse_typed_value(text: &str, value_type: ValueType) -> Result<Value, String> {
    let trimmed = text.trim();
    match value_type {
        ValueType::String | ValueType::Date => Ok(Value::String(trimmed.to_string())),
        ValueType::Number => trimmed
            .parse::<f64>()
            .map_err(|_| format!("`{trimmed}` is not a number"))
            .and_then(|n| {
                serde_json::Number::from_f64(n)
                    .map(Value::Number)
                    .ok_or_else(|| format!("`{trimmed}` is not a finite number"))
            }),
        ValueType::Boolean => match trimmed.to_ascii_lowercase().as_str() {
            "true" | "yes" | "y" | "1" => Ok(Value::Bool(true)),
            "false" | "no" | "n" | "0" => Ok(Value::Bool(false)),
            _ => Err(format!("`{trimmed}` is not true or false")),
        },
        ValueType::Object => {
            serde_json::from_str(trimmed).map_err(|e| format!("not valid JSON: {e}"))
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::persona::claim_types::Registry;

    /// The compiled copy — spec 0.1 — as the table these tests resolve
    /// against. A unit test must not depend on what a live agent happens to
    /// serve, and every assertion below is about a token 0.1 declares.
    fn reg() -> Registry {
        Registry::vendored()
    }

    use super::*;

    fn wire(provenance: &str) -> Value {
        serde_json::json!({
            "attributeId": "01J8",
            "type": "email.work",
            "valueType": "string",
            "provenance": { "kind": provenance },
            "version": 3,
            "updatedAt": "2026-09-06T00:00:00Z",
        })
    }

    /// The three provenance kinds the panel branches on, round-tripped from the
    /// shape the VTA actually sends.
    #[test]
    fn provenance_parses_from_its_discriminator() {
        assert_eq!(
            PoolAttribute::from_wire(&wire("selfAsserted")).provenance,
            ProvenanceKind::SelfAsserted
        );
        assert_eq!(
            PoolAttribute::from_wire(&wire("generated")).provenance,
            ProvenanceKind::Generated
        );
        assert_eq!(
            PoolAttribute::from_wire(&wire("credentialBacked")).provenance,
            ProvenanceKind::CredentialBacked
        );
    }

    /// A provenance this build has never heard of must not become editable.
    ///
    /// The failure this guards is quiet and one-directional: a newer VTA adds
    /// an attested kind, an older build parses it as self-asserted, and the
    /// holder retypes an attested value into a typed one from a panel that
    /// believed it was allowed to.
    #[test]
    fn an_unknown_provenance_is_not_editable() {
        let attr = PoolAttribute::from_wire(&wire("someFutureKind"));
        assert!(!attr.provenance.is_editable_here());
        let missing = PoolAttribute::from_wire(&serde_json::json!({ "attributeId": "01J8" }));
        assert!(!missing.provenance.is_editable_here());
    }

    /// "We did not ask", "there is nothing", and "the source failed" are three
    /// different sentences. A holder reading their own pool has to be able to
    /// tell them apart — collapsing them is how a panel says "you hold no phone
    /// number" about a value it simply never requested.
    #[test]
    fn the_three_reasons_for_an_absent_value_read_differently() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        assert_eq!(attr.display_value(&reg(), false), "(hidden)");
        assert_eq!(attr.display_value(&reg(), true), "(no value)");
        attr.stale = true;
        assert!(
            attr.display_value(&reg(), true)
                .contains("can no longer be proven")
        );
        // The reason is shown beside the word, never instead of it: "stale"
        // alone says something is wrong without saying what.
        attr.stale_reason = Some("revoked".into());
        assert_eq!(
            attr.display_value(&reg(), true),
            "stale · revoked — can no longer be proven"
        );
    }

    /// A string value renders as itself, not as a quoted JSON string — the
    /// panel shows `Alice`, never `"Alice"`. An unmasked type, so the
    /// assertion is about the quoting and not about the mask.
    #[test]
    fn a_string_value_renders_unquoted() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        attr.claim_type = "name.given".into();
        attr.value = Some(Value::String("Alice".into()));
        assert_eq!(attr.display_value(&reg(), true), "Alice");
    }

    /// A typed field stores the type it declares. The number case is the one
    /// that matters: `"30"` and `30` compare differently, and a predicate proof
    /// over the string form can never be satisfied.
    #[test]
    fn typed_values_parse_to_their_declared_type() {
        assert_eq!(
            parse_typed_value("30", ValueType::Number).unwrap(),
            serde_json::json!(30.0)
        );
        assert_eq!(
            parse_typed_value(" yes ", ValueType::Boolean).unwrap(),
            Value::Bool(true)
        );
        assert_eq!(
            parse_typed_value(r#"{"a":1}"#, ValueType::Object).unwrap(),
            serde_json::json!({"a": 1})
        );
        assert!(parse_typed_value("thirty", ValueType::Number).is_err());
        assert!(parse_typed_value("maybe", ValueType::Boolean).is_err());
    }

    /// A value whose type carries a mask style is masked, and reads back whole
    /// only when a caller asks for that one value.
    ///
    /// The pairing is the point: the mask has to be liftable, or a holder
    /// cannot check their own card number; and lifting it has to be a
    /// different call, or it is not a decision anyone made.
    #[test]
    fn a_masked_value_is_only_whole_when_it_is_asked_for() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        attr.claim_type = "payment.card".into();
        attr.value = Some(Value::String("4242424242424242".into()));

        assert!(attr.is_masked(&reg()));
        assert_eq!(attr.display_value(&reg(), true), "••••••••••••4242");
        assert_eq!(attr.revealed_value(&reg(), true), "4242424242424242");
    }

    /// A type whose style is `none` is shown as it is held. Masking every attribute
    /// would teach the reveal key as a reflex, and a reveal pressed by reflex
    /// protects nothing.
    #[test]
    fn a_value_with_no_mask_style_is_shown_whole() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        attr.claim_type = "name.given".into();
        attr.value = Some(Value::String("Alice".into()));
        assert!(!attr.is_masked(&reg()));
        assert_eq!(attr.display_value(&reg(), true), "Alice");
    }

    /// Sensitivity is not what triggers the mask — the style is. An email
    /// address is `normal` and still masked: worth hiding from the person
    /// behind you without being worth withholding from a listing.
    #[test]
    fn a_normal_type_with_a_style_is_still_masked() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        attr.value = Some(Value::String("alice@example.com".into()));
        assert!(attr.is_masked(&reg()));
        assert_eq!(attr.display_value(&reg(), true), "a•••@example.com");
        assert_eq!(attr.revealed_value(&reg(), true), "alice@example.com");
    }

    /// A vocabulary this build has never seen is masked, because nothing here
    /// knows what it holds. Same rule for the open `x:` namespace.
    #[test]
    fn an_unregistered_type_is_masked() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        attr.claim_type = "x:employer.badge".into();
        attr.value = Some(Value::String("A-1174".into()));
        assert!(attr.is_masked(&reg()));
        assert_eq!(attr.display_value(&reg(), true), "••••••••");
    }

    /// Masked and absent are different states, and a caller has to be able to
    /// tell them apart — `••••••••` and "(no value)" are one glance apart on a
    /// row, and one of them is a wrong answer about what the holder holds.
    #[test]
    fn masked_is_not_the_same_state_as_absent() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        attr.claim_type = "person.birthDate".into();
        assert!(!attr.is_masked(&reg()), "nothing held is nothing to mask");
        assert_eq!(attr.display_value(&reg(), true), "(no value)");
        assert_eq!(attr.display_value(&reg(), false), "(hidden)");

        attr.value = Some(Value::String("1990-01-01".into()));
        assert!(attr.is_masked(&reg()));
    }

    /// A value withheld because it is sensitive is "not loaded", a third state
    /// distinct from masked (in memory) and absent (nothing held) — so the pane
    /// can offer `s` to fetch it rather than render "(no value)".
    #[test]
    fn a_withheld_sensitive_value_is_distinct_from_absent() {
        // Fixture assumptions, asserted so a classification change fails loudly.
        let sensitive = "medical.condition";
        let ordinary = "name.given";
        assert!(reg().resolve(sensitive).is_sensitive());
        assert!(!reg().resolve(ordinary).is_sensitive());

        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        attr.value = None;

        // Sensitive + valueless + values requested → withheld (fetchable via `s`).
        attr.claim_type = sensitive.into();
        assert!(attr.is_withheld_sensitive(&reg(), true));
        // Non-sensitive + valueless → genuinely absent, not withheld.
        attr.claim_type = ordinary.into();
        assert!(!attr.is_withheld_sensitive(&reg(), true));
        // Picker mode (values not requested): nothing is "withheld".
        attr.claim_type = sensitive.into();
        assert!(!attr.is_withheld_sensitive(&reg(), false));
        // Once the value is in hand it is no longer withheld.
        attr.value = Some(Value::String("held".into()));
        assert!(!attr.is_withheld_sensitive(&reg(), true));
    }

    /// A stale value keeps saying it is stale. The reason it cannot be shown is
    /// not that it is masked, and a mask over it would hide the one thing the
    /// holder needs to act on.
    #[test]
    fn a_stale_masked_value_still_says_it_is_stale() {
        let mut attr = PoolAttribute::from_wire(&wire("credentialBacked"));
        attr.claim_type = "gov.id.passport".into();
        attr.value = Some(Value::String("P1234567".into()));
        attr.stale = true;
        attr.stale_reason = Some("revoked".into());

        assert!(!attr.is_masked(&reg()));
        assert_eq!(
            attr.display_value(&reg(), true),
            "stale · revoked — can no longer be proven"
        );
    }

    /// A label is what the holder sees; falling back to the vocabulary token
    /// keeps an unlabelled row identifiable rather than blank.
    #[test]
    fn display_name_falls_back_to_the_type() {
        let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
        assert_eq!(attr.display_name(), "email.work");
        attr.label = Some("  ".into());
        assert_eq!(
            attr.display_name(),
            "email.work",
            "a blank label is no label"
        );
        attr.label = Some("Work email".into());
        assert_eq!(attr.display_name(), "Work email");
    }
}