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};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvenanceKind {
#[default]
SelfAsserted,
CredentialBacked,
Generated,
Derived,
}
impl ProvenanceKind {
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,
}
}
#[must_use]
pub fn is_editable_here(self) -> bool {
matches!(self, Self::SelfAsserted)
}
#[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",
}
}
#[must_use]
pub fn linkage(self) -> Option<&'static str> {
match self {
Self::SelfAsserted => None,
Self::Derived => None,
Self::CredentialBacked => Some("same signature everywhere — links you"),
Self::Generated => Some("different for everyone — cannot link you"),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PoolAttribute {
pub attribute_id: String,
pub claim_type: String,
pub label: Option<String>,
pub value_type: String,
pub value: Option<Value>,
pub provenance: ProvenanceKind,
pub stale: bool,
pub stale_reason: Option<String>,
pub version: u64,
pub updated_at: String,
#[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(),
}
}
#[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)",
}
}
#[must_use]
pub fn claim_defaults(&self, registry: &Registry) -> ClaimTypeDefaults {
registry.resolve(&self.claim_type)
}
#[must_use]
pub fn is_masked(&self, registry: &Registry) -> bool {
!self.stale && self.value.is_some() && self.claim_defaults(registry).masks_by_default()
}
#[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()
}
#[must_use]
pub fn display_value(&self, registry: &Registry, values_requested: bool) -> String {
self.value_line(registry, values_requested, false)
}
#[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(),
}
}
}
#[derive(Clone, Debug)]
pub struct AttributeDraft {
pub attribute_id: Option<String>,
pub expected_version: Option<u64>,
pub claim_type: String,
pub label: Option<String>,
pub value: Value,
pub value_type: ValueType,
pub endorsements: Vec<String>,
}
impl Default for AttributeDraft {
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(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AttributeEdit {
Written(String),
Refused(String),
}
impl AttributeEdit {
#[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()
}
})
}
}
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();
attributes.sort_by(|a, b| {
a.claim_type
.cmp(&b.claim_type)
.then_with(|| a.display_name().cmp(b.display_name()))
});
Ok(attributes)
}
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}")))?,
))),
Err(e) if format!("{e}").contains("notFound") => Ok(None),
Err(e) => Err(OpenVTCError::Vta(format!(
"persona attribute reveal failed: {e}"
))),
}
}
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(),
))
}
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(())
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Purged {
pub versions: Vec<u64>,
pub stale_pins: usize,
}
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(),
})
}
#[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,
}
}
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;
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",
})
}
#[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
);
}
#[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());
}
#[test]
fn the_three_reasons_for_an_absent_value_read_differently() {
let mut attr = PoolAttribute::from_wire(&wire("selfAsserted"));
assert_eq!(attr.display_value(®(), false), "(hidden)");
assert_eq!(attr.display_value(®(), true), "(no value)");
attr.stale = true;
assert!(
attr.display_value(®(), true)
.contains("can no longer be proven")
);
attr.stale_reason = Some("revoked".into());
assert_eq!(
attr.display_value(®(), true),
"stale · revoked — can no longer be proven"
);
}
#[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(®(), true), "Alice");
}
#[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());
}
#[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(®()));
assert_eq!(attr.display_value(®(), true), "••••••••••••4242");
assert_eq!(attr.revealed_value(®(), true), "4242424242424242");
}
#[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(®()));
assert_eq!(attr.display_value(®(), true), "Alice");
}
#[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(®()));
assert_eq!(attr.display_value(®(), true), "a•••@example.com");
assert_eq!(attr.revealed_value(®(), true), "alice@example.com");
}
#[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(®()));
assert_eq!(attr.display_value(®(), true), "••••••••");
}
#[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(®()), "nothing held is nothing to mask");
assert_eq!(attr.display_value(®(), true), "(no value)");
assert_eq!(attr.display_value(®(), false), "(hidden)");
attr.value = Some(Value::String("1990-01-01".into()));
assert!(attr.is_masked(®()));
}
#[test]
fn a_withheld_sensitive_value_is_distinct_from_absent() {
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;
attr.claim_type = sensitive.into();
assert!(attr.is_withheld_sensitive(®(), true));
attr.claim_type = ordinary.into();
assert!(!attr.is_withheld_sensitive(®(), true));
attr.claim_type = sensitive.into();
assert!(!attr.is_withheld_sensitive(®(), false));
attr.value = Some(Value::String("held".into()));
assert!(!attr.is_withheld_sensitive(®(), true));
}
#[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(®()));
assert_eq!(
attr.display_value(®(), true),
"stale · revoked — can no longer be proven"
);
}
#[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");
}
}