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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,9 +44,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).

Expand All @@ -47,7 +66,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
Expand All @@ -56,7 +75,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.
Expand Down
49 changes: 25 additions & 24 deletions openvtc-core/src/persona/binding.rs
Original file line number Diff line number Diff line change
@@ -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 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
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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})")
}
}

Expand Down Expand Up @@ -167,13 +171,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
Expand All @@ -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.
Expand All @@ -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 —
Expand All @@ -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)");
}
}
49 changes: 39 additions & 10 deletions openvtc-core/src/persona/disclosure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DisclosedClaim>,
/// What the verifier said it was for, if they said.
Expand Down Expand Up @@ -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::<Vec<_>>()
.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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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");
}
}
8 changes: 4 additions & 4 deletions openvtc-core/src/persona/mod.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
//!
Expand Down
Loading
Loading