Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 144 additions & 2 deletions openvtc-core/src/persona/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ pub struct BindingSummary {
/// "presents nothing" — a confident wrong answer about the user's own
/// identity, which is the one thing this panel must not give.
pub unknown: bool,
/// A face worn by the same persona in the **parent** context, when it
/// wears nothing here.
///
/// Not part of the agent's answer, and **not something this community
/// sees**: the VTA keys a binding on an exact `(context_id, persona_did)`
/// pair and walks no hierarchy, and the `materialised_claims` a disclosure
/// draws on go through that same exact lookup. It is carried because the
/// alternative is a bare
/// "wears: nothing" in front of a holder who has just configured a face and
/// can see it on another surface, with nothing on screen to explain the
/// difference.
pub parent: Option<ParentBinding>,
}

/// What the same persona wears one context up.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ParentBinding {
/// The context the binding was found in — the sub-context's parent.
pub context_id: String,
/// The bound profile's label, or its identifier when it has no label.
pub label: String,
}

impl BindingSummary {
Expand All @@ -91,7 +112,18 @@ impl BindingSummary {
return "wears: unknown".to_string();
}
if !self.bound {
return "wears: nothing".to_string();
// A fourth reading, and the one a holder is most likely to arrive
// at confused: nothing is worn *here*, but the same persona wears
// something one context up. Saying only "nothing" is true and
// useless — they can see the face on another surface and have no
// way to tell why this one disagrees.
return match &self.parent {
Some(p) => format!(
"wears: nothing here — {} is worn in {}, which this community does not see",
p.label, p.context_id
),
None => "wears: nothing".to_string(),
};
}
let label = self
.profile_name
Expand Down Expand Up @@ -142,20 +174,68 @@ pub async fn get(
.and_then(serde_json::Value::as_str)
.map(str::to_string),
unknown: false,
// Filled by `get_or_unknown` when this answer is "nothing", not here:
// `get` reports one context, which is exactly what the agent was asked.
parent: None,
})
}

/// Ask what the same persona wears in `sub_context_id`'s **parent**.
///
/// Only ever called when the sub-context itself came back unbound, and only to
/// explain that answer rather than to change it. The VTA keys a binding on an
/// exact `(context_id, persona_did)` pair and walks no hierarchy — both
/// `binding_summary` and the `materialised_claims` a disclosure draws on read
/// through the same exact lookup — so a face worn in the parent is genuinely
/// **not** what this community sees. The panel says so in those words.
///
/// The parent is derived with [`context_path::parse_sub_context_id`], never by
/// hand: a top context may itself be nested, so the split is on the *last* `/`.
/// An id with no `/` is not a sub-context and has no parent to ask about.
///
/// Best-effort like every other binding read — a failure here must not turn a
/// perfectly good "wears: nothing" into an error.
///
/// [`context_path::parse_sub_context_id`]: crate::config::context_path::parse_sub_context_id
async fn worn_in_parent(
client: &VtaClient,
sub_context_id: &str,
persona_did: &str,
) -> Option<ParentBinding> {
let (parent, _) = crate::config::context_path::parse_sub_context_id(sub_context_id)?;
let summary = get(client, parent, persona_did).await.ok()?;
if !summary.bound {
return None;
}
Some(ParentBinding {
context_id: parent.to_string(),
label: summary
.profile_name
.or(summary.profile_id)
.unwrap_or_else(|| "an unnamed face".to_string()),
})
}

/// Ask once, and fall back to [`BindingSummary::unknown`] rather than failing.
///
/// The form a panel wants: it has a row to draw either way, and the question is
/// only whether it can say anything true about what that row presents.
///
/// One extra round-trip, and only in one case: an *unbound* sub-context asks
/// its parent as well, so "wears: nothing" can say whether a face is worn a
/// level up. A bound context costs nothing extra, which is the common one.
pub async fn get_or_unknown(
client: &VtaClient,
context_id: &str,
persona_did: &str,
) -> BindingSummary {
match get(client, context_id, persona_did).await {
Ok(summary) => summary,
Ok(mut summary) => {
if !summary.bound {
summary.parent = worn_in_parent(client, context_id, persona_did).await;
}
summary
}
Err(e) => {
tracing::debug!(
context_id,
Expand Down Expand Up @@ -208,6 +288,68 @@ mod tests {
assert_eq!(BindingSummary::default().describe(), "wears: nothing");
}

/// "Nothing here" and "nothing anywhere" are different answers, and the
/// holder most likely to be confused is the one looking at the first.
///
/// The VTA keys a binding on an exact `(context_id, persona_did)` pair and
/// walks no hierarchy, so a face worn in the parent context is genuinely
/// not what this community sees — but a bare "wears: nothing" in front of
/// someone who just configured that face, and can see it on another
/// surface, is true and useless. The sentence has to carry both halves:
/// there is a face, and this community does not see it.
#[test]
fn a_face_worn_one_level_up_is_named_rather_than_hidden_behind_nothing() {
let summary = BindingSummary {
parent: Some(ParentBinding {
context_id: "openvtc".into(),
label: "OSS Developer".into(),
}),
..BindingSummary::default()
};

let line = summary.describe();
assert!(line.contains("nothing here"), "{line}");
assert!(line.contains("OSS Developer"), "{line}");
assert!(line.contains("openvtc"), "{line}");
assert!(
line.contains("does not see"),
"the community must not be implied to see it: {line}"
);
}

/// A parent binding never turns an *unknown* into an answer.
///
/// "We could not ask" outranks everything: reporting what a parent context
/// wears while the context actually in question went unanswered would be a
/// confident statement built on a failed read.
#[test]
fn a_parent_binding_does_not_override_unknown() {
let summary = BindingSummary {
parent: Some(ParentBinding {
context_id: "openvtc".into(),
label: "OSS Developer".into(),
}),
..BindingSummary::unknown()
};
assert_eq!(summary.describe(), "wears: unknown");
}

/// And it never displaces a real answer either.
#[test]
fn a_bound_context_reports_what_it_wears_not_the_parent() {
let summary = BindingSummary {
bound: true,
profile_name: Some("Work".into()),
claim_count: 3,
parent: Some(ParentBinding {
context_id: "openvtc".into(),
label: "OSS Developer".into(),
}),
..BindingSummary::default()
};
assert_eq!(summary.describe(), "wears: Work (3 attributes)");
}

/// The distinction the `unknown` flag exists to preserve.
///
/// `BindingSummary::default()` is a *known* empty answer. If "could not
Expand Down
75 changes: 69 additions & 6 deletions openvtc-core/src/persona/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,17 +143,47 @@ impl ResolvedClaim {
/// minus the "hidden" case: a resolve was asked for, so an absent value is
/// an answer rather than a question that was never put.
///
/// There is no `revealed_value` counterpart here, and that is a decision
/// rather than an omission. A resolved claim has no identity of its own to
/// reveal *one* of — a face is read as a whole — so the only reveal this
/// type could offer is the blanket one the mask exists to avoid. A holder
/// who wants to check a value reads it among their attributes, one at a time.
/// See [`revealed_value`](Self::revealed_value) for lifting the mask on one
/// claim.
#[must_use]
pub fn display_value(&self) -> String {
self.value_line(false)
}

/// The same line with the mask lifted, for a holder who asked for this one
/// claim.
///
/// This used to be deliberately absent, on the argument that a resolved
/// claim has no identity of its own to reveal *one* of — a face was read as
/// a whole, so the only reveal the type could offer was the blanket one the
/// mask exists to avoid. That argument was about the **pane**, not the
/// type: it held only for as long as the face view had no cursor over its
/// claims. It has one now, so "the selected claim" is a thing a holder can
/// name, and the one-at-a-time reveal that the attributes tab has always
/// offered works here on the same terms.
///
/// Still a separate method rather than a `reveal: bool` on
/// [`display_value`](Self::display_value), for the reason
/// [`PoolAttribute::revealed_value`](crate::persona::pool::PoolAttribute::revealed_value)
/// gives: reading a masked value in the clear should be 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) -> String {
self.value_line(true)
}

fn value_line(&self, reveal: bool) -> String {
if self.stale {
return "stale — can no longer be proven".to_string();
}
let shown = |text: String| claim_types::resolve(&self.claim_type).render(&text);
let shown = |text: String| {
if reveal {
text
} else {
claim_types::resolve(&self.claim_type).render(&text)
}
};
match &self.value {
Some(Value::String(s)) => shown(s.clone()),
Some(other) => shown(other.to_string()),
Expand Down Expand Up @@ -448,6 +478,39 @@ mod tests {
assert!(claim.display_value().contains("can no longer be proven"));
}

/// A masked claim reads back whole when a caller asks for that one claim.
///
/// The pairing is the point, and it is the same one the pool makes: the
/// mask has to be liftable, or a holder cannot check what a community
/// actually sees; and lifting it has to be a different call, or it is not a
/// decision anyone made.
#[test]
fn a_masked_claim_is_only_whole_when_it_is_asked_for() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": "+61400123456",
"valueType": "string",
"provenance": { "kind": "selfAsserted" },
}));
assert_eq!(claim.display_value(), "••••••••••56");
assert_eq!(claim.revealed_value(), "+61400123456");
}

/// A stale claim says it is stale under a reveal too.
///
/// The reason it cannot be shown is not that it is masked, and a reveal
/// that turned the explanation into a blank would hide the one thing the
/// holder needs to act on.
#[test]
fn a_stale_claim_still_says_it_is_stale_when_revealed() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": "+61400123456",
"stale": true,
}));
assert!(claim.revealed_value().contains("can no longer be proven"));
}

