From 45538ca7326db0099b506f78ca0ce6231ddbdb3d Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 14:43:25 +0200 Subject: [PATCH 1/2] feat(persona): give a face a cursor, so its masked values can be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a face showed its claims masked with no way to read one, and told the holder to go and find the value among their attributes instead. That is a real answer to a question nobody asked: someone is looking at *this* face precisely because they want to know what *this* face shows, and sending them to another tab to reconstruct it by eye is the pane declining to answer. The old refusal was reasoned, and the reasoning is what changed. It was not "a face must not be revealed" — it was "a face has no cursor over its claims, so the only reveal this view could offer is the blanket one the mask exists to avoid". That is a statement about the pane, not about the data. Give the detail view a cursor and the objection is gone, because "the selected claim" becomes a thing a holder can name. So the face detail now works exactly like the attributes tab, on the same terms: - `↑`/`↓` move a cursor over the claims. - `s` lifts the mask on the selected one, and puts it back on a second press. - The grant is one claim, and only while it is also the selected row — the render checks the pairing as well as the handler clearing it, so a grant that outlived its row cannot open a claim nobody chose. - Moving the cursor, closing the face, or leaving the tab each ends it. Opening a face starts at the top with nothing revealed, rather than inheriting an index that now names a different claim in a different face. A masked row also says it is masked, for the reason the attributes tab already does: `••••••••` and "(no value)" are the same shape on a row, and a holder who reads the first as the second concludes the face shows nothing. The "s to show" tail follows the cursor rather than sitting on every masked row, so the key is offered where it would work. There is deliberately no `v` here. On the attributes tab `v` is a *network* question — a listing fetched without values does not hold them — but a face detail is resolved in full when it is opened, so every value is already in hand and the mask is the only thing between it and the screen. A `v` would be a display flag pretending to be an escalation. `ResolvedClaim::revealed_value` is the new counterpart to `display_value`, and stays a separate method rather than a `reveal: bool` for the reason its pool sibling gives: reading a masked value in the clear should be something a call site had to name. Its doc records why the counterpart used to be absent and what changed, so the next reader meets the argument rather than a gap. Tests: the reveal opens only the selected claim; a grant that no longer names the selected row opens nothing; the key toggles; a reveal past the end or with no face open does nothing; every exit puts the mask back; opening a face starts closed and at the top; and at the core level a masked claim reads back whole only when asked for, while a stale one still says it is stale under a reveal. The test that encoded the old decision is updated rather than deleted, and says what changed. Gate: fmt, clippy -D warnings, rustdoc -D warnings, cargo test --workspace, and --no-default-features. Signed-off-by: Glenn Gore --- openvtc-core/src/persona/profile.rs | 75 ++++++- openvtc/src/state_handler/actions/mod.rs | 11 + .../src/state_handler/main_page/content.rs | 16 ++ openvtc/src/state_handler/persona_actions.rs | 173 ++++++++++++++- .../pages/main/components/identity_panel.rs | 200 ++++++++++++++++-- openvtc/src/ui/pages/main/mod.rs | 18 +- 6 files changed, 468 insertions(+), 25 deletions(-) diff --git a/openvtc-core/src/persona/profile.rs b/openvtc-core/src/persona/profile.rs index 0f606f2..d7b9970 100644 --- a/openvtc-core/src/persona/profile.rs +++ b/openvtc-core/src/persona/profile.rs @@ -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()), @@ -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 diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index 95ccd4d..7886198 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -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), diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index c67b2df..09407ec 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -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, + /// 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, // ── Disclosures (from the agent) ───────────────────────────────────── /// What has actually left, newest first, across every context. diff --git a/openvtc/src/state_handler/persona_actions.rs b/openvtc/src/state_handler/persona_actions.rs index 21303ad..36bc5c6 100644 --- a/openvtc/src/state_handler/persona_actions.rs +++ b/openvtc/src/state_handler/persona_actions.rs @@ -118,8 +118,10 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect p.open_profile = None; p.status_message = None; // A reveal is granted to one row on one tab. Coming back to the - // attributes should find them masked again, not still open. + // attributes — or to a face — should find them masked again, not + // still open. p.revealed_attribute = None; + p.revealed_face_claim = None; // Read on arrival, once. The agent-served tabs are not polled: a // pane nobody has opened should not be asking the agent about the // holder's identity every few seconds. @@ -235,7 +237,38 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect }) } PersonaAction::ProfileClose => { - state.main_page.content_panel.identity.open_profile = None; + let p = &mut state.main_page.content_panel.identity; + p.open_profile = None; + p.revealed_face_claim = None; + PersonaEffect::None + } + PersonaAction::FaceClaimSelect(index) => { + let p = &mut state.main_page.content_panel.identity; + // Same rule as the attributes tab: the selection moved, so the + // reveal it was granted for is over. Carrying it down the list is + // how "one value" becomes "all of them", one press of ↓ at a time. + p.revealed_face_claim = None; + p.face_claim_selected = *index; + PersonaEffect::None + } + PersonaAction::RevealFaceClaim(index) => { + let p = &mut state.main_page.content_panel.identity; + // Bounds-checked against the open face rather than assumed: the + // detail can be replaced by a re-read between the keypress and here. + let claims = p.open_profile.as_ref().map_or(0, |d| d.resolved.len()); + if *index >= claims { + return PersonaEffect::None; + } + // A second press puts it back, so the key that showed the value is + // also the one that hides it again. + p.revealed_face_claim = match p.revealed_face_claim { + Some(i) if i == *index => None, + _ => Some(*index), + }; + // No read: a face detail is resolved in full when it is opened, so + // this lifts a mask over a value already in memory. Which is + // exactly why the mask is not a security control — see + // `openvtc_core::persona::claim_types`. PersonaEffect::None } PersonaAction::ProfileNew => { @@ -855,6 +888,12 @@ impl PersonaOutcome { } } else { p.open_profile = Some(detail); + // A fresh detail is a fresh set of rows: the cursor + // starts at the top and nothing is revealed. Carrying + // an index over would grant a reveal on whatever + // happens to sit at that position now. + p.face_claim_selected = 0; + p.revealed_face_claim = None; } } Err(e) => { @@ -1164,6 +1203,136 @@ mod tests { assert!(personas(&state).revealed_attribute.is_none()); } + /// A face detail with three claims, two of which carry a mask style. + fn open_face() -> openvtc_core::persona::profile::ProfileDetail { + use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; + ProfileDetail { + summary: ProfileSummary { + profile_id: "01P".into(), + name: "OSS Developer".into(), + ..ProfileSummary::default() + }, + resolved: vec![ + ResolvedClaim { + claim_type: "name.legal".into(), + value: Some(serde_json::json!("Glenn Gore")), + ..ResolvedClaim::default() + }, + ResolvedClaim { + claim_type: "email.work".into(), + value: Some(serde_json::json!("glenn@example.com")), + ..ResolvedClaim::default() + }, + ], + ..ProfileDetail::default() + } + } + + /// `s` on a face opens one claim, and pressing it again closes it — the key + /// that showed the value is the one that hides it. + #[test] + fn a_face_reveal_toggles_on_the_same_key() { + let mut state = state_with(IdentityState { + tab: PersonaTab::Profiles, + open_profile: Some(open_face()), + face_claim_selected: 1, + ..IdentityState::default() + }); + + apply(&mut state, &PersonaAction::RevealFaceClaim(1)); + assert_eq!(personas(&state).revealed_face_claim, Some(1)); + + apply(&mut state, &PersonaAction::RevealFaceClaim(1)); + assert!( + personas(&state).revealed_face_claim.is_none(), + "toggled off" + ); + } + + /// A reveal aimed past the end of the face opens nothing. + /// + /// The index comes from a keypress against what was on screen, and the + /// detail can be replaced between the two — so it is checked against the + /// open face rather than trusted. + #[test] + fn a_face_reveal_past_the_end_reveals_nothing() { + let mut state = state_with(IdentityState { + tab: PersonaTab::Profiles, + open_profile: Some(open_face()), + ..IdentityState::default() + }); + + apply(&mut state, &PersonaAction::RevealFaceClaim(7)); + assert!(personas(&state).revealed_face_claim.is_none()); + } + + /// A reveal with no face open at all opens nothing, rather than arming a + /// grant that the next face to be opened would inherit. + #[test] + fn a_face_reveal_with_nothing_open_reveals_nothing() { + let mut state = state_with(IdentityState { + tab: PersonaTab::Profiles, + ..IdentityState::default() + }); + + apply(&mut state, &PersonaAction::RevealFaceClaim(0)); + assert!(personas(&state).revealed_face_claim.is_none()); + } + + /// Everything that changes what is on screen puts a face's mask back too. + /// + /// Same rule as the attributes tab, and the same reason: a reveal is + /// granted to one claim on one open face, and moving the cursor, closing + /// the face or leaving the tab each ends it. + #[test] + fn moving_anywhere_puts_a_face_mask_back() { + let revealed = || { + state_with(IdentityState { + tab: PersonaTab::Profiles, + open_profile: Some(open_face()), + face_claim_selected: 1, + revealed_face_claim: Some(1), + loaded: true, + ..IdentityState::default() + }) + }; + + let mut moved = revealed(); + apply(&mut moved, &PersonaAction::FaceClaimSelect(0)); + assert!(personas(&moved).revealed_face_claim.is_none(), "cursor"); + assert_eq!(personas(&moved).face_claim_selected, 0); + + let mut closed = revealed(); + apply(&mut closed, &PersonaAction::ProfileClose); + assert!(personas(&closed).revealed_face_claim.is_none(), "closed"); + + let mut tabbed = revealed(); + apply(&mut tabbed, &PersonaAction::TabNext); + assert!(personas(&tabbed).revealed_face_claim.is_none(), "tab"); + } + + /// Opening a face starts at the top with nothing revealed. + /// + /// A carried-over index would grant a reveal on whatever now sits at that + /// position, which is a different claim in a different face. + #[test] + fn opening_a_face_starts_closed_and_at_the_top() { + let mut state = state_with(IdentityState { + tab: PersonaTab::Profiles, + face_claim_selected: 1, + revealed_face_claim: Some(1), + ..IdentityState::default() + }); + + PersonaOutcome::ProfileRead { + edit: false, + result: Ok(open_face()), + } + .apply(&mut state); + assert_eq!(personas(&state).face_claim_selected, 0); + assert!(personas(&state).revealed_face_claim.is_none()); + } + /// Everything that changes what is on screen puts the mask back. /// /// This is what keeps the reveal from becoming a global unmask reached one diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index f73fe44..7c1172e 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -475,17 +475,47 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { .fg(COLOR_TEXT_DEFAULT), ); lines.push(Line::from("")); - for claim in &detail.resolved { - lines.push(Line::from(vec![ + for (i, claim) in detail.resolved.iter().enumerate() { + let is_selected = i == state.face_claim_selected; + // A reveal is granted to one claim, and only while it is the + // selected one — the same pairing the attributes tab makes, so + // a stale grant cannot open a row nobody chose. + let revealed = is_selected && state.revealed_face_claim == Some(i); + let value = if revealed { + claim.revealed_value() + } else { + claim.display_value() + }; + let mut spans = vec![ Span::styled( - format!(" {:<22}", truncate(&claim.claim_type, 21)), - Style::new().fg(COLOR_SOFT_PURPLE), + if is_selected { " ▸ " } else { " " }, + if is_selected { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + Style::new().fg(COLOR_TEXT_DEFAULT) + }, ), Span::styled( - truncate(&claim.display_value(), 44), - Style::new().fg(COLOR_TEXT_DEFAULT), + format!("{:<22}", truncate(&claim.claim_type, 21)), + Style::new().fg(COLOR_SOFT_PURPLE), ), - ])); + Span::styled(truncate(&value, 44), Style::new().fg(COLOR_TEXT_DEFAULT)), + ]; + // Without this the row is a wrong answer rather than a reduced + // one: `••••••••` and "(no value)" are the same shape, and a + // holder reading the first as the second believes the face + // shows nothing. + if claim.is_masked() && !revealed { + spans.push(Span::styled( + if is_selected { + " masked — s to show" + } else { + " masked" + }, + Style::new().fg(COLOR_DARK_GRAY), + )); + } + lines.push(Line::from(spans)); // A value that lives only in this face is not among the // holder's attributes, so correcting it there will not correct it // here. Saying so on the row is the only place they find out. @@ -500,20 +530,28 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { } } lines.push(Line::from("")); - // No per-claim reveal here: a face has no cursor over its claims, so - // the only reveal this view could offer is the blanket one the mask - // exists to avoid. The one-at-a-time reveal lives with the attributes. + // Said once, above the keys, rather than on every row — and only when + // something on screen is actually masked, because explaining a + // mechanism the holder is not looking at is noise. if detail.resolved.iter().any(ResolvedClaim::is_masked) { lines.push( Line::from( - " Some values are masked by what they are. Read one of them among your \ - attributes, where they open one at a time.", + " Some values are masked by what they are — `s` shows the selected one. The \ + mask is against someone reading over your shoulder; this face already \ + shows the value to whoever wears it.", ) .fg(COLOR_DARK_GRAY), ); lines.push(Line::from("")); } - lines.push(Line::from(" ⏎/Esc: back e: edit").fg(COLOR_DARK_GRAY)); + lines.push( + Line::from(if detail.resolved.is_empty() { + " ⏎/Esc: back e: edit".to_string() + } else { + " ↑/↓ select s: show one ⏎/Esc: back e: edit".to_string() + }) + .fg(COLOR_DARK_GRAY), + ); return; } @@ -1731,6 +1769,127 @@ mod tests { ); } + /// How many *rows* are marked masked, as distinct from the paragraph above + /// them that explains the mask and contains the same word. + fn masked_rows(out: &str) -> usize { + out.lines() + .filter(|l| l.contains("masked") && !l.contains("Some values are masked")) + .count() + } + + /// A face builds a detail with two masked claims, for the reveal tests below. + fn face_with_two_masked_claims() -> IdentityState { + use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; + let mut state = IdentityState { + tab: PersonaTab::Profiles, + open_profile: Some(ProfileDetail { + summary: ProfileSummary { + profile_id: "01P".into(), + name: "OSS Developer".into(), + ..ProfileSummary::default() + }, + resolved: vec![ + ResolvedClaim { + claim_type: "name.legal".into(), + value: Some(serde_json::json!("Glenn Gore")), + ..ResolvedClaim::default() + }, + ResolvedClaim { + claim_type: "email.work".into(), + value: Some(serde_json::json!("glenn@example.com")), + ..ResolvedClaim::default() + }, + ResolvedClaim { + claim_type: "phone.mobile".into(), + value: Some(serde_json::json!("+6591234567")), + ..ResolvedClaim::default() + }, + ], + ..ProfileDetail::default() + }), + ..IdentityState::default() + }; + loaded(&mut state); + state + } + + /// A face's masked claims say they are masked, and say which key opens the + /// selected one — rather than sending the holder to another tab. + /// + /// This is the half the view used to be missing. A face was read as a whole + /// with no cursor, so it could offer no per-claim reveal and told the reader + /// to go and find the value among their attributes instead. That is a real + /// answer to a question nobody asked: the holder is looking at *this* face + /// because they want to know what *this* face shows. + #[test] + fn a_face_says_which_key_opens_the_selected_masked_claim() { + let state = face_with_two_masked_claims(); + let out = text(&render(&state)); + + assert!(out.contains("s: show one"), "the key is offered: {out}"); + assert!( + !out.contains("among your attributes"), + "no longer sends the reader to another tab: {out}" + ); + // Two of the three claim types carry a mask style; `name.legal` does + // not. The selected row here is index 0 (`name.legal`), so neither + // masked row carries the "s to show" tail — the key hint follows the + // cursor rather than sitting on every masked row. + assert_eq!( + masked_rows(&out), + 2, + "exactly the two masked claims are marked: {out}" + ); + assert!( + !out.contains("masked — s to show"), + "the key hint follows the cursor, which is on an unmasked row: {out}" + ); + assert!( + out.contains("Glenn Gore"), + "an unmasked type is still shown whole: {out}" + ); + } + + /// The reveal opens one claim — the selected one — and nothing else on the + /// face opens with it. + #[test] + fn a_face_reveal_opens_only_the_selected_claim() { + let mut state = face_with_two_masked_claims(); + state.face_claim_selected = 1; + state.revealed_face_claim = Some(1); + + let out = text(&render(&state)); + assert!(out.contains("glenn@example.com"), "the selected one: {out}"); + assert!( + !out.contains("+6591234567"), + "the other masked claim stays masked: {out}" + ); + assert_eq!( + masked_rows(&out), + 1, + "the revealed row drops its marker: {out}" + ); + } + + /// A grant that no longer names the selected row opens nothing. + /// + /// Belt and braces over the handler, which clears the grant when the + /// selection moves: the render checks the pairing itself, so a grant that + /// somehow outlived its row cannot unmask whatever moved under it. + #[test] + fn a_face_reveal_that_is_not_the_selected_row_opens_nothing() { + let mut state = face_with_two_masked_claims(); + state.face_claim_selected = 2; + state.revealed_face_claim = Some(1); + + let out = text(&render(&state)); + assert!( + !out.contains("glenn@example.com"), + "a stale grant does not open a row nobody chose: {out}" + ); + assert!(!out.contains("+6591234567"), "{out}"); + } + /// An attribute whose claim type carries a mask style is masked in the holder's /// own list, and the row says so rather than reading as empty. /// @@ -1852,8 +2011,12 @@ mod tests { assert!(!out.contains("masked"), "{out}"); } - /// A face masks what it shows too, and points at where a value can be read - /// one at a time — the detail view has no cursor of its own to reveal from. + /// A face masks what it shows too, and a claim left unselected stays masked. + /// + /// This used to assert that the view sent the reader "among your + /// attributes" to read a value, because the detail had no cursor of its own + /// to reveal from. It has one now, so the pointer to another tab is gone + /// and what is left to check is the masking itself. #[test] fn a_face_masks_its_values_and_says_where_to_read_one() { use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; @@ -1880,7 +2043,12 @@ mod tests { let out = text(&render(&state)); assert!(out.contains("••••••••••••4242"), "{out}"); assert!(!out.contains("4242424242424242"), "{out}"); - assert!(out.contains("among your"), "{out}"); + assert!( + !out.contains("among your"), + "no longer sends the reader to another tab: {out}" + ); + // The one claim is also the selected one, so the row names the key. + assert!(out.contains("masked — s to show"), "{out}"); } /// The one masked attribute the tests above share. diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 2d1b6ca..4ea8150 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -641,9 +641,25 @@ impl MainPage { let selected = personas.profile_selected; // The opened detail view is a mode of this tab, not of the // pane, so its keys live here. - if personas.open_profile.is_some() { + if let Some(detail) = personas.open_profile.as_ref() { + let claims = detail.resolved.len(); + let claim = personas.face_claim_selected; return match key.code { KeyCode::Enter => send(PA::ProfileClose), + KeyCode::Up if claims > 0 => { + send(PA::FaceClaimSelect(claim.saturating_sub(1))) + } + KeyCode::Down if claims > 0 => { + send(PA::FaceClaimSelect((claim + 1).min(claims - 1))) + } + // The same `s` the attributes tab uses, on the same + // terms: one claim, the selected one, and pressing it + // again puts the mask back. There is no `v` here + // because there is nothing for it to ask — a face + // detail is resolved in full when it is opened, so + // every value is already in hand and the mask is the + // only thing between it and the screen. + KeyCode::Char('s') if claim < claims => send(PA::RevealFaceClaim(claim)), KeyCode::Char('e') => send(PA::ProfileEdit(selected)), _ => false, }; From d341ada9c52f9c4790e1ffb5e785fe1d72614ec7 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 15:43:46 +0200 Subject: [PATCH 2/2] fix(persona): say when a face is worn one context up, not just "nothing" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A membership whose persona wears a face configured elsewhere read `wears: nothing`, with nothing on screen to explain why another surface showed that same persona wearing "OSS Developer". The answer was true and useless, which on the one pane whose job is to tell a holder what they present is the failure mode to avoid. ## Why it happens The VTA keys a binding on an **exact** `(context_id, persona_did)` pair and walks no hierarchy — `binding_summary` goes through `binding_record` -> `storage::binding_key`, and so do the `materialised_claims` a disclosure draws on. OpenVTC addresses the membership's **sub-context** (`openvtc/`) for both its read and its write, which is where community-scoped disclosure happens. A face bound against the parent context is a different row, and is genuinely not what the community sees. So "nothing" was the right answer to the question asked. What was missing is that the holder had no way to find out the question had two plausible readings and they were looking at the other one. ## What it says now A fourth reading, distinct from the three that were already kept apart: wears: nothing here — OSS Developer is worn in openvtc, which this community does not see Both halves are load-bearing. Naming the face is what connects this row to what the holder saw elsewhere; "does not see" is what stops the row being read as reassurance. The claim is safe to make in those words because both VTA read paths are exact lookups — this is not an inference about what the agent might roll up. 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* `/`, and an id with no `/` is not a sub-context and has no parent to ask about. Same discipline as the didwebvh-rs and agent-names rules. ## Cost One extra round-trip, and only when a sub-context comes back **unbound** — the bound case, which is the common one, costs nothing. Best-effort like every other binding read: a failure looking up the parent leaves a perfectly good "wears: nothing" alone rather than turning it into an error. Ordering is preserved where it matters. `unknown` still outranks everything, because reporting what a parent wears while the context actually in question went unanswered would be a confident statement built on a failed read; and a bound context reports what *it* wears, never the parent. Both are tested. Does not decide which context a membership's face *ought* to be bound in — that is a VTA/product question. It makes the current answer legible either way. Signed-off-by: Glenn Gore --- openvtc-core/src/persona/binding.rs | 146 +++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/openvtc-core/src/persona/binding.rs b/openvtc-core/src/persona/binding.rs index fd15cc0..57515fd 100644 --- a/openvtc-core/src/persona/binding.rs +++ b/openvtc-core/src/persona/binding.rs @@ -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, +} + +/// 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 { @@ -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 @@ -142,6 +174,45 @@ 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 { + 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()), }) } @@ -149,13 +220,22 @@ pub async fn get( /// /// 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, @@ -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