From b7e69d366813b0bdfd158126a6965d562c4ac44e Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 12:46:24 +0200 Subject: [PATCH 1/4] refactor(persona): say "persona", the word every other surface uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity pane called a persona DID a "face". Nothing else does: not the Trust Tasks spec, not the VTA, not the SDK, not `pnm persona binding set --persona-did`, not the console pane shipped this week, and not this repo's own `PersonaRecord` / `persona_ref` / `persona_did`. A holder moving between two of those surfaces had to translate, and translation is where a boundary gets misread. "Profile" was the other candidate and it is the one that would have hurt: it already names the thing a persona *presents*. Calling the DID a profile would have collided head-on with `persona/profile/*` — you bind a profile to a persona; the reverse is not a sentence. The word "persona" is genuinely overloaded — the task family spans the DID and everything above it — and "face" was an attempt to fix that by inventing a third noun. The overload is a prose problem with a prose fix: say "persona DID" where a sentence has to be unambiguous about which layer it means, exactly as `pnm`'s own help does. The pane's module header now states both words and the direction between them. `ContentPanelState::personas` becomes `identity`, so the list can take the name it should have had: `identity.personas` rather than `personas.personas`. That matches the menu entry, which has read "My Identity" since the pane landed — the pane is not only personas, and now the field says so. Signed-off-by: Glenn Gore --- CHANGELOG.md | 8 +- openvtc-core/src/persona/binding.rs | 10 +- openvtc-core/src/persona/disclosure.rs | 2 +- openvtc-core/src/persona/mod.rs | 8 +- openvtc-core/src/persona/profile.rs | 4 +- openvtc/src/state_handler/actions/mod.rs | 8 +- .../src/state_handler/background_dispatch.rs | 2 +- openvtc/src/state_handler/create_persona.rs | 2 +- .../src/state_handler/main_page/content.rs | 66 ++--- openvtc/src/state_handler/main_page/menu.rs | 16 +- openvtc/src/state_handler/main_page/mod.rs | 54 ++-- openvtc/src/state_handler/mod.rs | 40 +-- openvtc/src/state_handler/persona_actions.rs | 152 ++++++------ openvtc/src/state_handler/runtime_actions.rs | 2 +- .../main/components/communities_panel.rs | 10 +- .../ui/pages/main/components/content_panel.rs | 2 +- .../{personas_panel.rs => identity_panel.rs} | 232 ++++++++++-------- openvtc/src/ui/pages/main/components/mod.rs | 2 +- openvtc/src/ui/pages/main/mod.rs | 98 ++++---- tasks/follow-ups.md | 2 +- 20 files changed, 370 insertions(+), 350 deletions(-) rename openvtc/src/ui/pages/main/components/{personas_panel.rs => identity_panel.rs} (86%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c42ada5..00e0d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **One pane for your own identity — "My Identity" on the main menu.** Everything a holder can do with their persona now lives in one place, with five tabs in - the order the concepts build on each other: **Faces** (the persona DIDs), + the order the concepts build on each other: **Personas** (the persona DIDs), **Attributes** (the pool of facts behind them), **Profiles** (named subsets of - that pool), **Communities** (which face each community sees and what it + that pool), **Communities** (which persona each community sees and what it presents there) and **Disclosures** (the read-only record of what has actually left). @@ -47,7 +47,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). list. An unreachable agent and an empty pool are one pixel apart, and only one of them is a confident wrong answer about the holder's own data. - **Destructive questions are asked once, correctly.** Deleting an attribute a - profile uses, or a profile a face presents, needs a cascade or an unbind — + profile uses, or a profile a persona presents, needs a cascade or an unbind — and the pane knows which from data it already holds, so the first prompt names the real consequence rather than being refused and re-asked. - **The editor authors what it can honestly author.** Self-asserted attributes @@ -56,7 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). and a profile's pinned, overridden and inline entries are read, carried through a save untouched, and left to `pnm` to change. -- **A face's linkage is on screen.** A membership row says when the same face is +- **A persona's linkage is on screen.** A membership row says when the same persona is shown to other communities — the fact that lets two of them compare notes and find one person behind both, and the one thing a holder cannot work out by looking at a single row. diff --git a/openvtc-core/src/persona/binding.rs b/openvtc-core/src/persona/binding.rs index f143ffe..c474dbe 100644 --- a/openvtc-core/src/persona/binding.rs +++ b/openvtc-core/src/persona/binding.rs @@ -1,5 +1,5 @@ -//! What each of your faces actually says — the profile a persona presents in -//! one community's context. **Context-scoped**; see the [module header](super) +//! What each of your personas actually says — the profile one presents in a +//! given community's context. **Context-scoped**; see the [module header](super) //! for the boundary this sits on the low side of. //! //! A membership already carries both halves of the key: a @@ -29,7 +29,7 @@ //! //! [`set`] is the exception, and it has to be: it is a decision the holder just //! made about what a community sees. A write that quietly failed would leave -//! them believing a face presents something it does not, so its error is +//! them believing a persona presents something it does not, so its error is //! returned rather than softened. use serde::{Deserialize, Serialize}; @@ -167,13 +167,13 @@ pub async fn get_or_unknown( /// Decide what one persona presents in one context. /// /// `profile_id: None` clears the binding — a persona that presents nothing is a -/// legitimate, common state (a throwaway face), not an absence to be inferred, +/// legitimate, common state (a throwaway persona), not an absence to be inferred, /// so clearing is a first-class call rather than a delete. /// /// This is the push across the boundary. The VTA resolves the profile *above* /// the context and writes a materialised projection into it: the context /// receives values, never pool identifiers, so nothing inside it can walk back -/// to the holder's other faces. That is why there is no "read the pool from a +/// to the holder's other personas. That is why there is no "read the pool from a /// context" counterpart to this function anywhere in the module. /// /// `publicEntries` is deliberately sent empty. It publishes attributes on the diff --git a/openvtc-core/src/persona/disclosure.rs b/openvtc-core/src/persona/disclosure.rs index e76440c..dcc3d4f 100644 --- a/openvtc-core/src/persona/disclosure.rs +++ b/openvtc-core/src/persona/disclosure.rs @@ -46,7 +46,7 @@ pub struct DisclosureRow { pub context_id: String, /// Who it went to. pub verifier_did: String, - /// The face it was made as. + /// The persona it was made as. pub persona_did: String, pub claims: Vec, /// What the verifier said it was for, if they said. diff --git a/openvtc-core/src/persona/mod.rs b/openvtc-core/src/persona/mod.rs index 7b545b5..995b833 100644 --- a/openvtc-core/src/persona/mod.rs +++ b/openvtc-core/src/persona/mod.rs @@ -1,10 +1,10 @@ -//! The holder's own identity — the faces, the facts behind them, and what each -//! face presents where. +//! The holder's own identity — the personas, the facts behind them, and what each +//! persona presents where. //! //! # Two meanings of "persona", and they compose //! //! [`config::account::PersonaRecord`](crate::config::account::PersonaRecord) is -//! a face as an *identity*: a `did:webvh`, its keys, its mediator. That record +//! a persona as an *identity*: a `did:webvh`, its keys, its mediator. That record //! is local, and the TUI has always been able to mint one. //! //! The agent's `persona/*` Trust Tasks use the same word one layer up: a pool @@ -13,7 +13,7 @@ //! ([`binding`]). Those live in the VTA, not in `Config`, and every function //! here is a round-trip to it. //! -//! This crate holds the face; the agent holds what the face says. They join on +//! This crate holds the persona; the agent holds what the persona says. They join on //! the `(context_id, persona_did)` pair every community membership already //! carries. //! diff --git a/openvtc-core/src/persona/profile.rs b/openvtc-core/src/persona/profile.rs index 7810f94..1ce4849 100644 --- a/openvtc-core/src/persona/profile.rs +++ b/openvtc-core/src/persona/profile.rs @@ -11,7 +11,7 @@ //! attribute, a `pinVersion` pin of one, an `override` of its value, and an //! `inline` value that never enters the pool. [`put`] from here writes the //! first, and a picker over the pool is exactly what that form is: tick the -//! facts this face shows. It keeps "edit once, everywhere" true, which is the +//! facts this persona shows. It keeps "edit once, everywhere" true, which is the //! property a holder is relying on when they correct their address in one //! place. //! @@ -55,7 +55,7 @@ pub struct ProfileSummary { /// that from a rejection asks the holder the wrong question first. With /// this, the one question put is the right one. pub referenced: Vec, - /// Credentials listed as this profile's inventory — what the face can + /// Credentials listed as this profile's inventory — what the persona can /// prove, as distinct from the evidence behind a credential-backed value. pub credential_ref_count: usize, pub version: u64, diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index c5849ac..c59e428 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -188,9 +188,9 @@ pub enum SettingsAction { ClipboardCopied(String), } -/// Identity-pane actions — the holder's own faces, pool, profiles and bindings. +/// Identity-pane actions — the holder's own personas, pool, profiles and bindings. /// -/// One sub-enum for a pane with four tabs, rather than four: every one of them +/// One sub-enum for a pane with five tabs, rather than five: every one of them /// shares the pane's selection, its confirmation slot and its single load /// domain, and splitting them would put that shared state behind four names. pub enum PersonaAction { @@ -224,7 +224,7 @@ pub enum PersonaAction { ProfileDeleteArm(usize), // ── Communities ────────────────────────────────────────────────────── - /// Open the picker: what should this face present here? + /// Open the picker: what should this persona present here? BindOpen(usize), /// Arm "present nothing here". UnbindArm(usize), @@ -276,7 +276,7 @@ pub enum Action { Relationship(RelationshipAction), Credential(CredentialAction), Settings(SettingsAction), - /// Identity pane (faces / pool / profiles / bindings). + /// Identity pane (personas / pool / profiles / bindings). Persona(PersonaAction), /// Dismiss the startup loading screen (Enter, once loading has completed) and diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index d12008e..612fb47 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -470,7 +470,7 @@ pub(crate) fn apply_outcome( state .main_page .content_panel - .personas + .identity .bindings .extend(results); } diff --git a/openvtc/src/state_handler/create_persona.rs b/openvtc/src/state_handler/create_persona.rs index 63c9308..7bf726d 100644 --- a/openvtc/src/state_handler/create_persona.rs +++ b/openvtc/src/state_handler/create_persona.rs @@ -10,7 +10,7 @@ //! minus the community/submit parts: pick a WebVH server, mint the DID via the //! VTA, then persist through the shared [`ConfigExtension::mint_persona_into`]. //! The minted persona is an orphan (no community) until a join reuses it, and -//! shows in the identity pane's Faces list. +//! shows in the identity pane's Personas list. use affinidi_tdk::TDK; use anyhow::Result; diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 64b6f79..a9125ef 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -69,9 +69,9 @@ pub struct ContentPanelState { pub communities: CommunitiesState, /// Per-community capabilities view (opened from Communities with `c`). pub capabilities: CapabilitiesState, - /// The holder's own identity: faces, pool, profiles, and what each face + /// The holder's own identity: personas, pool, profiles, and what each persona /// presents where. - pub personas: PersonasState, + pub identity: IdentityState, } // **************************************************************************** @@ -409,14 +409,14 @@ pub struct CommunitySummary { /// not been introduced. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum PersonaTab { - /// The persona DIDs themselves — a face as an *identity*. + /// The persona DIDs themselves — a persona as an *identity*. #[default] - Faces, + Personas, /// The attribute pool: the facts, held once. Attributes, /// Named projections over the pool. Profiles, - /// Which face each community sees, and what it presents there. + /// Which persona each community sees, and what it presents there. Communities, /// What has actually left, and to whom. Disclosures, @@ -427,7 +427,7 @@ impl PersonaTab { #[must_use] pub fn all() -> [PersonaTab; 5] { [ - PersonaTab::Faces, + PersonaTab::Personas, PersonaTab::Attributes, PersonaTab::Profiles, PersonaTab::Communities, @@ -439,7 +439,7 @@ impl PersonaTab { #[must_use] pub fn label(self) -> &'static str { match self { - PersonaTab::Faces => "Faces", + PersonaTab::Personas => "Personas", PersonaTab::Attributes => "Attributes", PersonaTab::Profiles => "Profiles", PersonaTab::Communities => "Communities", @@ -450,7 +450,7 @@ impl PersonaTab { /// Whether this tab's contents come from the agent rather than `Config`. /// /// Drives which tabs a refresh has anything to do, and which can draw - /// before the agent has ever answered. Faces are local: an account with no + /// before the agent has ever answered. Personas are local: an account with no /// VTA session still has personas, and a pane that showed nothing until the /// network answered would be lying about the ones on disk. #[must_use] @@ -467,19 +467,19 @@ impl PersonaTab { #[must_use] pub fn next(self) -> PersonaTab { match self { - PersonaTab::Faces => PersonaTab::Attributes, + PersonaTab::Personas => PersonaTab::Attributes, PersonaTab::Attributes => PersonaTab::Profiles, PersonaTab::Profiles => PersonaTab::Communities, PersonaTab::Communities => PersonaTab::Disclosures, - PersonaTab::Disclosures => PersonaTab::Faces, + PersonaTab::Disclosures => PersonaTab::Personas, } } #[must_use] pub fn prev(self) -> PersonaTab { match self { - PersonaTab::Faces => PersonaTab::Disclosures, - PersonaTab::Attributes => PersonaTab::Faces, + PersonaTab::Personas => PersonaTab::Disclosures, + PersonaTab::Attributes => PersonaTab::Personas, PersonaTab::Profiles => PersonaTab::Attributes, PersonaTab::Communities => PersonaTab::Profiles, PersonaTab::Disclosures => PersonaTab::Communities, @@ -487,8 +487,8 @@ impl PersonaTab { } } -/// One community membership, as the Communities tab needs it: which face this -/// community sees, and enough to look up what that face presents. +/// One community membership, as the Communities tab needs it: which persona this +/// community sees, and enough to look up what that persona presents. #[derive(Clone, Debug, Default)] pub struct PersonaMembership { /// Display name of the community. @@ -497,13 +497,13 @@ pub struct PersonaMembership { pub sub_context_id: String, /// The persona DID this community sees. pub persona_did: String, - /// The holder's label for that face (or its agent name / DID). - pub face_label: String, + /// The holder's label for that persona (or its agent name / DID). + pub persona_label: String, /// Membership lifecycle, already worded for display. pub status_label: String, - /// How many *other* communities are shown this same face. + /// How many *other* communities are shown this same persona. /// - /// The linkage number. Two communities shown one face can compare notes and + /// The linkage number. Two communities shown one persona can compare notes and /// discover they are talking to the same person, which is the single most /// consequential fact about a persona arrangement and the one a holder /// cannot recompute by looking at one row. @@ -517,12 +517,12 @@ pub struct PersonaMembership { pub enum PersonaConfirm { #[default] None, - /// Remove an orphan persona DID (index into `faces`). + /// Remove an orphan persona DID (index into `personas`). /// /// The one variant still addressed by index, because it hands off to the /// existing identity-deletion path, which takes one and re-resolves the DID /// under its own guards before anything is removed. - DeleteFace(usize), + DeletePersona(usize), /// Delete a pool attribute, named by what it is rather than by where it sat. /// /// **Not an index.** An armed question survives across a listing that @@ -543,7 +543,7 @@ pub enum PersonaConfirm { }, /// Delete a profile. Named, not indexed, for the reason above. /// - /// `unbind` is the same decision one layer up: it makes every face + /// `unbind` is the same decision one layer up: it makes every persona /// presenting under this profile present nothing, and it is decided from /// the binding map rather than discovered from a refusal. DeleteProfile { @@ -551,7 +551,7 @@ pub enum PersonaConfirm { name: String, unbind: bool, }, - /// Clear what one face presents in one context — the pair the binding is + /// Clear what one persona presents in one context — the pair the binding is /// addressed by, carried whole for the same reason as above. Unbind { context_id: String, @@ -657,7 +657,7 @@ pub struct ProfileForm { pub working: bool, } -/// The profile picker for one membership: what should this face present here? +/// The profile picker for one membership: what should this persona present here? #[derive(Clone, Debug, Default)] pub struct BindPicker { /// The binding this picker will write, named rather than indexed. The @@ -669,9 +669,9 @@ pub struct BindPicker { pub persona_did: String, /// Display only: who is being shown something, and as whom. pub community: String, - pub face_label: String, + pub persona_label: String, /// Cursor over the options, where 0 is always "present nothing" — a - /// first-class choice rather than the absence of one, because a face that + /// first-class choice rather than the absence of one, because a persona that /// deliberately shows nothing is a common and legitimate arrangement. pub cursor: usize, pub working: bool, @@ -690,19 +690,19 @@ pub enum PersonaMode { /// The persona pane: every surface for the holder's own identity, in one place. /// -/// Three of the four tabs are served by the agent and one by `Config`, and the -/// difference is load-bearing rather than incidental. Faces are on disk, so +/// Four of the five tabs are served by the agent and one by `Config`, and the +/// difference is load-bearing rather than incidental. Personas are on disk, so /// they draw at launch with no session; the pool, the profiles and the bindings /// are the agent's, so each of them has to be able to say "I could not ask" /// distinctly from "you hold nothing" — see [`load_error`](Self::load_error). #[derive(Clone, Debug, Default)] -pub struct PersonasState { +pub struct IdentityState { pub tab: PersonaTab, - // ── Faces (from `Config`) ──────────────────────────────────────────── + // ── Personas (from `Config`) ──────────────────────────────────────────── /// Every persona DID in the account, with how many communities present it. - pub faces: Arc<[ManagedDid]>, - pub face_selected: usize, + pub personas: Arc<[ManagedDid]>, + pub persona_selected: usize, // ── Communities (from `Config`, annotated from the agent) ──────────── pub memberships: Arc<[PersonaMembership]>, @@ -776,8 +776,8 @@ pub struct PersonasState { pub status_message: Option, } -impl PersonasState { - /// What one membership's face presents, as far as we know. +impl IdentityState { + /// What one membership's persona presents, as far as we know. /// /// Falls back to `unknown` rather than a default summary: the two render /// differently on purpose, and a caller reaching for `unwrap_or_default()` diff --git a/openvtc/src/state_handler/main_page/menu.rs b/openvtc/src/state_handler/main_page/menu.rs index f719dc3..b9fe9fe 100644 --- a/openvtc/src/state_handler/main_page/menu.rs +++ b/openvtc/src/state_handler/main_page/menu.rs @@ -29,15 +29,15 @@ pub enum MainMenu { Inbox, Relationships, Credentials, - /// The holder's own identity — faces, the attribute pool behind them, the - /// profiles over that pool, and what each face presents in each community. + /// The holder's own identity — personas, the attribute pool behind them, the + /// profiles over that pool, and what each persona presents in each community. /// /// Sits beside the other "my …" panels rather than under the VTA service, /// which is where its parts used to live: minting a persona DID was a menu /// action, the list of them was a section of the VTA panel, and everything /// above them was reachable only from `pnm`. Identity is not a property of /// the agent that hosts its keys. - Personas, + Identity, Settings, Vta, Logs, @@ -52,7 +52,7 @@ impl Display for MainMenu { MainMenu::Inbox => write!(f, "Inbox"), MainMenu::Relationships => write!(f, "My Relationships"), MainMenu::Credentials => write!(f, "My Credentials"), - MainMenu::Personas => write!(f, "My Identity"), + MainMenu::Identity => write!(f, "My Identity"), MainMenu::Settings => write!(f, "Settings"), MainMenu::Vta => write!(f, "VTA Service"), MainMenu::Logs => write!(f, "Logs"), @@ -70,8 +70,8 @@ impl MainMenu { MainMenu::Inbox => MainMenu::Communities, MainMenu::Relationships => MainMenu::Inbox, MainMenu::Credentials => MainMenu::Relationships, - MainMenu::Personas => MainMenu::Credentials, - MainMenu::Settings => MainMenu::Personas, + MainMenu::Identity => MainMenu::Credentials, + MainMenu::Settings => MainMenu::Identity, MainMenu::Vta => MainMenu::Settings, MainMenu::Logs => MainMenu::Vta, MainMenu::Help => MainMenu::Logs, @@ -85,8 +85,8 @@ impl MainMenu { MainMenu::Communities => MainMenu::Inbox, MainMenu::Inbox => MainMenu::Relationships, MainMenu::Relationships => MainMenu::Credentials, - MainMenu::Credentials => MainMenu::Personas, - MainMenu::Personas => MainMenu::Settings, + MainMenu::Credentials => MainMenu::Identity, + MainMenu::Identity => MainMenu::Settings, MainMenu::Settings => MainMenu::Vta, MainMenu::Vta => MainMenu::Logs, MainMenu::Logs => MainMenu::Help, diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index 8ace7ee..2f0d1c6 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -496,7 +496,7 @@ impl MainPageState { } self.content_panel.vta.active_dids = active_dids.into(); - // Faces: every persona in the account, with how many communities + // Personas: every persona in the account, with how many communities // present it. A persona bound to zero communities is an orphan (e.g. // left by a failed join before the rollback fix) — surfaced so the // operator can spot and manage it. @@ -505,7 +505,7 @@ impl MainPageState { // DID is the holder's identity, not a property of the agent that // happens to host its keys, and the pane that manages it is the pane // that should list it. - let mut faces: Vec = config + let mut personas: Vec = config .account .personas .values() @@ -523,17 +523,19 @@ impl MainPageState { is_active: p.did.as_str() == persona_did, }) .collect(); - faces.sort_by(|a, b| a.did.cmp(&b.did)); + personas.sort_by(|a, b| a.did.cmp(&b.did)); // Clamp the selection to the rebuilt list. Nothing else does: the list is // rebuilt wholesale on every sync, so deleting a persona (or loading a // profile with fewer than the last one had) could leave the index past // the end — where the panel draws no cursor and every list-scoped key // (`d`, `g`) silently does nothing, since each is guarded on - // `face_selected < face count`. Restarting "fixed" it only because the - // index starts at 0. - let personas = &mut self.content_panel.personas; - personas.face_selected = personas.face_selected.min(faces.len().saturating_sub(1)); - personas.faces = faces.into(); + // `persona_selected < persona count`. Restarting "fixed" it only because + // the index starts at 0. + let identity = &mut self.content_panel.identity; + identity.persona_selected = identity + .persona_selected + .min(personas.len().saturating_sub(1)); + identity.personas = personas.into(); // The VIC list is not derived from `Config` (it comes from the VTA // credential vault), so it is annotated rather than rebuilt here. @@ -626,8 +628,8 @@ impl MainPageState { } // The persona pane's Communities tab: one row per membership, not per - // community. A community the holder joined twice under two faces is two - // rows here, because the question this tab answers — which face does + // community. A community the holder joined twice under two personas is two + // rows here, because the question this tab answers — which persona does // this relationship use, and what does it say — has two different // answers in that case. // @@ -638,7 +640,7 @@ impl MainPageState { let mut memberships = Vec::new(); for c in config.account.memberships() { let persona = config.account.personas.get(&c.persona_ref); - // How many *other* memberships show this same face. Computed per + // How many *other* memberships show this same persona. Computed per // row rather than per persona because it is read that way: the // holder is looking at one community and asking "who else sees // this me". @@ -660,7 +662,7 @@ impl MainPageState { // holder's own label, then a *verified* agent name, then the // DID itself. An unverified `alsoKnownAs` claim never reaches // the cache this reads. - face_label: persona + persona_label: persona .and_then(|p| p.label.clone()) .or_else(|| { persona.and_then(|p| config.agent_name_for(&p.did).map(str::to_owned)) @@ -672,14 +674,14 @@ impl MainPageState { }); } // Stable order the holder can predict, and one that puts the rows they - // are most likely to be reconsidering — the faces shown to several + // are most likely to be reconsidering — the personas shown to several // communities — next to each other. memberships.sort_by(|a, b| { - a.face_label - .cmp(&b.face_label) + a.persona_label + .cmp(&b.persona_label) .then_with(|| a.community_name.cmp(&b.community_name)) }); - let personas = &mut self.content_panel.personas; + let personas = &mut self.content_panel.identity; personas.membership_selected = personas .membership_selected .min(memberships.len().saturating_sub(1)); @@ -1800,11 +1802,11 @@ mod tests { assert_eq!(task.remote_did, shorten_did(BOB_DID, 60)); } - /// The identity pane's Faces list carries the persona's verified name + /// The identity pane's Personas list carries the persona's verified name /// beside its DID; the persona's own label is a separate line and is left /// untouched. #[test] - fn face_row_carries_verified_agent_name() { + fn persona_row_carries_verified_agent_name() { let vtc_did = "did:webvh:QmScidCommunityBBBBBBBBBBBBBBBBBB:example.com:community"; let mut config = config_with_membership(ALICE_DID, Some("Work me"), vtc_did, None); config.set_cached_agent_name( @@ -1815,7 +1817,7 @@ mod tests { let mut page = MainPageState::default(); page.sync_from_config(&config); - let row = &page.content_panel.personas.faces[0]; + let row = &page.content_panel.identity.personas[0]; assert_eq!(row.agent_name.as_deref(), Some("example.com/@alice")); assert_eq!(row.did, ALICE_DID); @@ -1827,7 +1829,7 @@ mod tests { /// /// Nothing else did this: the list is rebuilt wholesale on every sync, and a /// stale index disables every list-scoped key (each is guarded on - /// `face_selected < face count`) while drawing no cursor to show why — a + /// `persona_selected < persona count`) while drawing no cursor to show why — a /// keyboard that looks broken until the next restart resets the index to 0. #[test] fn syncing_clamps_a_stale_persona_selection() { @@ -1836,12 +1838,12 @@ mod tests { let mut page = MainPageState::default(); // What a deletion (or a smaller profile) leaves behind. - page.content_panel.personas.face_selected = 4; + page.content_panel.identity.persona_selected = 4; page.sync_from_config(&config); - assert_eq!(page.content_panel.personas.faces.len(), 1); + assert_eq!(page.content_panel.identity.personas.len(), 1); assert_eq!( - page.content_panel.personas.face_selected, 0, + page.content_panel.identity.persona_selected, 0, "the selection must land on a row that exists" ); } @@ -2126,9 +2128,9 @@ mod tests { assert!(page.content_panel.vta.vics[0].issuer_agent_name.is_none()); } - /// A cached negative lookup leaves the face row on the DID. + /// A cached negative lookup leaves the persona row on the DID. #[test] - fn face_row_ignores_a_cached_negative_lookup() { + fn persona_row_ignores_a_cached_negative_lookup() { let vtc_did = "did:webvh:QmScidCommunityBBBBBBBBBBBBBBBBBB:example.com:community"; let mut config = config_with_membership(ALICE_DID, None, vtc_did, None); config.set_cached_agent_name(ALICE_DID, None, chrono::Utc::now()); @@ -2136,7 +2138,7 @@ mod tests { let mut page = MainPageState::default(); page.sync_from_config(&config); - assert!(page.content_panel.personas.faces[0].agent_name.is_none()); + assert!(page.content_panel.identity.personas[0].agent_name.is_none()); } // --- persona_in_scope (community-scoping filter, D10/R-C-6) --- diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index c2e3ba5..e3212db 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1442,8 +1442,8 @@ impl StateHandler { // Same rule for the identity pane: a write that just landed // asked for the listing it invalidated, and could not start // it while its own outcome held the domain. - if state.main_page.content_panel.personas.refresh_queued { - state.main_page.content_panel.personas.refresh_queued = false; + if state.main_page.content_panel.identity.refresh_queued { + state.main_page.content_panel.identity.refresh_queued = false; spawn_persona_effect( &dispatch_tx, &mut in_flight, @@ -2155,7 +2155,7 @@ impl StateHandler { // No session or no messaging runtime: say so and // disarm, rather than leaving the prompt hanging. _ => { - state.main_page.content_panel.personas.confirm = + state.main_page.content_panel.identity.confirm = main_page::content::PersonaConfirm::None; state .main_page @@ -2167,7 +2167,7 @@ impl StateHandler { // Serviced in State A as well: the attribute pool is // holder-scoped and the admin session that reads it // exists before any community does. The pane has no - // faces or memberships to show yet, which is a true + // personas or memberships to show yet, which is a true // answer rather than a missing one. let effect = persona_actions::apply(state, &persona_action); let av = join_ctx.as_ref().and_then(|c| c.admin_vta.as_ref()); @@ -2339,8 +2339,8 @@ impl StateHandler { let av = join_ctx.as_ref().and_then(|c| c.admin_vta.as_ref()); spawn_vic_refresh(&dispatch_tx, &mut in_flight, state, av); } - if state.main_page.content_panel.personas.refresh_queued { - state.main_page.content_panel.personas.refresh_queued = false; + if state.main_page.content_panel.identity.refresh_queued { + state.main_page.content_panel.identity.refresh_queued = false; let av = join_ctx.as_ref().and_then(|c| c.admin_vta.as_ref()); spawn_persona_effect( &dispatch_tx, @@ -2934,7 +2934,7 @@ fn spawn_persona_effect( "No VTA session — your identity is held by the agent, which cannot be reached \ right now." .to_string(); - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.loading = false; p.load_error = Some(reason.clone()); if matches!(effect, PersonaEffect::Job(_)) { @@ -2945,7 +2945,7 @@ fn spawn_persona_effect( if !in_flight.try_begin(domain) { match effect { PersonaEffect::Read => { - state.main_page.content_panel.personas.refresh_queued = true; + state.main_page.content_panel.identity.refresh_queued = true; } _ => persona_actions::release_form( state, @@ -2960,10 +2960,10 @@ fn spawn_persona_effect( PersonaEffect::Read => { let job = persona_actions::PersonaReadJob { admin_vta: admin_vta.clone(), - include_values: state.main_page.content_panel.personas.show_values, + include_values: state.main_page.content_panel.identity.show_values, targets: persona_actions::PersonaReadJob::targets(state), }; - state.main_page.content_panel.personas.loading = true; + state.main_page.content_panel.identity.loading = true; background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { background_dispatch::DispatchOutcome::PersonaManage(job.run().await) }); @@ -3005,8 +3005,8 @@ fn open_agent_name_overlay(state: &mut State, index: usize) -> Option { let Some(persona) = state .main_page .content_panel + .identity .personas - .faces .get(index) .cloned() else { @@ -3054,12 +3054,12 @@ fn prepare_delete_context_did( didcomm_service: &openvtc_core::didcomm::Messaging, index: usize, ) -> Option { - state.main_page.content_panel.personas.confirm = main_page::content::PersonaConfirm::None; + state.main_page.content_panel.identity.confirm = main_page::content::PersonaConfirm::None; let did = state .main_page .content_panel + .identity .personas - .faces .get(index) .map(|d| d.did.clone())?; @@ -3249,14 +3249,14 @@ fn handle_nav_action(state: &mut State, action: &Action) -> bool { state.main_page.switcher = None; } Action::DidSelect(i) => { - state.main_page.content_panel.personas.face_selected = *i; + state.main_page.content_panel.identity.persona_selected = *i; } Action::DidConfirmDelete(i) => { - state.main_page.content_panel.personas.confirm = - main_page::content::PersonaConfirm::DeleteFace(*i); + state.main_page.content_panel.identity.confirm = + main_page::content::PersonaConfirm::DeletePersona(*i); } Action::DidCancelDelete => { - state.main_page.content_panel.personas.confirm = + state.main_page.content_panel.identity.confirm = main_page::content::PersonaConfirm::None; } Action::VicSelect(i) => { @@ -3985,12 +3985,12 @@ mod tests { }, }, Case { - name: "DidConfirmDelete arms the face confirmation (degraded mode used to drop this)", + name: "DidConfirmDelete arms the persona confirmation (degraded mode used to drop this)", action: Action::DidConfirmDelete(1), assert_fn: |s| { assert_eq!( - s.main_page.content_panel.personas.confirm, - main_page::content::PersonaConfirm::DeleteFace(1) + s.main_page.content_panel.identity.confirm, + main_page::content::PersonaConfirm::DeletePersona(1) ) }, }, diff --git a/openvtc/src/state_handler/persona_actions.rs b/openvtc/src/state_handler/persona_actions.rs index b5d962d..2021d71 100644 --- a/openvtc/src/state_handler/persona_actions.rs +++ b/openvtc/src/state_handler/persona_actions.rs @@ -19,7 +19,7 @@ //! # Questions are asked once, and correctly //! //! Deleting an attribute a profile references is refused by the VTA unless the -//! caller cascades; deleting a profile a face presents is refused unless the +//! caller cascades; deleting a profile a persona presents is refused unless the //! caller unbinds. Neither refusal is *discovered* here. The profile listing //! already says which attributes are referenced and the binding map already //! says which profiles are presented, so the prompt names the real consequence @@ -81,7 +81,7 @@ pub(crate) enum PersonaJob { /// to happen. edit: bool, }, - /// Decide what one face presents in one context. + /// Decide what one persona presents in one context. Bind { context_id: String, persona_did: String, @@ -99,7 +99,7 @@ pub(crate) enum PersonaJob { pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect { match action { PersonaAction::TabNext | PersonaAction::TabPrev => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.tab = match action { PersonaAction::TabNext => p.tab.next(), _ => p.tab.prev(), @@ -118,9 +118,9 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::Select(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match p.tab { - PersonaTab::Faces => p.face_selected = *index, + PersonaTab::Personas => p.persona_selected = *index, PersonaTab::Attributes => p.attribute_selected = *index, PersonaTab::Profiles => p.profile_selected = *index, PersonaTab::Communities => p.membership_selected = *index, @@ -130,7 +130,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect } PersonaAction::Refresh => PersonaEffect::Read, PersonaAction::ToggleValues => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.show_values = !p.show_values; // A re-read, not a redraw: a listing fetched without values does // not hold them. Flipping a display flag over data already in @@ -140,12 +140,12 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Attributes ─────────────────────────────────────────────────── PersonaAction::AttributeNew => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.mode = PersonaMode::Attribute(AttributeForm::default()); PersonaEffect::None } PersonaAction::AttributeEdit(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(attr) = p.attributes.get(*index).cloned() else { return PersonaEffect::None; }; @@ -170,7 +170,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::AttributeDeleteArm(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(attr) = p.attributes.get(*index) else { return PersonaEffect::None; }; @@ -191,7 +191,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Profiles ───────────────────────────────────────────────────── PersonaAction::ProfileOpen(index) | PersonaAction::ProfileEdit(index) => { let edit = matches!(action, PersonaAction::ProfileEdit(_)); - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(profile) = p.profiles.get(*index) else { return PersonaEffect::None; }; @@ -201,20 +201,20 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect }) } PersonaAction::ProfileClose => { - state.main_page.content_panel.personas.open_profile = None; + state.main_page.content_panel.identity.open_profile = None; PersonaEffect::None } PersonaAction::ProfileNew => { - state.main_page.content_panel.personas.mode = + state.main_page.content_panel.identity.mode = PersonaMode::Profile(ProfileForm::default()); PersonaEffect::None } PersonaAction::ProfileDeleteArm(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(profile) = p.profiles.get(*index) else { return PersonaEffect::None; }; - // Presented by a face somewhere? Deleting then leaves that face + // Presented by a persona somewhere? Deleting then leaves that persona // presenting nothing, which the prompt has to say. let unbind = p .bindings @@ -230,7 +230,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Communities ────────────────────────────────────────────────── PersonaAction::BindOpen(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(membership) = p.memberships.get(*index).cloned() else { return PersonaEffect::None; }; @@ -244,7 +244,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect context_id: membership.sub_context_id.clone(), persona_did: membership.persona_did.clone(), community: membership.community_name.clone(), - face_label: membership.face_label.clone(), + persona_label: membership.persona_label.clone(), cursor, working: false, error: None, @@ -252,7 +252,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::UnbindArm(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(m) = p.memberships.get(*index) else { return PersonaEffect::None; }; @@ -266,7 +266,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── The confirmation slot ──────────────────────────────────────── PersonaAction::ConfirmNo => { - state.main_page.content_panel.personas.confirm = PersonaConfirm::None; + state.main_page.content_panel.identity.confirm = PersonaConfirm::None; PersonaEffect::None } PersonaAction::ConfirmYes => confirm_yes(state), @@ -274,7 +274,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Forms ──────────────────────────────────────────────────────── PersonaAction::FormKey(key) => { use tui_input::backend::crossterm::EventHandler; - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let event = crossterm::event::Event::Key(*key); match &mut p.mode { PersonaMode::Attribute(form) => { @@ -297,7 +297,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::FormField(forwards) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::Attribute(form) => { form.field = if *forwards { @@ -317,9 +317,9 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::FormCycle(forwards) => { - let attribute_count = state.main_page.content_panel.personas.attributes.len(); - let option_count = state.main_page.content_panel.personas.profiles.len() + 1; - let p = &mut state.main_page.content_panel.personas; + let attribute_count = state.main_page.content_panel.identity.attributes.len(); + let option_count = state.main_page.content_panel.identity.profiles.len() + 1; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::Attribute(form) => { if form.field == AttributeField::ValueType { @@ -343,7 +343,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect } PersonaAction::FormToggleEntry => { let attribute_id = { - let p = &state.main_page.content_panel.personas; + let p = &state.main_page.content_panel.identity; match &p.mode { PersonaMode::Profile(form) => p .attributes @@ -352,7 +352,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect _ => None, } }; - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; if let (PersonaMode::Profile(form), Some(id)) = (&mut p.mode, attribute_id) { match form.ticked.iter().position(|x| *x == id) { Some(i) => { @@ -366,7 +366,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::FormCancel => { - state.main_page.content_panel.personas.mode = PersonaMode::View; + state.main_page.content_panel.identity.mode = PersonaMode::View; PersonaEffect::None } PersonaAction::FormSubmit => form_submit(state), @@ -379,12 +379,12 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect /// that arrived while the prompt was on screen cannot redirect the answer onto /// a row the operator never selected. fn confirm_yes(state: &mut State) -> PersonaEffect { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let confirm = std::mem::replace(&mut p.confirm, PersonaConfirm::None); match confirm { - // A face deletion is not answered here — the pane arms the question and + // A persona deletion is not answered here — the pane arms the question and // the existing identity-deletion path answers it. See the key handler. - PersonaConfirm::None | PersonaConfirm::DeleteFace(_) => PersonaEffect::None, + PersonaConfirm::None | PersonaConfirm::DeletePersona(_) => PersonaEffect::None, PersonaConfirm::DeleteAttribute { attribute_id, cascade, @@ -414,12 +414,12 @@ fn form_submit(state: &mut State) -> PersonaEffect { let profiles: Vec = state .main_page .content_panel - .personas + .identity .profiles .iter() .cloned() .collect(); - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::View => PersonaEffect::None, @@ -494,7 +494,7 @@ fn form_submit(state: &mut State) -> PersonaEffect { /// callers are the paths where the loop declines to spawn — no admin session, /// and the domain already busy. pub(crate) fn release_form(state: &mut State, reason: String) { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::Attribute(form) => { form.working = false; @@ -573,7 +573,7 @@ impl PersonaReadJob { state .main_page .content_panel - .personas + .identity .memberships .iter() .filter(|m| !m.sub_context_id.is_empty() && !m.persona_did.is_empty()) @@ -715,7 +715,7 @@ pub(crate) enum PersonaOutcome { impl PersonaOutcome { /// Fold the result into the pane, on the loop thread. pub(crate) fn apply(self, state: &mut State) { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.loading = false; match self { @@ -754,7 +754,7 @@ impl PersonaOutcome { } // Merged, not replaced: a read only carries the targets it was // given, and replacing would blank every row it did not cover — - // which reads on screen as those faces having stopped + // which reads on screen as those personas having stopped // presenting anything. p.bindings.extend(bindings); if p.load_error.is_none() { @@ -853,7 +853,7 @@ impl PersonaOutcome { } state .main_page - .log_error("Changing what a face presents failed", e.as_str()); + .log_error("Changing what a persona presents failed", e.as_str()); } } } @@ -864,7 +864,7 @@ impl PersonaOutcome { #[cfg(test)] mod tests { use super::*; - use crate::state_handler::main_page::content::{PersonaMembership, PersonasState}; + use crate::state_handler::main_page::content::{IdentityState, PersonaMembership}; use openvtc_core::persona::binding::BindingSummary; use openvtc_core::persona::pool::ProvenanceKind; @@ -878,14 +878,14 @@ mod tests { } } - fn state_with(personas: PersonasState) -> State { + fn state_with(personas: IdentityState) -> State { let mut state = State::default(); - state.main_page.content_panel.personas = personas; + state.main_page.content_panel.identity = personas; state } - fn personas(state: &State) -> &PersonasState { - &state.main_page.content_panel.personas + fn personas(state: &State) -> &IdentityState { + &state.main_page.content_panel.identity } /// Deleting an attribute a profile uses asks the cascading question *first*. @@ -896,7 +896,7 @@ mod tests { /// answers the follow-up with the first question's reasoning. #[test] fn a_referenced_attribute_arms_the_cascading_question_directly() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A"), attribute("01B")].into(), profiles: vec![ProfileSummary { profile_id: "01P".into(), @@ -905,7 +905,7 @@ mod tests { ..ProfileSummary::default() }] .into(), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::AttributeDeleteArm(0)); @@ -930,7 +930,7 @@ mod tests { ); } - /// Same rule one layer up: deleting a profile a face presents asks the + /// Same rule one layer up: deleting a profile a persona presents asks the /// unbinding question, because that is what will actually happen. #[test] fn a_presented_profile_arms_the_unbinding_question() { @@ -943,7 +943,7 @@ mod tests { ..BindingSummary::default() }, ); - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { profiles: vec![ ProfileSummary { profile_id: "01P".into(), @@ -956,7 +956,7 @@ mod tests { ] .into(), bindings, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::ProfileDeleteArm(0)); @@ -989,9 +989,9 @@ mod tests { /// selected while showing them the name of something else. #[test] fn an_armed_delete_survives_the_list_moving_underneath_it() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A"), attribute("01B")].into(), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::AttributeDeleteArm(0)); @@ -1024,10 +1024,10 @@ mod tests { fn a_credential_backed_attribute_refuses_the_editor() { let mut attr = attribute("01A"); attr.provenance = ProvenanceKind::CredentialBacked; - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attr].into(), show_values: true, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::AttributeEdit(0)); @@ -1047,10 +1047,10 @@ mod tests { /// a value the holder never saw. #[test] fn editing_without_values_in_hand_asks_for_them() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A")].into(), show_values: false, - ..PersonasState::default() + ..IdentityState::default() }); let effect = apply(&mut state, &PersonaAction::AttributeEdit(0)); @@ -1074,10 +1074,10 @@ mod tests { /// dropped rather than flashed — the same rule the VIC listing follows. #[test] fn a_superseded_read_is_discarded() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A")].into(), show_values: true, - ..PersonasState::default() + ..IdentityState::default() }); PersonaOutcome::Read { @@ -1100,10 +1100,10 @@ mod tests { /// leave the pane claiming the holder has no attributes. #[test] fn a_failed_read_keeps_the_list_and_says_why() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A")].into(), loaded: true, - ..PersonasState::default() + ..IdentityState::default() }); PersonaOutcome::Read { @@ -1146,12 +1146,12 @@ mod tests { /// clear it, and the form sits on "Saving…" over an edit nobody is saving. #[test] fn a_request_that_never_left_unlocks_the_form() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Attribute(AttributeForm { working: true, ..AttributeForm::default() }), - ..PersonasState::default() + ..IdentityState::default() }); release_form(&mut state, "no session".to_string()); @@ -1169,12 +1169,12 @@ mod tests { /// back if the write never went. #[test] fn a_request_that_never_left_unlocks_the_picker() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Bind(BindPicker { working: true, ..BindPicker::default() }), - ..PersonasState::default() + ..IdentityState::default() }); release_form(&mut state, "busy".to_string()); @@ -1192,13 +1192,13 @@ mod tests { /// does not lose what they typed. #[test] fn a_failed_save_keeps_the_form_and_its_contents() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Attribute(AttributeForm { claim_type: tui_input::Input::new("email.work".into()), working: true, ..AttributeForm::default() }), - ..PersonasState::default() + ..IdentityState::default() }); PersonaOutcome::Written { @@ -1221,9 +1221,9 @@ mod tests { /// field with no sensible default: it is what a verifier matches on. #[test] fn a_typeless_attribute_is_refused_locally() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Attribute(AttributeForm::default()), - ..PersonasState::default() + ..IdentityState::default() }); let effect = apply(&mut state, &PersonaAction::FormSubmit); @@ -1257,7 +1257,7 @@ mod tests { ..BindingSummary::default() }, ); - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { memberships: vec![membership].into(), profiles: vec![ ProfileSummary { @@ -1271,7 +1271,7 @@ mod tests { ] .into(), bindings, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::BindOpen(0)); @@ -1291,13 +1291,13 @@ mod tests { } } - /// An unbound face opens the picker on "nothing", which is where it + /// An unbound persona opens the picker on "nothing", which is where it /// already is. #[test] - fn an_unbound_face_opens_the_picker_on_nothing() { - let mut state = state_with(PersonasState { + fn an_unbound_persona_opens_the_picker_on_nothing() { + let mut state = state_with(IdentityState { memberships: vec![PersonaMembership::default()].into(), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::BindOpen(0)); match &personas(&state).mode { @@ -1310,13 +1310,13 @@ mod tests { /// presents. #[test] fn ticking_preserves_the_order_entries_were_chosen_in() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A"), attribute("01B"), attribute("01C")].into(), mode: PersonaMode::Profile(ProfileForm { focus: ProfileFormFocus::Entries, ..ProfileForm::default() }), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::FormCycle(true)); // → 01B @@ -1367,14 +1367,14 @@ mod tests { /// belongs to the screen that asked the question. #[test] fn changing_tab_disarms_the_confirmation() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { tab: PersonaTab::Attributes, confirm: PersonaConfirm::DeleteAttribute { attribute_id: "01A".into(), name: "email.work".into(), cascade: false, }, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::TabNext); @@ -1388,13 +1388,13 @@ mod tests { #[test] fn an_agent_tab_reads_once_on_arrival() { let mut state = State::default(); - // Faces → Attributes: needs the agent, nothing loaded yet. + // Personas → Attributes: needs the agent, nothing loaded yet. assert!(matches!( apply(&mut state, &PersonaAction::TabNext), PersonaEffect::Read )); - state.main_page.content_panel.personas.loaded = true; + state.main_page.content_panel.identity.loaded = true; assert!(matches!( apply(&mut state, &PersonaAction::TabNext), PersonaEffect::None diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 16e3e88..05fc378 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -1257,7 +1257,7 @@ mod tests { } fn with_persona(&mut self) { - self.state.main_page.content_panel.personas.faces = vec![ManagedDid { + self.state.main_page.content_panel.identity.personas = vec![ManagedDid { did: "did:webvh:QmScidPersona:example.com:alice".into(), agent_name: None, label: "Alice".into(), diff --git a/openvtc/src/ui/pages/main/components/communities_panel.rs b/openvtc/src/ui/pages/main/components/communities_panel.rs index f0609fc..59762b4 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel.rs @@ -25,14 +25,14 @@ impl Panel for CommunitiesPanel { state: &ContentPanelState, _connection: &ConnectionState, ) -> Vec> { - render(&state.communities, &state.personas.bindings) + render(&state.communities, &state.identity.bindings) } } /// Render the communities panel content. /// /// `bindings` is the persona pane's map, read here rather than copied: this -/// panel and the persona pane render the same fact — what a face presents in a +/// panel and the persona pane render the same fact — what a persona presents in a /// community — and two copies of it would drift the moment one was refreshed /// and the other was not. pub fn render( @@ -119,10 +119,10 @@ pub fn render( Span::styled(attention, Style::new().fg(COLOR_ORANGE)), ])); - // Secondary line: status · member-since · what this face presents. + // Secondary line: status · member-since · what this persona presents. // - // The row above says WHICH of the holder's faces this community sees; - // this says WHAT that face tells them. Both halves are needed to answer + // The row above says WHICH of the holder's personas this community sees; + // this says WHAT that persona tells them. Both halves are needed to answer // "what does this community know about me", and until now only the // first was on screen. // diff --git a/openvtc/src/ui/pages/main/components/content_panel.rs b/openvtc/src/ui/pages/main/components/content_panel.rs index 2510e2c..3249ab8 100644 --- a/openvtc/src/ui/pages/main/components/content_panel.rs +++ b/openvtc/src/ui/pages/main/components/content_panel.rs @@ -82,7 +82,7 @@ impl ContentPanelState { MainMenu::Credentials => Some(Box::new(CredentialsPanel)), MainMenu::Settings => Some(Box::new(SettingsPanel)), MainMenu::Vta => Some(Box::new(VtaPanel)), - MainMenu::Personas => Some(Box::new(super::personas_panel::PersonasPanel)), + MainMenu::Identity => Some(Box::new(super::identity_panel::IdentityPanel)), _ => None, }; diff --git a/openvtc/src/ui/pages/main/components/personas_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs similarity index 86% rename from openvtc/src/ui/pages/main/components/personas_panel.rs rename to openvtc/src/ui/pages/main/components/identity_panel.rs index 9952d5c..9560217 100644 --- a/openvtc/src/ui/pages/main/components/personas_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -1,11 +1,24 @@ //! The identity pane — every surface for the holder's own identity, in one //! place. //! -//! Four tabs, in the order the concepts build on each other: the **faces** a -//! holder presents (persona DIDs), the **attributes** they hold about -//! themselves, the **profiles** that project a subset of those attributes, and -//! the **communities** each face is shown to. Tab one comes from `Config`; the -//! other three come from the agent. +//! Five tabs, in the order the concepts build on each other: the **personas** a +//! holder presents, the **attributes** they hold about themselves, the +//! **profiles** that project a subset of those attributes, the **communities** +//! each persona is shown to, and the **disclosures** that have actually left. +//! The first comes from `Config`; the rest come from the agent. +//! +//! # Two words, and they are not interchangeable +//! +//! A **persona** is an identity: a `did:webvh`, its keys, its mediator — the +//! thing a community sees. A **profile** is a named projection over the +//! attribute pool — the thing a persona *presents*. You bind a profile to a +//! persona in a context; the reverse is not a sentence. +//! +//! Both words are the spec's, the VTA's, the SDK's and `pnm`'s, and this pane +//! uses them the same way so that a holder reading +//! `pnm persona binding set --persona-did … --profile-id …` recognises what is +//! on screen. Where a sentence needs to be unambiguous about which layer it +//! means — the `persona/*` task family spans both — it says "persona DID". //! //! # What this pane will not draw //! @@ -27,8 +40,8 @@ use crate::colors::{ }; use crate::state_handler::{ main_page::content::{ - AttributeField, AttributeForm, BindPicker, PersonaConfirm, PersonaMode, PersonaTab, - PersonasState, ProfileForm, ProfileFormFocus, VALUE_TYPES, + AttributeField, AttributeForm, BindPicker, IdentityState, PersonaConfirm, PersonaMode, + PersonaTab, ProfileForm, ProfileFormFocus, VALUE_TYPES, }, state::ConnectionState, }; @@ -47,22 +60,22 @@ const ID_WIDTH: usize = 46; const CONFIRM_DID_WIDTH: usize = 48; /// The identity pane. -pub struct PersonasPanel; +pub struct IdentityPanel; -impl Panel for PersonasPanel { +impl Panel for IdentityPanel { fn render( &self, state: &crate::state_handler::main_page::content::ContentPanelState, _connection: &ConnectionState, ) -> Vec> { - render(&state.personas) + render(&state.identity) } } /// Render the pane. A form or picker owns the whole panel while it is open — /// the lists behind it are not interactive then, and drawing them would invite /// keys that go nowhere. -pub fn render(state: &PersonasState) -> Vec> { +pub fn render(state: &IdentityState) -> Vec> { match &state.mode { PersonaMode::Attribute(form) => render_attribute_form(form), PersonaMode::Profile(form) => render_profile_form(state, form), @@ -75,7 +88,7 @@ pub fn render(state: &PersonasState) -> Vec> { // The tabbed view // --------------------------------------------------------------------------- -fn render_tabs(state: &PersonasState) -> Vec> { +fn render_tabs(state: &IdentityState) -> Vec> { let mut lines = vec![Line::from("")]; if let Some(msg) = &state.status_message { @@ -100,7 +113,7 @@ fn render_tabs(state: &PersonasState) -> Vec> { lines.push(Line::from("")); match state.tab { - PersonaTab::Faces => render_faces(state, &mut lines), + PersonaTab::Personas => render_personas(state, &mut lines), PersonaTab::Attributes => render_attributes(state, &mut lines), PersonaTab::Profiles => render_profiles(state, &mut lines), PersonaTab::Communities => render_communities(state, &mut lines), @@ -119,23 +132,23 @@ fn render_tabs(state: &PersonasState) -> Vec> { } /// The question to put, worded so a `y` cannot be given to the wrong one. -fn confirm_prompt(state: &PersonasState) -> Option { +fn confirm_prompt(state: &IdentityState) -> Option { match &state.confirm { PersonaConfirm::None => None, - PersonaConfirm::DeleteFace(i) => { - let face = state.faces.get(*i)?; + PersonaConfirm::DeletePersona(i) => { + let persona = state.personas.get(*i)?; // Keep *both* the name and the DID. The row the operator selected // shows the name, so a DID-only prompt names something they never // saw; but a destructive confirm must stay unambiguous, so the DID // is not dropped either — it is centre-truncated to keep the line // readable while both ends stay checkable. - let did = openvtc_core::display::truncate_did_centered(&face.did, CONFIRM_DID_WIDTH); - let name = match face + let did = openvtc_core::display::truncate_did_centered(&persona.did, CONFIRM_DID_WIDTH); + let name = match persona .label .is_empty() - .then_some(face.agent_name.as_deref()) + .then_some(persona.agent_name.as_deref()) .flatten() - .or_else(|| (!face.label.is_empty()).then_some(face.label.as_str())) + .or_else(|| (!persona.label.is_empty()).then_some(persona.label.as_str())) { Some(name) => format!("{name} ({did})"), None => did.into_owned(), @@ -159,7 +172,7 @@ fn confirm_prompt(state: &PersonasState) -> Option { PersonaConfirm::DeleteProfile { name, unbind, .. } => { if *unbind { Some(format!( - "A face still presents \"{name}\". Delete it and leave that face presenting \ + "A persona still presents \"{name}\". Delete it and leave that persona presenting \ nothing? y: confirm n: cancel" )) } else { @@ -174,10 +187,10 @@ fn confirm_prompt(state: &PersonasState) -> Option { } } -fn hints(state: &PersonasState) -> &'static str { +fn hints(state: &IdentityState) -> &'static str { match state.tab { - PersonaTab::Faces => { - "↑/↓ select n: new face g: agent names d: remove orphan ⇥/⇧⇥: tab" + PersonaTab::Personas => { + "↑/↓ select n: new persona g: agent names d: remove orphan ⇥/⇧⇥: tab" } PersonaTab::Attributes => { "↑/↓ select n: new e: edit d: delete v: values r: refresh ⇥/⇧⇥: tab" @@ -186,23 +199,23 @@ fn hints(state: &PersonasState) -> &'static str { "↑/↓ select ⏎: what it shows n: new e: edit d: delete r: refresh ⇥/⇧⇥: tab" } PersonaTab::Communities => { - "↑/↓ select b: choose what this face shows u: show nothing r: refresh ⇥/⇧⇥: tab" + "↑/↓ select b: choose what this persona shows u: show nothing r: refresh ⇥/⇧⇥: tab" } PersonaTab::Disclosures => "↑/↓ select r: refresh ⇥/⇧⇥: tab", } } // --------------------------------------------------------------------------- -// Faces +// Personas // --------------------------------------------------------------------------- -fn render_faces(state: &PersonasState, lines: &mut Vec>) { - if state.faces.is_empty() { +fn render_personas(state: &IdentityState, lines: &mut Vec>) { + if state.personas.is_empty() { lines.push(Line::from(" You have no persona DIDs yet.").fg(COLOR_DARK_GRAY)); lines.push(Line::from("")); lines.push( Line::from( - " A face is a did:webvh you present to a community. Mint one with `n`, hand its \ + " A persona is a did:webvh you present to a community. Mint one with `n`, hand its \ DID to a community, and they can issue an invitation bound to it.", ) .fg(COLOR_DARK_GRAY), @@ -212,20 +225,20 @@ fn render_faces(state: &PersonasState, lines: &mut Vec>) { lines.push( Line::from(format!( - " {} face{}", - state.faces.len(), - if state.faces.len() == 1 { "" } else { "s" } + " {} persona{}", + state.personas.len(), + if state.personas.len() == 1 { "" } else { "s" } )) .fg(COLOR_TEXT_DEFAULT), ); lines.push(Line::from("")); - for (i, face) in state.faces.iter().enumerate() { - let is_selected = i == state.face_selected; - // An orphan is a face no community sees. Usually the residue of a join + for (i, persona) in state.personas.iter().enumerate() { + let is_selected = i == state.persona_selected; + // An orphan is a persona no community sees. Usually the residue of a join // that failed, and worth spotting: it costs keys and a mediator // registration while presenting nothing to anybody. - let orphan = face.bound_communities == 0; + let orphan = persona.bound_communities == 0; let prefix = if is_selected { "▸ " } else { " " }; let marker_style = if orphan { Style::new().fg(COLOR_ORANGE) @@ -242,30 +255,35 @@ fn render_faces(state: &PersonasState, lines: &mut Vec>) { Span::styled(prefix, marker_style), Span::styled(if orphan { "○ " } else { "● " }, marker_style), Span::styled( - display_identifier(face.agent_name.as_deref(), &face.did, ID_WIDTH).into_owned(), + display_identifier(persona.agent_name.as_deref(), &persona.did, ID_WIDTH) + .into_owned(), row_style, ), ])); - let name = if face.label.is_empty() { - "unnamed face".to_string() + let name = if persona.label.is_empty() { + "unnamed persona".to_string() } else { - face.label.clone() + persona.label.clone() }; let seen_by = if orphan { "orphan — no community".to_string() } else { format!( "seen by {} communit{}", - face.bound_communities, - if face.bound_communities == 1 { + persona.bound_communities, + if persona.bound_communities == 1 { "y" } else { "ies" } ) }; - let active = if face.is_active { " · active" } else { "" }; + let active = if persona.is_active { + " · active" + } else { + "" + }; lines.push(Line::from(vec![ Span::styled( format!(" {name}{active} · "), @@ -287,7 +305,7 @@ fn render_faces(state: &PersonasState, lines: &mut Vec>) { // Attributes // --------------------------------------------------------------------------- -fn render_attributes(state: &PersonasState, lines: &mut Vec>) { +fn render_attributes(state: &IdentityState, lines: &mut Vec>) { if push_agent_state(state, lines, "attributes") { return; } @@ -374,7 +392,7 @@ fn render_attributes(state: &PersonasState, lines: &mut Vec>) { // Profiles // --------------------------------------------------------------------------- -fn render_profiles(state: &PersonasState, lines: &mut Vec>) { +fn render_profiles(state: &IdentityState, lines: &mut Vec>) { if let Some(detail) = &state.open_profile { lines.push( Line::from(format!(" {}", detail.summary.display_name())) @@ -448,7 +466,7 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { lines.push( Line::from( " No profiles yet. A profile is a named subset of your pool — \"Work\", \ - \"Gaming\" — and it is what a face presents to a community.", + \"Gaming\" — and it is what a persona presents to a community.", ) .fg(COLOR_DARK_GRAY), ); @@ -484,7 +502,7 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { // Communities // --------------------------------------------------------------------------- -fn render_communities(state: &PersonasState, lines: &mut Vec>) { +fn render_communities(state: &IdentityState, lines: &mut Vec>) { if state.memberships.is_empty() { lines.push(Line::from(" You are not a member of any community yet.").fg(COLOR_DARK_GRAY)); return; @@ -515,13 +533,13 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { Span::styled(if is_selected { "▸ " } else { " " }, row_style), Span::styled(truncate(&m.community_name, 30), row_style), Span::styled( - format!(" as {}", truncate(&m.face_label, 24)), + format!(" as {}", truncate(&m.persona_label, 24)), Style::new().fg(COLOR_SOFT_PURPLE), ), ])); - // What that face tells them. `unknown` is drawn, never omitted: an - // omitted row is indistinguishable from a face bound to nothing, and + // What that persona tells them. `unknown` is drawn, never omitted: an + // omitted row is indistinguishable from a persona bound to nothing, and // "we have not asked" is not "you are sharing nothing". let detail = format!( " {} · {}", @@ -537,13 +555,13 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { }, ))); - // The linkage line. Two communities shown one face can compare notes + // The linkage line. Two communities shown one persona can compare notes // and find the same person behind both, and that is the consequence a // holder cannot see by looking at either row on its own. if m.shared_with > 0 { lines.push( Line::from(format!( - " ⚠ this face is also shown to {} other communit{}", + " ⚠ this persona is also shown to {} other communit{}", m.shared_with, if m.shared_with == 1 { "y" } else { "ies" } )) @@ -554,12 +572,12 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { lines.push(Line::from("")); // The honest limit of this tab. A membership is held by a credential issued - // to one persona DID, so which face a community sees is fixed at the join — - // `b` changes what that face says, never who it is. + // to one persona DID, so which persona a community sees is fixed at the join — + // `b` changes what that persona says, never who it is. lines.push( Line::from( - " `b` changes what a face presents here. To show a community a *different* face, join \ - it again with that face — the membership credential is bound to the persona that \ + " `b` changes what a persona presents here. To show a community a *different* persona, join \ + it again with that persona — the membership credential is bound to the persona that \ joined.", ) .fg(COLOR_DARK_GRAY), @@ -570,7 +588,7 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { // Disclosures // --------------------------------------------------------------------------- -fn render_disclosures(state: &PersonasState, lines: &mut Vec>) { +fn render_disclosures(state: &IdentityState, lines: &mut Vec>) { if push_agent_state(state, lines, "disclosures") { return; } @@ -643,7 +661,7 @@ fn render_disclosures(state: &PersonasState, lines: &mut Vec>) { /// The failure case is why this exists: an empty list and an unreachable agent /// render identically unless something says otherwise, and of the two, "you /// hold no attributes" is the confident wrong answer (VTI R6.4). -fn push_agent_state(state: &PersonasState, lines: &mut Vec>, noun: &str) -> bool { +fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: &str) -> bool { if let Some(error) = &state.load_error { lines.push( Line::from(format!(" Could not read your {noun} from the agent.")) @@ -782,7 +800,7 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { // The profile editor // --------------------------------------------------------------------------- -fn render_profile_form(state: &PersonasState, form: &ProfileForm) -> Vec> { +fn render_profile_form(state: &IdentityState, form: &ProfileForm) -> Vec> { let mut lines = vec![Line::from("")]; lines.push( Line::from(if form.profile_id.is_some() { @@ -882,12 +900,12 @@ fn render_profile_form(state: &PersonasState, form: &ProfileForm) -> Vec Vec> { +fn render_bind_picker(state: &IdentityState, picker: &BindPicker) -> Vec> { let mut lines = vec![Line::from("")]; lines.push( Line::from(format!( " What should {} present to {}?", - picker.face_label, picker.community + picker.persona_label, picker.community )) .fg(COLOR_SUCCESS) .bold(), @@ -897,13 +915,13 @@ fn render_bind_picker(state: &PersonasState, picker: &BindPicker) -> Vec Vec Vec { +pub fn bind_options(state: &IdentityState) -> Vec { let mut options = vec!["Nothing — present no identity here".to_string()]; options.extend(state.profiles.iter().map(|p| { format!( @@ -994,7 +1012,7 @@ mod tests { .join("\n") } - fn loaded(state: &mut PersonasState) { + fn loaded(state: &mut IdentityState) { state.loaded = true; state.loading = false; } @@ -1004,9 +1022,9 @@ mod tests { /// confident claim about the holder's own data, and it would be wrong. #[test] fn an_unreachable_agent_never_reads_as_an_empty_pool() { - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); let empty = text(&render(&state)); @@ -1077,10 +1095,10 @@ mod tests { label: Some("Work email".into()), ..PoolAttribute::default() }; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, attributes: vec![attr].into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1097,30 +1115,30 @@ mod tests { ); } - /// A face shown to more than one community carries the linkage warning. + /// A persona shown to more than one community carries the linkage warning. /// Nothing else on screen lets a holder work that out from a single row. #[test] - fn a_reused_face_is_flagged_as_linkable() { - let mut state = PersonasState { + fn a_reused_persona_is_flagged_as_linkable() { + let mut state = IdentityState { tab: PersonaTab::Communities, memberships: vec![ PersonaMembership { community_name: "Acme".into(), - face_label: "Work me".into(), + persona_label: "Work me".into(), status_label: "Member".into(), shared_with: 1, ..PersonaMembership::default() }, PersonaMembership { community_name: "Chess Club".into(), - face_label: "Gaming me".into(), + persona_label: "Gaming me".into(), status_label: "Member".into(), shared_with: 0, ..PersonaMembership::default() }, ] .into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1129,7 +1147,7 @@ mod tests { assert_eq!( out.matches("also shown to").count(), 1, - "the face used once must not be flagged: {out}" + "the persona used once must not be flagged: {out}" ); } @@ -1142,14 +1160,14 @@ mod tests { community_name: "Acme".into(), sub_context_id: "ctx".into(), persona_did: "did:webvh:example.com:alice".into(), - face_label: "Work me".into(), + persona_label: "Work me".into(), status_label: "Member".into(), shared_with: 0, }; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Communities, memberships: vec![membership.clone()].into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); assert!(text(&render(&state)).contains("presents: unknown")); @@ -1171,10 +1189,10 @@ mod tests { label: Some("Work email".into()), ..PoolAttribute::default() }; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, attributes: vec![attr].into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1206,34 +1224,34 @@ mod tests { /// a menu of other keys do not belong on screen together. #[test] fn a_confirmation_replaces_the_key_hints() { - let mut state = PersonasState { - tab: PersonaTab::Faces, - faces: vec![ManagedDid { + let mut state = IdentityState { + tab: PersonaTab::Personas, + personas: vec![ManagedDid { did: "did:webvh:example.com:alice".into(), label: "Work me".into(), ..ManagedDid::default() }] .into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); - assert!(text(&render(&state)).contains("n: new face")); + assert!(text(&render(&state)).contains("n: new persona")); - state.confirm = PersonaConfirm::DeleteFace(0); + state.confirm = PersonaConfirm::DeletePersona(0); let armed = text(&render(&state)); assert!(armed.contains("Remove Work me"), "{armed}"); - assert!(!armed.contains("n: new face"), "{armed}"); + assert!(!armed.contains("n: new persona"), "{armed}"); } /// The destructive confirm names what the operator selected *and* keeps the /// DID, so it is both recognisable and unambiguous. Moved here with the /// list it prompts over. #[test] - fn the_face_prompt_keeps_both_the_name_and_the_did() { + fn the_persona_prompt_keeps_both_the_name_and_the_did() { const DID: &str = "did:webvh:QmScidAliceAAAAAAAAAAAAAAAAAAAAAA:example.com:alice"; - let mut state = PersonasState { - tab: PersonaTab::Faces, - faces: vec![ManagedDid { + let mut state = IdentityState { + tab: PersonaTab::Personas, + personas: vec![ManagedDid { did: DID.into(), agent_name: Some("example.com/@alice".into()), label: String::new(), @@ -1241,8 +1259,8 @@ mod tests { is_active: false, }] .into(), - confirm: PersonaConfirm::DeleteFace(0), - ..PersonasState::default() + confirm: PersonaConfirm::DeletePersona(0), + ..IdentityState::default() }; loaded(&mut state); @@ -1261,17 +1279,17 @@ mod tests { /// With no name at all the prompt falls back to the DID alone — never to an /// empty parenthetical or a nameless "this identity". #[test] - fn the_face_prompt_without_a_name_shows_the_did() { + fn the_persona_prompt_without_a_name_shows_the_did() { const DID: &str = "did:webvh:QmScidAliceAAAAAAAAAAAAAAAAAAAAAA:example.com:alice"; - let mut state = PersonasState { - tab: PersonaTab::Faces, - faces: vec![ManagedDid { + let mut state = IdentityState { + tab: PersonaTab::Personas, + personas: vec![ManagedDid { did: DID.into(), ..ManagedDid::default() }] .into(), - confirm: PersonaConfirm::DeleteFace(0), - ..PersonasState::default() + confirm: PersonaConfirm::DeletePersona(0), + ..IdentityState::default() }; loaded(&mut state); @@ -1292,7 +1310,7 @@ mod tests { /// rather than as an empty row. #[test] fn the_bind_picker_offers_nothing_as_a_first_class_choice() { - let state = PersonasState::default(); + let state = IdentityState::default(); let options = bind_options(&state); assert_eq!(options.len(), 1); assert!(options[0].starts_with("Nothing")); @@ -1303,9 +1321,9 @@ mod tests { /// does anyone already know", and an unexplained blank answers nothing. #[test] fn an_empty_history_explains_itself_rather_than_going_blank() { - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Disclosures, - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1322,7 +1340,7 @@ mod tests { #[test] fn a_disclosure_row_shows_its_rungs_and_flags_a_live_credential() { use openvtc_core::persona::disclosure::{DisclosedClaim, DisclosureRow}; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Disclosures, disclosures: vec![ DisclosureRow { @@ -1348,7 +1366,7 @@ mod tests { }, ] .into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1367,7 +1385,7 @@ mod tests { #[test] fn an_inline_claim_says_it_lives_only_in_the_profile() { use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Profiles, open_profile: Some(ProfileDetail { summary: ProfileSummary { @@ -1391,7 +1409,7 @@ mod tests { ], ..ProfileDetail::default() }), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); diff --git a/openvtc/src/ui/pages/main/components/mod.rs b/openvtc/src/ui/pages/main/components/mod.rs index 3e98d79..74e9add 100644 --- a/openvtc/src/ui/pages/main/components/mod.rs +++ b/openvtc/src/ui/pages/main/components/mod.rs @@ -5,11 +5,11 @@ pub mod capabilities_panel; pub mod communities_panel; pub mod content_panel; pub mod credentials_panel; +pub mod identity_panel; pub mod inbox_panel; pub mod logs_panel; pub mod menu_panel; pub mod panel; -pub mod personas_panel; pub mod relationships_panel; pub mod settings_panel; pub mod status; diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 01384fa..a2c4e14 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -352,7 +352,7 @@ impl MainPage { MainMenu::Logs => self.handle_logs_key(key), MainMenu::Help => self.handle_help_key(key), MainMenu::Communities => self.handle_communities_key(key), - MainMenu::Personas => self.handle_personas_key(key), + MainMenu::Identity => self.handle_personas_key(key), MainMenu::Vta => self.handle_vta_key(key), _ => false, } @@ -472,7 +472,7 @@ impl MainPage { PersonaConfirm, PersonaMode, PersonaTab, ProfileFormFocus, }; - let personas = &self.props.main_page.content_panel.personas; + let personas = &self.props.main_page.content_panel.identity; let send = |action: PA| { let _ = self.action_tx.send(Action::Persona(action)); true @@ -532,13 +532,13 @@ impl MainPage { // ── An armed confirmation owns y/n ──────────────────────────────── // - // Removing a face goes through the existing identity-deletion path + // Removing a persona goes through the existing identity-deletion path // (`DeleteDid`), which does the VTA delete, the listener teardown and // the local cleanup. The pane owns the *question*; it does not own a // second implementation of the answer. match personas.confirm { PersonaConfirm::None => {} - PersonaConfirm::DeleteFace(i) => { + PersonaConfirm::DeletePersona(i) => { let act = match key.code { KeyCode::Char('y') | KeyCode::Enter => Action::DeleteDid(i), _ => Action::DidCancelDelete, @@ -574,9 +574,9 @@ impl MainPage { } match personas.tab { - PersonaTab::Faces => { - let count = personas.faces.len(); - let selected = personas.face_selected; + PersonaTab::Personas => { + let count = personas.personas.len(); + let selected = personas.persona_selected; match key.code { KeyCode::Up if count > 0 => { let _ = self @@ -598,14 +598,14 @@ impl MainPage { let _ = self.action_tx.send(Action::StartAgentNameManager(selected)); true } - // Only an orphan is removable: a face a community still + // Only an orphan is removable: a persona a community still // presents cannot be deleted without leaving that community // first, and the deletion path refuses it anyway. Refusing // to *arm* it keeps the prompt from appearing over a // question that has already been answered no. KeyCode::Char('d') | KeyCode::Delete if selected < count => { if personas - .faces + .personas .get(selected) .is_some_and(|f| f.bound_communities == 0) { @@ -3389,9 +3389,9 @@ mod key_handler_tests { } } - // ----- Identity pane: faces --------------------------------------------- + // ----- Identity pane: personas --------------------------------------------- - fn face(did: &str, label: &str, bound: usize) -> ManagedDid { + fn persona(did: &str, label: &str, bound: usize) -> ManagedDid { ManagedDid { did: did.to_string(), agent_name: None, @@ -3401,26 +3401,26 @@ mod key_handler_tests { } } - /// Two faces, the second one an orphan and selected. - fn faces_with_orphan_selected() -> impl Fn(&mut State) { + /// Two personas, the second one an orphan and selected. + fn personas_with_orphan_selected() -> impl Fn(&mut State) { move |s: &mut State| { - let p = &mut s.main_page.content_panel.personas; - p.faces = vec![ - face("did:webvh:example.com:alice", "Alice", 1), - face("did:webvh:example.com:bob", "Bob", 0), + let p = &mut s.main_page.content_panel.identity; + p.personas = vec![ + persona("did:webvh:example.com:alice", "Alice", 1), + persona("did:webvh:example.com:bob", "Bob", 0), ] .into(); - p.face_selected = 1; + p.persona_selected = 1; } } - /// Removing a face is armed here and answered by the existing + /// Removing a persona is armed here and answered by the existing /// identity-deletion path — the pane owns the question, not a second /// implementation of the answer. #[test] - fn personas_face_confirm_commits_and_cancels() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - s.main_page.content_panel.personas.confirm = PersonaConfirm::DeleteFace(1); + fn personas_confirm_commits_and_cancels() { + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(1); }); page.handle_key_event(press(KeyCode::Enter)); match rx.try_recv() { @@ -3428,8 +3428,8 @@ mod key_handler_tests { _ => panic!("expected DeleteDid(1)"), } - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - s.main_page.content_panel.personas.confirm = PersonaConfirm::DeleteFace(0); + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(0); }); page.handle_key_event(press(KeyCode::Esc)); match rx.try_recv() { @@ -3438,33 +3438,33 @@ mod key_handler_tests { } } - /// Only an orphan arms: a face a community still presents cannot be + /// Only an orphan arms: a persona a community still presents cannot be /// deleted without leaving that community, and the deletion path refuses /// it. Arming anyway would put a question that has already been answered. #[test] - fn personas_d_arms_only_on_an_orphan_face() { - let (mut page, mut rx) = page_for(MainMenu::Personas, faces_with_orphan_selected()); + fn personas_d_arms_only_on_an_orphan_persona() { + let (mut page, mut rx) = page_for(MainMenu::Identity, personas_with_orphan_selected()); page.handle_key_event(press(KeyCode::Char('d'))); match rx.try_recv() { Ok(Action::DidConfirmDelete(1)) => {} _ => panic!("expected DidConfirmDelete(1)"), } - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - let p = &mut s.main_page.content_panel.personas; - p.faces = vec![face("did:webvh:example.com:alice", "Alice", 1)].into(); - p.face_selected = 0; + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + let p = &mut s.main_page.content_panel.identity; + p.personas = vec![persona("did:webvh:example.com:alice", "Alice", 1)].into(); + p.persona_selected = 0; }); page.handle_key_event(press(KeyCode::Char('d'))); assert!( rx.try_recv().is_err(), - "a face a community presents must not arm a deletion" + "a persona a community presents must not arm a deletion" ); } #[test] fn personas_n_opens_create_persona() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |_| {}); + let (mut page, mut rx) = page_for(MainMenu::Identity, |_| {}); page.handle_key_event(press(KeyCode::Char('n'))); match rx.try_recv() { Ok(Action::StartCreatePersona) => {} @@ -3473,8 +3473,8 @@ mod key_handler_tests { } #[test] - fn personas_g_opens_agent_name_manager_for_the_selected_face() { - let (mut page, mut rx) = page_for(MainMenu::Personas, faces_with_orphan_selected()); + fn personas_g_opens_agent_name_manager_for_the_selected_persona() { + let (mut page, mut rx) = page_for(MainMenu::Identity, personas_with_orphan_selected()); page.handle_key_event(press(KeyCode::Char('g'))); match rx.try_recv() { Ok(Action::StartAgentNameManager(1)) => {} @@ -3484,18 +3484,18 @@ mod key_handler_tests { // ----- Identity pane: tabs and their verbs ------------------------------- - /// Tab moves between the pane's four tabs; the verbs that follow belong to + /// Tab moves between the pane's five tabs; the verbs that follow belong to /// whichever one is showing. #[test] fn personas_tab_moves_between_tabs() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |_| {}); + let (mut page, mut rx) = page_for(MainMenu::Identity, |_| {}); page.handle_key_event(press(KeyCode::Tab)); assert!(matches!( rx.try_recv(), Ok(Action::Persona(PersonaAction::TabNext)) )); - let (mut page, mut rx) = page_for(MainMenu::Personas, |_| {}); + let (mut page, mut rx) = page_for(MainMenu::Identity, |_| {}); page.handle_key_event(press(KeyCode::BackTab)); assert!(matches!( rx.try_recv(), @@ -3509,8 +3509,8 @@ mod key_handler_tests { fn personas_attribute_keys_map_to_actions() { use openvtc_core::persona::pool::PoolAttribute; let open = || { - page_for(MainMenu::Personas, |s| { - let p = &mut s.main_page.content_panel.personas; + page_for(MainMenu::Identity, |s| { + let p = &mut s.main_page.content_panel.identity; p.tab = PersonaTab::Attributes; p.attributes = vec![PoolAttribute { attribute_id: "01A".into(), @@ -3545,8 +3545,8 @@ mod key_handler_tests { #[test] fn personas_confirmation_owns_the_keyboard() { let armed = || { - page_for(MainMenu::Personas, |s| { - let p = &mut s.main_page.content_panel.personas; + page_for(MainMenu::Identity, |s| { + let p = &mut s.main_page.content_panel.identity; p.tab = PersonaTab::Attributes; p.confirm = PersonaConfirm::DeleteAttribute { attribute_id: "01A".into(), @@ -3580,8 +3580,8 @@ mod key_handler_tests { fn personas_space_ticks_only_on_the_entry_list() { use crate::state_handler::main_page::content::{ProfileForm, ProfileFormFocus}; let with_focus = |focus: ProfileFormFocus| { - page_for(MainMenu::Personas, move |s| { - s.main_page.content_panel.personas.mode = PersonaMode::Profile(ProfileForm { + page_for(MainMenu::Identity, move |s| { + s.main_page.content_panel.identity.mode = PersonaMode::Profile(ProfileForm { focus, ..ProfileForm::default() }); @@ -3608,8 +3608,8 @@ mod key_handler_tests { #[test] fn personas_a_working_form_swallows_input() { use crate::state_handler::main_page::content::AttributeForm; - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - s.main_page.content_panel.personas.mode = PersonaMode::Attribute(AttributeForm { + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.mode = PersonaMode::Attribute(AttributeForm { working: true, ..AttributeForm::default() }); @@ -3739,7 +3739,7 @@ mod key_handler_tests { /// the TUI had. Everything it stood for is now a key inside the pane. #[test] fn menu_enter_on_identity_switches_to_the_panel() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { s.main_page.menu_panel.selected = true; s.main_page.content_panel.selected = false; }); @@ -3755,7 +3755,7 @@ mod key_handler_tests { use crate::state_handler::main_page::content::CreatePersonaState; // The open overlay owns all input regardless of the focused panel. let open = || { - page_for(MainMenu::Personas, |s| { + page_for(MainMenu::Identity, |s| { s.main_page.create_persona = Some(CreatePersonaState::default()); }) }; @@ -3786,7 +3786,7 @@ mod key_handler_tests { fn create_persona_done_phase_keys() { use crate::state_handler::main_page::content::{CreatePersonaPhase, CreatePersonaState}; let done = || { - page_for(MainMenu::Personas, |s| { + page_for(MainMenu::Identity, |s| { s.main_page.create_persona = Some(CreatePersonaState { phase: CreatePersonaPhase::Done, did: Some("did:webvh:example:alice".to_string()), diff --git a/tasks/follow-ups.md b/tasks/follow-ups.md index 172b707..a371ff6 100644 --- a/tasks/follow-ups.md +++ b/tasks/follow-ups.md @@ -26,7 +26,7 @@ support for rotating a persona's keys today. ### [ ] Identity pane — the four `persona/*` verbs it does not offer The pane (`ui/pages/main/components/personas_panel.rs` + `state_handler/persona_actions.rs`) -covers faces, the pool, profiles, bindings and the disclosure history. Four +covers personas, the pool, profiles, bindings and the disclosure history. Four parts of the family are deliberately not on it, each for a reason worth keeping: - **Authoring a credential-backed or generated attribute.** Both are shown and From 0511dd1effe0568c607a5d1f35f4470b12ee53b6 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 13:48:26 +0200 Subject: [PATCH 2/4] refactor(persona): speak the vocabulary a person reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `design-docs/persona-vocabulary.md` fixes one set of words across the console, `pnm`, the mobile agent and this TUI, so a person meets the same sentence wherever they meet the model. This brings the identity pane to it. The substitutions that matter: - an **attribute** is a **fact** — "a fact about you, held once"; - a **profile** is a **face** — "the set of facts you show together". Not "profile": that already means three things in this product and "my LinkedIn page" to everyone else; - a **binding** is **wearing** — a persona wears a face in a community. `Change face`, `Take it off`; - **disclosures** are **what has left, and to whom**; **correlation** is **linked**; provenance reads *you said so* / *credential* / *made per verifier*, and the credential row carries the half people miss — the same signature everywhere, so it links you. Tabs: Personas · Your facts · Faces · Communities · What has left. The spec's words are exact and stay in the types, on the wire and in the audit log. They are kept off the *screen*: `PersonaTab::Profiles` still addresses `persona/profile/*`; its label says Faces. That split is the whole design of the table, and it is why this is a display-layer change rather than a rename. `stale` now carries its reason (`stale · revoked`) — the table asks for it, and "stale" alone tells someone something is wrong without telling them what. The agent was already sending `staleReason`; nothing was reading it. ## The guard A test renders every tab — empty and populated — plus all three editors, and fails if any word from the table's avoid-list reaches the screen. Every one of them is a word this pane's own code and wire format use, so they are one careless `format!` away at all times, and the drift is invisible in review because each line looks correct to whoever wrote it. Verified by flipping a label back: the test fails, rather than passing vacuously. Signed-off-by: Glenn Gore --- CHANGELOG.md | 19 + openvtc-core/src/persona/binding.rs | 43 ++- openvtc-core/src/persona/disclosure.rs | 47 ++- openvtc-core/src/persona/pool.rs | 63 ++- openvtc-core/src/persona/profile.rs | 10 +- .../src/state_handler/main_page/content.rs | 15 +- openvtc/src/state_handler/persona_actions.rs | 32 +- .../pages/main/components/identity_panel.rs | 359 ++++++++++++------ 8 files changed, 417 insertions(+), 171 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e0d92..4502f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- **The identity pane speaks the words a person would use.** Following + `design-docs/persona-vocabulary.md`, which fixes one vocabulary across the + console, `pnm`, the mobile agent and this TUI: an *attribute* is a **fact**, + a *profile* is a **face** — the set of facts you show together — and a persona + **wears** a face in a community. The tabs read *Personas · Your facts · Faces · + Communities · What has left*. + + The spec's words are exact and are not being replaced in code, on the wire or + in the audit log; they are kept off the screen. `persona/attribute/put` stays + `persona/attribute/put` — the form says *Add a fact*. + + A test renders every tab, both empty and populated, plus each editor, and + fails if any word from the table's avoid-list reaches the screen. Each of them + is a word this pane's own code uses, so they are one careless `format!` away + at all times, and the drift is invisible in review. + + ### Added - **Setup now asks for the grant the identity pane needs.** The `pnm` command on diff --git a/openvtc-core/src/persona/binding.rs b/openvtc-core/src/persona/binding.rs index c474dbe..6522af7 100644 --- a/openvtc-core/src/persona/binding.rs +++ b/openvtc-core/src/persona/binding.rs @@ -1,5 +1,5 @@ -//! What each of your personas actually says — the profile one presents in a -//! given community's context. **Context-scoped**; see the [module header](super) +//! What each of your personas actually says — the face one wears in a given +//! community's context. **Context-scoped**; see the [module header](super) //! for the boundary this sits on the low side of. //! //! A membership already carries both halves of the key: a @@ -77,29 +77,33 @@ impl BindingSummary { } } - /// A one-line description for a panel row. + /// A one-line description for a panel row, in the words a person reads. + /// + /// A persona *wears* a face in a context — the on-screen vocabulary for what + /// the wire calls binding a profile (`design-docs/persona-vocabulary.md`). + /// The spec's words stay in the types; they are kept off the screen. /// /// Three distinct readings, deliberately worded so they cannot be confused: - /// we do not know; we know nothing is bound; we know what is bound. + /// we do not know; we know nothing is worn; we know what is worn. #[must_use] pub fn describe(&self) -> String { if self.unknown { - return "presents: unknown".to_string(); + return "wears: unknown".to_string(); } if !self.bound { - return "presents: nothing".to_string(); + return "wears: nothing".to_string(); } let label = self .profile_name .clone() .or_else(|| self.profile_id.clone()) - .unwrap_or_else(|| "an unlabelled profile".to_string()); - let claims = if self.claim_count == 1 { - "1 claim".to_string() + .unwrap_or_else(|| "an unnamed face".to_string()); + let facts = if self.claim_count == 1 { + "1 fact".to_string() } else { - format!("{} claims", self.claim_count) + format!("{} facts", self.claim_count) }; - format!("presents: {label} ({claims})") + format!("wears: {label} ({facts})") } } @@ -200,8 +204,8 @@ mod tests { #[test] fn unknown_is_not_the_same_as_unbound() { - assert_eq!(BindingSummary::unknown().describe(), "presents: unknown"); - assert_eq!(BindingSummary::default().describe(), "presents: nothing"); + assert_eq!(BindingSummary::unknown().describe(), "wears: unknown"); + assert_eq!(BindingSummary::default().describe(), "wears: nothing"); } /// The distinction the `unknown` flag exists to preserve. @@ -224,20 +228,20 @@ mod tests { claim_count: 3, ..Default::default() }; - assert_eq!(s.describe(), "presents: work (3 claims)"); + assert_eq!(s.describe(), "wears: work (3 facts)"); } /// One claim is not "1 claims". Small, and the kind of thing that makes a /// panel look unfinished. #[test] - fn a_single_claim_is_singular() { + fn a_single_fact_is_singular() { let s = BindingSummary { bound: true, profile_name: Some("gaming".into()), claim_count: 1, ..Default::default() }; - assert_eq!(s.describe(), "presents: gaming (1 claim)"); + assert_eq!(s.describe(), "wears: gaming (1 fact)"); } /// A profile with no label falls back to its id, and then to a phrase — @@ -251,16 +255,13 @@ mod tests { claim_count: 2, ..Default::default() }; - assert_eq!(by_id.describe(), "presents: 01J8 (2 claims)"); + assert_eq!(by_id.describe(), "wears: 01J8 (2 facts)"); let bare = BindingSummary { bound: true, claim_count: 2, ..Default::default() }; - assert_eq!( - bare.describe(), - "presents: an unlabelled profile (2 claims)" - ); + assert_eq!(bare.describe(), "wears: an unnamed face (2 facts)"); } } diff --git a/openvtc-core/src/persona/disclosure.rs b/openvtc-core/src/persona/disclosure.rs index dcc3d4f..9c0bf07 100644 --- a/openvtc-core/src/persona/disclosure.rs +++ b/openvtc-core/src/persona/disclosure.rs @@ -111,24 +111,41 @@ impl DisclosureRow { } } - /// The claims as one line: `email.work (whole), age.over18 (predicate)`. + /// The facts as one line: `email.work (whole), age.over18 (yes/no only)`. /// - /// The rung travels with every type rather than being summarised, because + /// The rung travels with every fact rather than being summarised, because /// there is no summary of a mixed release that is not misleading in one - /// direction or the other. + /// direction or the other — and because severity inverts intuition: a + /// credential shown *whole* links the holder more than a fact they simply + /// asserted. #[must_use] pub fn describe_claims(&self) -> String { if self.claims.is_empty() { - return "no claims recorded".to_string(); + return "no facts recorded".to_string(); } self.claims .iter() - .map(|c| format!("{} ({})", c.claim_type, c.rung)) + .map(|c| format!("{} ({})", c.claim_type, rung_label(&c.rung))) .collect::>() .join(", ") } } +/// What a person reads for a proof rung +/// (`design-docs/persona-vocabulary.md`). An unrecognised rung is shown +/// verbatim rather than mapped to a friendlier neighbour: the words carry a +/// privacy ordering, and guessing one would misstate how much left. +#[must_use] +fn rung_label(rung: &str) -> &str { + match rung { + "whole" => "whole", + "selectiveDisclosure" => "partly", + "derived" => "derived", + "predicate" => "yes/no only", + other => other, + } +} + /// Every release, newest first, across every context. /// /// `limit` caps the read: a history is append-only and unbounded, and a panel @@ -158,8 +175,12 @@ pub async fn history( mod tests { use super::*; + /// Rungs are shown in the words the vocabulary fixes, and an unrecognised + /// one is passed through rather than mapped to a friendlier neighbour — the + /// four words carry a privacy ordering, so guessing would misstate how much + /// of the fact left. #[test] - fn a_claim_carries_its_rung_into_the_row() { + fn a_fact_carries_its_rung_into_the_row() { let row = DisclosureRow::from_wire(&serde_json::json!({ "disclosureId": "01D", "contextId": "ctx", @@ -172,10 +193,18 @@ mod tests { })); assert_eq!( row.describe_claims(), - "email.work (whole), age.over18 (predicate)" + "email.work (whole), age.over18 (yes/no only)" ); } + #[test] + fn an_unknown_rung_is_shown_verbatim() { + let row = DisclosureRow::from_wire(&serde_json::json!({ + "claims": [{ "type": "email.work", "rung": "someFutureRung" }], + })); + assert_eq!(row.describe_claims(), "email.work (someFutureRung)"); + } + /// An unrecorded rung reads as `whole`. /// /// It is the least private of the four, and the conservative answer for an @@ -193,8 +222,8 @@ mod tests { /// A release with nothing recorded says so, rather than rendering as a /// blank line that reads like a release of nothing. #[test] - fn a_claimless_record_says_so() { + fn a_factless_record_says_so() { let row = DisclosureRow::default(); - assert_eq!(row.describe_claims(), "no claims recorded"); + assert_eq!(row.describe_claims(), "no facts recorded"); } } diff --git a/openvtc-core/src/persona/pool.rs b/openvtc-core/src/persona/pool.rs index 8446d0f..1854f20 100644 --- a/openvtc-core/src/persona/pool.rs +++ b/openvtc-core/src/persona/pool.rs @@ -76,13 +76,34 @@ impl ProvenanceKind { matches!(self, Self::SelfAsserted) } - /// One word for a panel row. + /// 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 => "self-asserted", - Self::CredentialBacked => "credential-backed", - Self::Generated => "generated", + Self::SelfAsserted => "you said so", + Self::CredentialBacked => "credential", + Self::Generated => "made per verifier", + } + } + + /// 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 facts 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, + Self::CredentialBacked => Some("same signature everywhere — links you"), + Self::Generated => Some("different for everyone — cannot link you"), } } } @@ -106,6 +127,10 @@ pub struct PoolAttribute { /// 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, /// Optimistic-concurrency token, passed back on edit so two editors cannot /// silently overwrite each other. pub version: u64, @@ -132,6 +157,10 @@ impl PoolAttribute { 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"), } @@ -144,7 +173,7 @@ impl PoolAttribute { match self.label.as_deref() { Some(label) if !label.trim().is_empty() => label, _ if !self.claim_type.is_empty() => &self.claim_type, - _ => "(unnamed attribute)", + _ => "(unnamed fact)", } } @@ -156,7 +185,10 @@ impl PoolAttribute { #[must_use] pub fn display_value(&self, values_requested: bool) -> String { if self.stale { - return "(unavailable — its credential could not be read)".to_string(); + return match &self.stale_reason { + Some(reason) => format!("stale · {reason} — can no longer be proven"), + None => "stale — can no longer be proven".to_string(), + }; } match &self.value { Some(Value::String(s)) => s.clone(), @@ -215,18 +247,18 @@ impl AttributeEdit { pub fn refusal(kind: ProvenanceKind) -> Self { Self::Refused(match kind { ProvenanceKind::CredentialBacked => { - "This attribute's value comes from a credential — editing it here would turn an \ - attested claim into a typed one. Change it at its source, or replace the \ + "This fact 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 => { - "This attribute is minted by the agent, usually a fresh value per verifier. There \ - is no single value to edit." + "Your agent makes this one per verifier — a different value for everyone, so \ + there is no single value to edit." .to_string() } ProvenanceKind::SelfAsserted => { - "This attribute is editable; nothing should have refused it.".to_string() + "You said this one, so it is editable; nothing should have refused it.".to_string() } }) } @@ -406,7 +438,14 @@ mod tests { assert_eq!(attr.display_value(false), "(hidden)"); assert_eq!(attr.display_value(true), "(no value)"); attr.stale = true; - assert!(attr.display_value(true).contains("could not be read")); + assert!(attr.display_value(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(true), + "stale · revoked — can no longer be proven" + ); } /// A string value renders as itself, not as a quoted JSON string — the diff --git a/openvtc-core/src/persona/profile.rs b/openvtc-core/src/persona/profile.rs index 1ce4849..e320ce9 100644 --- a/openvtc-core/src/persona/profile.rs +++ b/openvtc-core/src/persona/profile.rs @@ -91,12 +91,12 @@ impl ProfileSummary { } } - /// The name to show. Never empty: an unnamed profile still has to be + /// The name to show. Never empty: an unnamed face still has to be /// selectable in a list. #[must_use] pub fn display_name(&self) -> &str { if self.name.trim().is_empty() { - "(unnamed profile)" + "unnamed face" } else { &self.name } @@ -144,7 +144,7 @@ impl ResolvedClaim { #[must_use] pub fn display_value(&self) -> String { if self.stale { - return "(unavailable — its credential could not be read)".to_string(); + return "stale — can no longer be proven".to_string(); } match &self.value { Some(Value::String(s)) => s.clone(), @@ -185,7 +185,7 @@ impl ProfileDetail { #[must_use] pub fn refusal(&self) -> String { format!( - "This profile has {} entr{} this version of OpenVTC cannot read, so saving would drop \ + "This face has {} entr{} this version of OpenVTC cannot read, so saving would drop \ {}. Edit it with `pnm persona profile`, or upgrade.", self.unreadable_entries, if self.unreadable_entries == 1 { @@ -424,7 +424,7 @@ mod tests { "value": Value::Null, "stale": true, })); - assert!(claim.display_value().contains("could not be read")); + assert!(claim.display_value().contains("can no longer be proven")); } /// The put ordering: ticked entries first, preserved forms after, so the diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index a9125ef..ec63e83 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -435,15 +435,22 @@ impl PersonaTab { ] } - /// The tab's name in the header strip. + /// The tab's name in the header strip — the words a person reads, which are + /// not the words the code and the wire use + /// (`design-docs/persona-vocabulary.md`). + /// + /// The variants keep the spec's nouns because that is what they address: + /// `Profiles` is `persona/profile/*`. The screen says *Faces*, because + /// "profile" already means three things in this product and "my LinkedIn + /// page" to everyone else. #[must_use] pub fn label(self) -> &'static str { match self { PersonaTab::Personas => "Personas", - PersonaTab::Attributes => "Attributes", - PersonaTab::Profiles => "Profiles", + PersonaTab::Attributes => "Your facts", + PersonaTab::Profiles => "Faces", PersonaTab::Communities => "Communities", - PersonaTab::Disclosures => "Disclosures", + PersonaTab::Disclosures => "What has left", } } diff --git a/openvtc/src/state_handler/persona_actions.rs b/openvtc/src/state_handler/persona_actions.rs index 2021d71..348f966 100644 --- a/openvtc/src/state_handler/persona_actions.rs +++ b/openvtc/src/state_handler/persona_actions.rs @@ -16,6 +16,14 @@ //! setting `refresh_queued` rather than starting one, because its own outcome //! is still holding the domain — the same shape the VIC manager uses. //! +//! # The words, and where they change +//! +//! This module is engineering-side: it keeps the spec's nouns +//! (`attribute`, `profile`, `binding`), because those are what it addresses on +//! the wire. The strings it *hands to the panel* — status lines, refusals — use +//! the vocabulary a person reads (`design-docs/persona-vocabulary.md`): a fact, +//! a face, wearing one. +//! //! # Questions are asked once, and correctly //! //! Deleting an attribute a profile references is refused by the VTA unless the @@ -454,7 +462,7 @@ fn form_submit(state: &mut State) -> PersonaEffect { PersonaMode::Profile(form) => { let name = form.name.value().trim().to_string(); if name.is_empty() { - form.error = Some("A name is required — \"Work\", \"Gaming\".".to_string()); + form.error = Some("A face needs a name — \"Work\", \"Gaming\".".to_string()); return PersonaEffect::None; } form.error = None; @@ -618,7 +626,7 @@ impl PersonaJobRun { let client = self.admin_vta; match self.job { PersonaJob::AttributePut(draft) => PersonaOutcome::Written { - verb: "Saved attribute", + verb: "Saved the fact", error: pool::put(&client, draft) .await .err() @@ -628,7 +636,7 @@ impl PersonaJobRun { attribute_id, cascade, } => PersonaOutcome::Written { - verb: "Deleted attribute", + verb: "Forgot the fact", error: pool::delete(&client, &attribute_id, cascade) .await .err() @@ -641,7 +649,7 @@ impl PersonaJobRun { other_entries, expected_version, } => PersonaOutcome::Written { - verb: "Saved profile", + verb: "Saved the face", error: profile::put( &client, profile_id.as_deref(), @@ -655,7 +663,7 @@ impl PersonaJobRun { .map(|e| format!("{e}")), }, PersonaJob::ProfileDelete { profile_id, unbind } => PersonaOutcome::Written { - verb: "Deleted profile", + verb: "Deleted the face", error: profile::delete(&client, &profile_id, unbind) .await .err() @@ -837,9 +845,9 @@ impl PersonaOutcome { None => { p.mode = PersonaMode::View; let msg = if cleared { - format!("{community} is now shown nothing.") + format!("Taken off. {community} is now shown nothing.") } else { - format!("Updated what {community} is shown.") + format!("Changed the face {community} sees.") }; p.status_message = Some(msg.clone()); state.main_page.log(msg); @@ -853,7 +861,7 @@ impl PersonaOutcome { } state .main_page - .log_error("Changing what a persona presents failed", e.as_str()); + .log_error("Changing the face failed", e.as_str()); } } } @@ -964,7 +972,7 @@ mod tests { personas(&state).confirm, PersonaConfirm::DeleteProfile { profile_id: "01P".into(), - name: "(unnamed profile)".into(), + name: "unnamed face".into(), unbind: true } ); @@ -973,7 +981,7 @@ mod tests { personas(&state).confirm, PersonaConfirm::DeleteProfile { profile_id: "01Q".into(), - name: "(unnamed profile)".into(), + name: "unnamed face".into(), unbind: false } ); @@ -1130,7 +1138,7 @@ mod tests { for error in [None, Some("refused".to_string())] { let mut state = State::default(); PersonaOutcome::Written { - verb: "Saved attribute", + verb: "Saved the fact", error, } .apply(&mut state); @@ -1202,7 +1210,7 @@ mod tests { }); PersonaOutcome::Written { - verb: "Saved attribute", + verb: "Saved the fact", error: Some("version conflict".to_string()), } .apply(&mut state); diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index 9560217..a5553c6 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -1,36 +1,42 @@ -//! The identity pane — every surface for the holder's own identity, in one -//! place. +//! The identity pane — every surface for a person's own identity, in one place. //! -//! Five tabs, in the order the concepts build on each other: the **personas** a -//! holder presents, the **attributes** they hold about themselves, the -//! **profiles** that project a subset of those attributes, the **communities** -//! each persona is shown to, and the **disclosures** that have actually left. -//! The first comes from `Config`; the rest come from the agent. +//! Five tabs, in the order the concepts build on each other. On screen they are +//! **Personas**, **Your facts**, **Faces**, **Communities** and **What has +//! left**; in the code and on the wire they are personas, attributes, profiles, +//! contexts and disclosures. The first comes from `Config`; the rest come from +//! the agent. //! -//! # Two words, and they are not interchangeable +//! # The screen speaks a different language, on purpose //! -//! A **persona** is an identity: a `did:webvh`, its keys, its mediator — the -//! thing a community sees. A **profile** is a named projection over the -//! attribute pool — the thing a persona *presents*. You bind a profile to a -//! persona in a context; the reverse is not a sentence. +//! `design-docs/persona-vocabulary.md` fixes the words a person reads, so the +//! console, `pnm`, the mobile agent and this pane say the same thing. The +//! spec's words are exact and stay in the types, on the wire and in the audit +//! log; they are kept off the screen. `persona/attribute/put` stays +//! `persona/attribute/put` — the form says *Add a fact*. //! -//! Both words are the spec's, the VTA's, the SDK's and `pnm`'s, and this pane -//! uses them the same way so that a holder reading -//! `pnm persona binding set --persona-did … --profile-id …` recognises what is -//! on screen. Where a sentence needs to be unambiguous about which layer it -//! means — the `persona/*` task family spans both — it says "persona DID". +//! The three that matter most here: +//! +//! - an **attribute** is a **fact** — *a fact about you, held once*; +//! - a **profile** is a **face** — *the set of facts you show together*. Not +//! "profile", which already means three things in this product and "my +//! LinkedIn page" to everyone else; +//! - a **binding** is **wearing**: a persona *wears* a face in a community. +//! `Be known here as…`, `Change face`, `Take it off`. +//! +//! A **persona** keeps its own name in both languages. It is already a human +//! word, and it is what a community knows you as. //! //! # What this pane will not draw //! -//! A value the holder never asked to see. The attribute list is fetched without +//! A value the holder never asked to see. The list of facts is fetched without //! values by default and `v` re-reads *with* them — an opt-in that costs a //! round-trip rather than a display flag over data already in memory, because a -//! listing that holds values has already read the holder's identity whether or -//! not the panel chose to paint it. +//! listing that holds values has already read someone's identity whether or not +//! the panel chose to paint it. //! //! A number it does not have. "We could not ask the agent" and "you hold //! nothing" are one pixel apart and one of them is a confident wrong answer -//! about the holder's own data, so every agent-served tab draws the failure +//! about a person's own data, so every agent-served tab draws the failure //! rather than an empty list. use super::panel::Panel; @@ -160,29 +166,30 @@ fn confirm_prompt(state: &IdentityState) -> Option { // The cascading question names the consequence the plain one // does not have: profiles that show this value stop showing it. Some(format!( - "\"{name}\" is used by one or more profiles. Delete it AND remove it from \ - them? y: confirm n: cancel" + "\"{name}\" is on one or more faces. Forget it AND remove it from those \ + faces too? Nothing already shared is affected — that has left. \ + y: confirm n: cancel" )) } else { - Some(format!( - "Delete \"{name}\" from your pool? y: confirm n: cancel" - )) + Some(format!("Forget \"{name}\"? y: confirm n: cancel")) } } PersonaConfirm::DeleteProfile { name, unbind, .. } => { if *unbind { Some(format!( - "A persona still presents \"{name}\". Delete it and leave that persona presenting \ - nothing? y: confirm n: cancel" + "A persona wears \"{name}\". Delete it and leave that persona showing \ + nothing? Nothing already shared is affected — that has left. \ + y: confirm n: cancel" )) } else { Some(format!( - "Delete the profile \"{name}\"? y: confirm n: cancel" + "Delete the face \"{name}\"? y: confirm n: cancel" )) } } PersonaConfirm::Unbind { community, .. } => Some(format!( - "Stop presenting anything to {community}? y: confirm n: cancel" + "Take it off — show {community} nothing? Nothing already shared is affected \ + — that has left. y: confirm n: cancel" )), } } @@ -190,16 +197,16 @@ fn confirm_prompt(state: &IdentityState) -> Option { fn hints(state: &IdentityState) -> &'static str { match state.tab { PersonaTab::Personas => { - "↑/↓ select n: new persona g: agent names d: remove orphan ⇥/⇧⇥: tab" + "↑/↓ select n: new persona g: agent names d: remove unused ⇥/⇧⇥: tab" } PersonaTab::Attributes => { - "↑/↓ select n: new e: edit d: delete v: values r: refresh ⇥/⇧⇥: tab" + "↑/↓ select n: add a fact e: edit d: delete v: values r: refresh ⇥/⇧⇥: tab" } PersonaTab::Profiles => { - "↑/↓ select ⏎: what it shows n: new e: edit d: delete r: refresh ⇥/⇧⇥: tab" + "↑/↓ select ⏎: what it shows n: make a face e: edit d: delete r: refresh ⇥/⇧⇥: tab" } PersonaTab::Communities => { - "↑/↓ select b: choose what this persona shows u: show nothing r: refresh ⇥/⇧⇥: tab" + "↑/↓ select b: change face u: take it off r: refresh ⇥/⇧⇥: tab" } PersonaTab::Disclosures => "↑/↓ select r: refresh ⇥/⇧⇥: tab", } @@ -211,12 +218,12 @@ fn hints(state: &IdentityState) -> &'static str { fn render_personas(state: &IdentityState, lines: &mut Vec>) { if state.personas.is_empty() { - lines.push(Line::from(" You have no persona DIDs yet.").fg(COLOR_DARK_GRAY)); + lines.push(Line::from(" You have no personas yet.").fg(COLOR_DARK_GRAY)); lines.push(Line::from("")); lines.push( Line::from( - " A persona is a did:webvh you present to a community. Mint one with `n`, hand its \ - DID to a community, and they can issue an invitation bound to it.", + " A persona is who a community knows you as. Make one with `n`, hand its DID \ + to a community, and they can issue you an invitation bound to it.", ) .fg(COLOR_DARK_GRAY), ); @@ -267,10 +274,10 @@ fn render_personas(state: &IdentityState, lines: &mut Vec>) { persona.label.clone() }; let seen_by = if orphan { - "orphan — no community".to_string() + "not known anywhere yet".to_string() } else { format!( - "seen by {} communit{}", + "known to {} communit{}", persona.bound_communities, if persona.bound_communities == 1 { "y" @@ -306,14 +313,14 @@ fn render_personas(state: &IdentityState, lines: &mut Vec>) { // --------------------------------------------------------------------------- fn render_attributes(state: &IdentityState, lines: &mut Vec>) { - if push_agent_state(state, lines, "attributes") { + if push_agent_state(state, lines, "facts") { return; } lines.push(Line::from(vec![ Span::styled( format!( - " {} attribute{} in your pool", + " {} fact{} about you", state.attributes.len(), if state.attributes.len() == 1 { "" } else { "s" } ), @@ -337,8 +344,8 @@ fn render_attributes(state: &IdentityState, lines: &mut Vec>) { if state.attributes.is_empty() { lines.push( Line::from( - " Nothing in the pool yet. `n` adds a fact about yourself — a name, an email, a \ - date of birth — that profiles can then draw on.", + " No facts yet. `n` adds one — a name, an email, a date of birth. A fact \ + about you, held once; faces select from these.", ) .fg(COLOR_DARK_GRAY), ); @@ -406,13 +413,12 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { } if detail.resolved.is_empty() { lines.push( - Line::from(" This profile presents nothing — it has no entries.") - .fg(COLOR_DARK_GRAY), + Line::from(" This face shows nothing — no facts are on it.").fg(COLOR_DARK_GRAY), ); } else { lines.push( Line::from(format!( - " Presents {} claim{}:", + " Shows {} fact{}:", detail.resolved.len(), if detail.resolved.len() == 1 { "" } else { "s" } )) @@ -430,12 +436,12 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { Style::new().fg(COLOR_TEXT_DEFAULT), ), ])); - // An inline value lives only in this profile: it is not in the - // pool, so correcting it in the pool will not correct it here. - // Saying so on the row is the only place a holder finds out. + // A value that lives only in this face is not among the + // holder's facts, so correcting it there will not correct it + // here. Saying so on the row is the only place they find out. let origin = match claim.attribute_id { Some(_) => claim.provenance.label().to_string(), - None => format!("{} · only in this profile", claim.provenance.label()), + None => format!("{} · only in this face", claim.provenance.label()), }; lines.push(Line::from(Span::styled( format!(" {origin}"), @@ -448,13 +454,13 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { return; } - if push_agent_state(state, lines, "profiles") { + if push_agent_state(state, lines, "faces") { return; } lines.push( Line::from(format!( - " {} profile{}", + " {} face{}", state.profiles.len(), if state.profiles.len() == 1 { "" } else { "s" } )) @@ -465,8 +471,9 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { if state.profiles.is_empty() { lines.push( Line::from( - " No profiles yet. A profile is a named subset of your pool — \"Work\", \ - \"Gaming\" — and it is what a persona presents to a community.", + " No faces yet. A face is the set of facts you show together — \"Work\", \ + \"Gaming\" — and it is what a persona wears in a community. What you leave \ + unticked stays out, including facts you add later.", ) .fg(COLOR_DARK_GRAY), ); @@ -488,9 +495,9 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { ), Span::styled( format!( - "{} entr{}", + "{} fact{}", profile.entry_count, - if profile.entry_count == 1 { "y" } else { "ies" } + if profile.entry_count == 1 { "" } else { "s" } ), Style::new().fg(COLOR_DARK_GRAY), ), @@ -561,7 +568,7 @@ fn render_communities(state: &IdentityState, lines: &mut Vec>) { if m.shared_with > 0 { lines.push( Line::from(format!( - " ⚠ this persona is also shown to {} other communit{}", + " ⚠ linked: the same persona is known to {} other communit{}", m.shared_with, if m.shared_with == 1 { "y" } else { "ies" } )) @@ -576,9 +583,9 @@ fn render_communities(state: &IdentityState, lines: &mut Vec>) { // `b` changes what that persona says, never who it is. lines.push( Line::from( - " `b` changes what a persona presents here. To show a community a *different* persona, join \ - it again with that persona — the membership credential is bound to the persona that \ - joined.", + " `b` changes the face this persona wears here. To be known to a community as a \ + *different* persona, join it again with that one — the membership credential names \ + the persona that joined.", ) .fg(COLOR_DARK_GRAY), ); @@ -589,13 +596,13 @@ fn render_communities(state: &IdentityState, lines: &mut Vec>) { // --------------------------------------------------------------------------- fn render_disclosures(state: &IdentityState, lines: &mut Vec>) { - if push_agent_state(state, lines, "disclosures") { + if push_agent_state(state, lines, "history") { return; } lines.push( Line::from(format!( - " {} disclosure{}, newest first", + " {} release{}, newest first — what has left, and to whom", state.disclosures.len(), if state.disclosures.len() == 1 { "" @@ -610,9 +617,8 @@ fn render_disclosures(state: &IdentityState, lines: &mut Vec>) { if state.disclosures.is_empty() { lines.push( Line::from( - " Nothing has been disclosed from this agent yet. A release happens when a \ - verifier asks and you approve it — this is the record of those, and it is \ - read-only.", + " Nothing has left yet. Something leaves when a site asks and you approve \ + it — this is the record of those, and it is read-only.", ) .fg(COLOR_DARK_GRAY), ); @@ -646,7 +652,7 @@ fn render_disclosures(state: &IdentityState, lines: &mut Vec>) { if let Some(id) = &row.durable_credential_id { lines.push( Line::from(format!( - " ● still live as a credential ({})", + " ● still live as a credential ({}) — can be revoked", truncate(id, 40) )) .fg(COLOR_ORANGE), @@ -734,9 +740,9 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { let mut lines = vec![Line::from("")]; lines.push( Line::from(if form.attribute_id.is_some() { - " Edit attribute" + " Edit a fact" } else { - " New attribute" + " Add a fact" }) .fg(COLOR_SUCCESS) .bold(), @@ -744,8 +750,8 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { lines.push(Line::from("")); lines.push( Line::from( - " A fact about you, held once. Profiles reference it, so correcting it here corrects \ - it everywhere it is presented.", + " A fact about you, held once. Faces select it, so correcting it here corrects \ + it everywhere it is worn.", ) .fg(COLOR_DARK_GRAY), ); @@ -804,9 +810,9 @@ fn render_profile_form(state: &IdentityState, form: &ProfileForm) -> Vec Vec Vec Vec Vec Vec { - let mut options = vec!["Nothing — present no identity here".to_string()]; + let mut options = vec!["Take it off — show nothing here".to_string()]; options.extend(state.profiles.iter().map(|p| { format!( "{} ({} entr{})", @@ -1017,32 +1024,170 @@ mod tests { state.loading = false; } + /// The words `design-docs/persona-vocabulary.md` keeps off the screen, held + /// off it. + /// + /// Every one of them is a word this pane's own code and wire format use, so + /// they are one careless `format!` away at all times — and the drift is + /// invisible in review, because each looks correct to the person who wrote + /// the line. The table's whole point is that a person meets the same + /// sentence in the console, in `pnm` and here; a test is the only thing that + /// notices when one surface wanders off. + /// + /// Two are absent from the list on purpose. **"credential"** is on-screen + /// vocabulary (`credential · ‹issuer›`), and **"per verifier"** is the + /// agreed phrase for a generated value — the table bans *"verifier" in + /// prose*, not that phrase. + #[test] + fn the_pane_speaks_the_agreed_vocabulary() { + const BANNED: &[&str] = &[ + "attribute", + "pool", + "profile", + "binding", + "unbind", + "materialise", + "projection", + "disclosure", + "correlation", + "self-asserted", + "credential-backed", + "provenance", + "holder", + ]; + + // Every tab, in both its empty and its populated state, plus the three + // things that own the screen when they are open. + let mut screens: Vec = Vec::new(); + for tab in PersonaTab::all() { + let mut empty = IdentityState { + tab, + ..IdentityState::default() + }; + loaded(&mut empty); + screens.push(text(&render(&empty))); + + let mut full = populated(tab); + loaded(&mut full); + screens.push(text(&render(&full))); + } + for mode in [ + PersonaMode::Attribute(AttributeForm::default()), + PersonaMode::Profile(ProfileForm::default()), + PersonaMode::Bind(BindPicker::default()), + ] { + let mut state = populated(PersonaTab::Personas); + state.mode = mode; + loaded(&mut state); + screens.push(text(&render(&state))); + } + + for screen in &screens { + let lower = screen.to_ascii_lowercase(); + for word in BANNED { + assert!( + !lower.contains(word), + "\"{word}\" is a word the vocabulary keeps off the screen \ + (design-docs/persona-vocabulary.md):\n{screen}" + ); + } + } + } + + /// One populated screen per tab, so the guard above reads real rows rather + /// than five empty states. + fn populated(tab: PersonaTab) -> IdentityState { + use openvtc_core::persona::disclosure::{DisclosedClaim, DisclosureRow}; + use openvtc_core::persona::pool::ProvenanceKind; + use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; + + let mut attr = PoolAttribute { + attribute_id: "01A".into(), + claim_type: "email.work".into(), + label: Some("Work email".into()), + ..PoolAttribute::default() + }; + attr.provenance = ProvenanceKind::CredentialBacked; + attr.stale = true; + attr.stale_reason = Some("revoked".into()); + + IdentityState { + tab, + show_values: true, + personas: vec![ManagedDid { + did: "did:webvh:example.com:alice".into(), + label: "Work me".into(), + ..ManagedDid::default() + }] + .into(), + attributes: vec![attr].into(), + profiles: vec![ProfileSummary { + profile_id: "01P".into(), + name: "Work".into(), + entry_count: 3, + ..ProfileSummary::default() + }] + .into(), + memberships: vec![PersonaMembership { + community_name: "Acme".into(), + persona_label: "Work me".into(), + status_label: "Member".into(), + shared_with: 1, + ..PersonaMembership::default() + }] + .into(), + disclosures: vec![DisclosureRow { + verifier_did: "did:webvh:example.com:acme".into(), + disclosed_at: "2026-09-07T10:00:00Z".into(), + claims: vec![DisclosedClaim { + claim_type: "email.work".into(), + rung: "selectiveDisclosure".into(), + }], + durable_credential_id: Some("urn:cred:1".into()), + ..DisclosureRow::default() + }] + .into(), + open_profile: (tab == PersonaTab::Profiles).then(|| ProfileDetail { + summary: ProfileSummary { + profile_id: "01P".into(), + name: "Work".into(), + ..ProfileSummary::default() + }, + resolved: vec![ResolvedClaim { + claim_type: "nickname".into(), + value: Some(serde_json::json!("Ace")), + attribute_id: None, + ..ResolvedClaim::default() + }], + ..ProfileDetail::default() + }), + ..IdentityState::default() + } + } + /// The distinction the whole pane is built around: an agent that could not - /// be asked must never render as an empty pool. One of those sentences is a + /// be asked must never render as "you have no facts". One of those is a /// confident claim about the holder's own data, and it would be wrong. #[test] - fn an_unreachable_agent_never_reads_as_an_empty_pool() { + fn an_unreachable_agent_never_reads_as_having_no_facts() { let mut state = IdentityState { tab: PersonaTab::Attributes, ..IdentityState::default() }; loaded(&mut state); let empty = text(&render(&state)); - assert!(empty.contains("Nothing in the pool yet"), "{empty}"); + assert!(empty.contains("No facts yet"), "{empty}"); state.load_error = Some("connection refused".to_string()); let failed = text(&render(&state)); - assert!( - failed.contains("Could not read your attributes"), - "{failed}" - ); + assert!(failed.contains("Could not read your facts"), "{failed}"); assert!( failed.contains("connection refused"), "the reason has to reach the operator: {failed}" ); assert!( - !failed.contains("Nothing in the pool yet"), - "a failed read must not claim the pool is empty: {failed}" + !failed.contains("No facts yet"), + "a failed read must not claim there are no facts: {failed}" ); } @@ -1143,15 +1288,15 @@ mod tests { loaded(&mut state); let out = text(&render(&state)); - assert!(out.contains("also shown to 1 other community"), "{out}"); + assert!(out.contains("known to 1 other community"), "{out}"); assert_eq!( - out.matches("also shown to").count(), + out.matches("⚠ linked").count(), 1, "the persona used once must not be flagged: {out}" ); } - /// "We have not asked" and "presents nothing" are different sentences on a + /// "We have not asked" and "wears nothing" are different sentences on a /// membership row, for the same reason they are different in /// `BindingSummary`. #[test] @@ -1170,13 +1315,13 @@ mod tests { ..IdentityState::default() }; loaded(&mut state); - assert!(text(&render(&state)).contains("presents: unknown")); + assert!(text(&render(&state)).contains("wears: unknown")); state.bindings.insert( ("ctx".to_string(), membership.persona_did.clone()), BindingSummary::default(), ); - assert!(text(&render(&state)).contains("presents: nothing")); + assert!(text(&render(&state)).contains("wears: nothing")); } /// The two delete questions are worded differently, and the cascading one @@ -1202,11 +1347,11 @@ mod tests { cascade: false, }; let plain = text(&render(&state)); + assert!(plain.contains("Forget \"Work email\"?"), "{plain}"); assert!( - plain.contains("Delete \"Work email\" from your pool?"), - "{plain}" + !plain.contains("faces"), + "the plain question must not mention what it does not do: {plain}" ); - assert!(!plain.contains("profiles"), "{plain}"); state.confirm = PersonaConfirm::DeleteAttribute { attribute_id: "01A".into(), @@ -1214,10 +1359,7 @@ mod tests { cascade: true, }; let escalated = text(&render(&state)); - assert!( - escalated.contains("used by one or more profiles"), - "{escalated}" - ); + assert!(escalated.contains("is on one or more faces"), "{escalated}"); } /// An armed confirmation replaces the key hints: a destructive question and @@ -1313,7 +1455,7 @@ mod tests { let state = IdentityState::default(); let options = bind_options(&state); assert_eq!(options.len(), 1); - assert!(options[0].starts_with("Nothing")); + assert!(options[0].starts_with("Take it off")); } /// The disclosure history is read-only and says so, and an empty one says @@ -1328,7 +1470,7 @@ mod tests { loaded(&mut state); let out = text(&render(&state)); - assert!(out.contains("Nothing has been disclosed"), "{out}"); + assert!(out.contains("Nothing has left yet"), "{out}"); assert!(out.contains("read-only"), "{out}"); // No verbs beyond navigation and refresh: this pane cannot disclose. assert!(!out.contains("n: new"), "{out}"); @@ -1340,6 +1482,7 @@ mod tests { #[test] fn a_disclosure_row_shows_its_rungs_and_flags_a_live_credential() { use openvtc_core::persona::disclosure::{DisclosedClaim, DisclosureRow}; + use openvtc_core::persona::pool::ProvenanceKind; let mut state = IdentityState { tab: PersonaTab::Disclosures, disclosures: vec![ @@ -1372,7 +1515,7 @@ mod tests { let out = text(&render(&state)); assert!(out.contains("email.work (whole)"), "{out}"); - assert!(out.contains("age.over18 (predicate)"), "{out}"); + assert!(out.contains("age.over18 (yes/no only)"), "{out}"); assert_eq!( out.matches("still live as a credential").count(), 1, @@ -1380,8 +1523,8 @@ mod tests { ); } - /// A resolved claim with no pool attribute behind it is marked, because - /// correcting the pool will not correct it. + /// A value that lives only in a face is marked as such, because correcting + /// the holder's facts will not correct it. #[test] fn an_inline_claim_says_it_lives_only_in_the_profile() { use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; @@ -1415,7 +1558,7 @@ mod tests { let out = text(&render(&state)); assert_eq!( - out.matches("only in this profile").count(), + out.matches("only in this face").count(), 1, "exactly the inline claim is marked: {out}" ); From 6588c1dae0aacc4968374670e5584e31d707d3c3 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 13:57:56 +0200 Subject: [PATCH 3/4] fix(persona): drop a duplicate import the vocabulary sweep left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scripted replacement matched the same `use` line in two tests and added the import to both. Clippy runs with `-D warnings` in CI, so an unused import there is a failed build. Ran `cargo clippy --workspace --all-targets -- -D warnings` this time rather than plain clippy — the plain form said nothing, which is how it reached CI. Signed-off-by: Glenn Gore --- openvtc/src/ui/pages/main/components/identity_panel.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index a5553c6..e2ef97f 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -1476,13 +1476,12 @@ mod tests { assert!(!out.contains("n: new"), "{out}"); } - /// Every claim carries the rung it went out at, and a release that is still + /// Every fact carries the rung it left at, and a release that is still /// live as a credential is marked as such — it is the one kind that can /// still be revoked rather than only regretted. #[test] fn a_disclosure_row_shows_its_rungs_and_flags_a_live_credential() { use openvtc_core::persona::disclosure::{DisclosedClaim, DisclosureRow}; - use openvtc_core::persona::pool::ProvenanceKind; let mut state = IdentityState { tab: PersonaTab::Disclosures, disclosures: vec![ From c25cd345479690d8b4de25eb5aa913c23e6ec59b Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 14:19:03 +0200 Subject: [PATCH 4/4] fix(persona): answer the refusal we recognise in our own words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto #279 put the grant hint inside the pane the vocabulary work had just rewritten, and the widened guard immediately found three words on screen that the table keeps off it — "pool" and "profiles" in the hint's own prose, and "holder" in the agent's refusal, which the pane was echoing verbatim. The hint's prose is ours, so it now says facts and faces. The agent's sentence is not ours, and echoing it was the wrong call rather than a wording slip. It is accurate and abstract — "the holder's attribute pool, which sits above every trust context" — and it lands on someone who has just been refused, which is the worst moment to meet three unfamiliar nouns. This is the one failure the pane recognises exactly, so it now answers it in the agreed words and gives the command. Every other failure is still echoed verbatim: that text is *data*, naming a host or a contract mismatch, and translating what we do not recognise would be inventing a cause (R6.4). The guard grew the state that would have caught this: every tab now renders refused as well as empty and populated. The words written for a failure are the ones written under pressure, and nothing else on screen exercised them. `persona-holder` inside the command is exempted — a capability name an operator types verbatim, like a task URI. The exemption is the exact token, so a sentence cannot hide behind the hyphen. Signed-off-by: Glenn Gore --- .../pages/main/components/identity_panel.rs | 69 ++++++++++++++----- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index e2ef97f..7ebaa26 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -674,17 +674,23 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: .fg(COLOR_WARNING_ACCESSIBLE_RED), ); lines.push(Line::from("")); - super::status::push_status(lines, error, " "); - lines.push(Line::from("")); - // The one failure with a specific answer, so it gets one. Everything - // above this line is the agent's own words; this is what to do about - // them. + // The one failure we recognise is said in our own words, and they are + // the agreed ones. The agent's sentence is accurate and abstract — + // "the holder's attribute pool, which sits above every trust context" — + // and echoing it would put three words on screen that the vocabulary + // keeps off it, in the place a person is least able to absorb them. + // + // Every other failure is echoed verbatim. That text is *data*: it may + // name a host, a port, a contract mismatch, and translating what we do + // not recognise would be inventing a cause (VTI R6.4). if needs_holder_grant(error) { for line in HOLDER_GRANT_HINT { lines.push(Line::from(*line).fg(COLOR_ORANGE)); } - lines.push(Line::from("")); + } else { + super::status::push_status(lines, error, " "); } + lines.push(Line::from("")); lines.push(Line::from(" r: try again").fg(COLOR_DARK_GRAY)); return true; } @@ -709,8 +715,8 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: /// Kept as lines rather than a paragraph because the middle one is a command an /// operator has to read character by character. const HOLDER_GRANT_HINT: &[&str] = &[ - " Your agent credential administers this context. The pool and the profiles", - " over it sit above every context, so reaching them is a separate grant:", + " Your agent credential administers this context. Your facts, and the faces", + " over them, sit above every context — reaching them is a separate grant:", "", " pnm acl update --did --capabilities persona-holder", "", @@ -1056,8 +1062,14 @@ mod tests { "holder", ]; - // Every tab, in both its empty and its populated state, plus the three - // things that own the screen when they are open. + // Every tab, in each of its three states — empty, populated, and having + // failed to load — plus the three things that own the screen when they + // are open. + // + // The failure state earns its place: the words there are the ones + // written under pressure, they include a hint carrying a `pnm` command, + // and nothing else on screen exercises them. This test did miss a + // "pool" and a "profiles" that lived only in that hint. let mut screens: Vec = Vec::new(); for tab in PersonaTab::all() { let mut empty = IdentityState { @@ -1070,6 +1082,18 @@ mod tests { let mut full = populated(tab); loaded(&mut full); screens.push(text(&render(&full))); + + // The refusal we recognise, which this pane answers in its own + // words. An *unrecognised* failure is echoed verbatim and is the + // agent's text rather than ours, so it is not this test's to police. + let mut refused = populated(tab); + loaded(&mut refused); + refused.load_error = Some( + "this task reads or writes the holder's attribute pool, which sits above \ + every trust context. It requires an unscoped holder credential" + .to_string(), + ); + screens.push(text(&render(&refused))); } for mode in [ PersonaMode::Attribute(AttributeForm::default()), @@ -1083,7 +1107,15 @@ mod tests { } for screen in &screens { - let lower = screen.to_ascii_lowercase(); + // `persona-holder` is a capability name an operator types verbatim, + // like a task URI would be — wire vocabulary inside a command, not + // prose. Removing it before the scan keeps "holder" banned as a + // word while letting the one command that needs it stay correct; + // exempting it any more loosely would let a sentence hide behind a + // hyphen. + let lower = screen + .to_ascii_lowercase() + .replace("persona-holder", "‹capability›"); for word in BANNED { assert!( !lower.contains(word), @@ -1193,19 +1225,20 @@ mod tests { /// The refusal a context-scoped credential earns says what to do about it. /// - /// This is the failure every install hits before the grant exists, so the - /// agent's own words — accurate but abstract — are not enough on their own: - /// the operator needs the command. + /// This is the failure every install hits before the grant exists, and the + /// agent's own words — accurate, abstract, and in the spec's vocabulary — + /// are not what a person needs at that moment. The pane answers it in its + /// own words and gives them the command. #[test] fn the_holder_refusal_carries_the_grant_command() { - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, load_error: Some( "this task reads or writes the holder's attribute pool … it requires an unscoped \ holder credential" .to_string(), ), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1218,10 +1251,10 @@ mod tests { /// their agent is simply unreachable sends them to fix the wrong thing. #[test] fn an_unrelated_failure_carries_no_grant_command() { - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, load_error: Some("connection refused".to_string()), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state);