/// A face masks what its type says to mask, and says that it did.
///
/// The face detail view is a screen a holder opens to check what a
Expand Down
11 changes: 11 additions & 0 deletions openvtc/src/state_handler/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ pub enum PersonaAction {
ProfileOpen(usize),
/// Close that view.
ProfileClose,
/// Move the cursor within the opened face's claims.
///
/// Distinct from [`Select`](Self::Select) because the detail view is a mode
/// of the profiles tab with a cursor of its own: `Select` moves the face
/// list behind it, which still has to be where closing the detail lands.
FaceClaimSelect(usize),
/// Show the selected claim of the opened face unmasked, or stop showing it.
///
/// The face-view counterpart to [`RevealValue`](Self::RevealValue), and one
/// claim rather than a mode for the same reason.
RevealFaceClaim(usize),
ProfileNew,
ProfileEdit(usize),
ProfileDeleteArm(usize),
Expand Down
16 changes: 16 additions & 0 deletions openvtc/src/state_handler/main_page/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,22 @@ pub struct IdentityState {
pub profile_selected: usize,
/// The profile opened with Enter, resolved to what it would present.
pub open_profile: Option<openvtc_core::persona::profile::ProfileDetail>,
/// The claim under the cursor inside that opened face.
///
/// Separate from [`profile_selected`](Self::profile_selected), which keeps
/// pointing at the face in the list behind the detail — closing the detail
/// has to land back on the face that was opened, so the two cursors cannot
/// share a field.
pub face_claim_selected: usize,
/// The one claim of the opened face being shown unmasked, by index.
///
/// The same grant the attributes tab makes, on the same terms: one claim,
/// and only while it is also the selected one — the render checks both.
/// Indices rather than identifiers because a resolved claim has no id of
/// its own to key on (an inline value has no pool attribute behind it), and
/// the order is fixed for as long as a detail is open: every path that
/// replaces `open_profile` clears this too.
pub revealed_face_claim: Option<usize>,

// ── Disclosures (from the agent) ─────────────────────────────────────
/// What has actually left, newest first, across every context.
Expand Down
Loading
Loading