fix(persona): say when a face is worn one context up, not just "nothing" - #289
Merged
Conversation
Opening a face showed its claims masked with no way to read one, and told the holder to go and find the value among their attributes instead. That is a real answer to a question nobody asked: someone is looking at *this* face precisely because they want to know what *this* face shows, and sending them to another tab to reconstruct it by eye is the pane declining to answer. The old refusal was reasoned, and the reasoning is what changed. It was not "a face must not be revealed" — it was "a face has no cursor over its claims, so the only reveal this view could offer is the blanket one the mask exists to avoid". That is a statement about the pane, not about the data. Give the detail view a cursor and the objection is gone, because "the selected claim" becomes a thing a holder can name. So the face detail now works exactly like the attributes tab, on the same terms: - `↑`/`↓` move a cursor over the claims. - `s` lifts the mask on the selected one, and puts it back on a second press. - The grant is one claim, and only while it is also the selected row — the render checks the pairing as well as the handler clearing it, so a grant that outlived its row cannot open a claim nobody chose. - Moving the cursor, closing the face, or leaving the tab each ends it. Opening a face starts at the top with nothing revealed, rather than inheriting an index that now names a different claim in a different face. A masked row also says it is masked, for the reason the attributes tab already does: `••••••••` and "(no value)" are the same shape on a row, and a holder who reads the first as the second concludes the face shows nothing. The "s to show" tail follows the cursor rather than sitting on every masked row, so the key is offered where it would work. There is deliberately no `v` here. On the attributes tab `v` is a *network* question — a listing fetched without values does not hold them — but a face detail is resolved in full when it is opened, so every value is already in hand and the mask is the only thing between it and the screen. A `v` would be a display flag pretending to be an escalation. `ResolvedClaim::revealed_value` is the new counterpart to `display_value`, and stays a separate method rather than a `reveal: bool` for the reason its pool sibling gives: reading a masked value in the clear should be something a call site had to name. Its doc records why the counterpart used to be absent and what changed, so the next reader meets the argument rather than a gap. Tests: the reveal opens only the selected claim; a grant that no longer names the selected row opens nothing; the key toggles; a reveal past the end or with no face open does nothing; every exit puts the mask back; opening a face starts closed and at the top; and at the core level a masked claim reads back whole only when asked for, while a stale one still says it is stale under a reveal. The test that encoded the old decision is updated rather than deleted, and says what changed. Gate: fmt, clippy -D warnings, rustdoc -D warnings, cargo test --workspace, and --no-default-features. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
A membership whose persona wears a face configured elsewhere read
`wears: nothing`, with nothing on screen to explain why another surface
showed that same persona wearing "OSS Developer". The answer was true and
useless, which on the one pane whose job is to tell a holder what they
present is the failure mode to avoid.
## Why it happens
The VTA keys a binding on an **exact** `(context_id, persona_did)` pair and
walks no hierarchy — `binding_summary` goes through `binding_record` ->
`storage::binding_key`, and so do the `materialised_claims` a disclosure
draws on. OpenVTC addresses the membership's **sub-context**
(`openvtc/<vtc-slug>`) for both its read and its write, which is where
community-scoped disclosure happens. A face bound against the parent context
is a different row, and is genuinely not what the community sees.
So "nothing" was the right answer to the question asked. What was missing is
that the holder had no way to find out the question had two plausible
readings and they were looking at the other one.
## What it says now
A fourth reading, distinct from the three that were already kept apart:
wears: nothing here — OSS Developer is worn in openvtc,
which this community does not see
Both halves are load-bearing. Naming the face is what connects this row to
what the holder saw elsewhere; "does not see" is what stops the row being
read as reassurance. The claim is safe to make in those words because both
VTA read paths are exact lookups — this is not an inference about what the
agent might roll up.
The parent is derived with `context_path::parse_sub_context_id`, never by
hand: a top context may itself be nested, so the split is on the *last* `/`,
and an id with no `/` is not a sub-context and has no parent to ask about.
Same discipline as the didwebvh-rs and agent-names rules.
## Cost
One extra round-trip, and only when a sub-context comes back **unbound** —
the bound case, which is the common one, costs nothing. Best-effort like
every other binding read: a failure looking up the parent leaves a perfectly
good "wears: nothing" alone rather than turning it into an error.
Ordering is preserved where it matters. `unknown` still outranks everything,
because reporting what a parent wears while the context actually in question
went unanswered would be a confident statement built on a failed read; and a
bound context reports what *it* wears, never the parent. Both are tested.
Does not decide which context a membership's face *ought* to be bound in —
that is a VTA/product question. It makes the current answer legible either
way.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review🔎 A manual security review is recommended before merging. Please contact the Security team for specifics and remediation guidance.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A membership whose persona wears a face configured elsewhere read
wears: nothing, with nothing on screen to explain why another surface showed that same persona wearing "OSS Developer". The answer was true and useless — which, on the one pane whose job is to tell a holder what they present, is the failure mode to avoid.Why it happens
The VTA keys a binding on an exact
(context_id, persona_did)pair and walks no hierarchy:vta-persona/src/binding.rs→binding_summary→binding_record→storage::binding_keymaterialised_claimsa disclosure draws on go through that same exact lookupOpenVTC addresses the membership's sub-context (
openvtc/<vtc-slug>) for both its read and its write — which is where community-scoped disclosure happens. A face bound against the parent context (openvtc) is a different row, and is genuinely not what the community sees.So "nothing" was the right answer to the question asked. What was missing is that the holder had no way to find out the question had two plausible readings and they were looking at the other one.
(Ruled out along the way: context-local bindings are not a second address.
set_local_bindingwrites into the ordinarypb:keyspace —local_binding_keyis dead code — precisely because a split address space had made local bindings reportbound: falseand disclose nothing.)What it says now
A fourth reading, distinct from the three already kept apart:
Both halves are load-bearing. Naming the face is what connects this row to what the holder saw elsewhere; "does not see" is what stops the row being read as reassurance. The claim is safe to make in those words because both VTA read paths are exact lookups — this is not an inference about what the agent might roll up.
The parent is derived with
context_path::parse_sub_context_id, never by hand: a top context may itself be nested, so the split is on the last/, and an id with no/is not a sub-context and has no parent to ask about. Same discipline as thedidwebvh-rsandagent-namesrules.Cost
One extra round-trip, and only when a sub-context comes back unbound. The bound case — the common one — costs nothing. Best-effort like every other binding read: a failure looking up the parent leaves a perfectly good
wears: nothingalone rather than turning it into an error.Ordering is preserved where it matters, and both are tested:
unknownstill outranks everything. Reporting what a parent wears while the context actually in question went unanswered would be a confident statement built on a failed read.What this deliberately does not do
It does not decide which context a membership's face ought to be bound in. That is a VTA/product question — the sub-context is where community-scoped disclosure happens, which argues OpenVTC asks in the right place, but that is an argument rather than a decision. This PR makes the current answer legible either way; if the canonical answer turns out to be the parent, the read address changes and this hint becomes dead code rather than becoming wrong.
Gate
cargo fmt --all;cargo clippy --workspace --all-targets -- -D warnings;RUSTDOCFLAGS="-D warnings" cargo doc;cargo test --workspace.