Skip to content

fix(persona): say when a face is worn one context up, not just "nothing" - #289

Merged
stormer78 merged 2 commits into
mainfrom
fix/wears-nothing-parent-hint
Sep 8, 2026
Merged

fix(persona): say when a face is worn one context up, not just "nothing"#289
stormer78 merged 2 commits into
mainfrom
fix/wears-nothing-parent-hint

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

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.rsbinding_summarybinding_recordstorage::binding_key
  • the materialised_claims a disclosure draws on go through that same exact lookup

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 (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_binding writes into the ordinary pb: keyspace — local_binding_key is dead code — precisely because a split address space had made local bindings report bound: false and disclose nothing.)

What it says now

A fourth reading, distinct from the three 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 — 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, and both are tested:

  • unknown still 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.
  • a bound context reports what it wears, never the parent.

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.

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>
@stormer78
stormer78 requested a review from a team as a code owner September 8, 2026 13:44
@stormer78
stormer78 merged commit 955fcca into main Sep 8, 2026
14 checks passed
@stormer78
stormer78 deleted the fix/wears-nothing-parent-hint branch September 8, 2026 13:53
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

🔎 A manual security review is recommended before merging. Please contact the Security team for specifics and remediation guidance.

ℹ️ Detailed findings are not published on public repositories; the Security team holds the complete report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants