From d735751cd8e9ffa5d886d207d753aeef531e3c4c Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 9 Sep 2026 09:25:47 +0200 Subject: [PATCH] fix(manager): the guided setup dropped the holder's own visibility decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the live console with a screenshot: an attribute set to *show it* still rendered as ●●●● in "N attributes so far", with a Show button beside it. **One missing prop, at three call sites.** Every `AttributeValue` in `persona-setup.tsx` passed `type` and `value` and never `sensitivity`, so `treatmentFor` fell through to the registry — and for an unregistered token like `profile.github` the registry's answer is the conservative floor, `high`/`full`. The holder's answer existed, was stored, was returned by the agent, and was dropped on the way to the component. That is the exact defect `treatmentFor`'s own docstring records having fixed once already, arriving by a different route: there the decision lost to a fail-closed branch, here it never reached the function. `AttributeValue` says a surface holding a whole attribute should pass `sensitivity`; the guide held whole attributes and passed none of them. **`onReveal` is threaded in with it.** Without it a *Show* on a genuinely withheld value has nothing to call, and a button that cannot do what it says is the same defect one step along. The map and the list already had it; the guide never did. Three tests, and the two that are not the report matter as much: - the reported case — a holder's *show it* on an unregistered token is legible, asserted on the value being present rather than the mask being absent, since "no bullets" also passes if the value vanishes; - **absent is not a decision** — an unregistered token the holder did not decide on stays masked, so the fix cannot become "never mask"; - **§3.3 stays intact** — a declared token keeps the registry's mask however the holder marks it, so the narrow unregistered exception does not widen. Verified non-vacuous: removing the one prop again fails the first test with "the holder said show it and the guide hid it anyway". extension 485/485. Signed-off-by: Glenn Gore --- .../src/manager/panes/persona-setup.tsx | 40 +++++- .../extension/src/manager/panes/persona.tsx | 1 + .../tests/persona-pane.render.test.mts | 136 ++++++++++++++++++ 3 files changed, 173 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/manager/panes/persona-setup.tsx b/packages/extension/src/manager/panes/persona-setup.tsx index e3d3a7b..70338da 100644 --- a/packages/extension/src/manager/panes/persona-setup.tsx +++ b/packages/extension/src/manager/panes/persona-setup.tsx @@ -19,6 +19,7 @@ import { Button, Note, Panel } from "../../ui.js"; import { c, t, font } from "../../theme.js"; import { contextHeading } from "../format.js"; import type { Authority, Parties } from "../use-vta.js"; +import type { RevealTarget } from "../reveal-value.js"; import { AttributeEditor, BindingForm, AttributeValue, ProfileEditor } from "./persona-editors.js"; import { StarterForm } from "./persona-starter.js"; import { holderGate } from "../holder-gate.js"; @@ -88,10 +89,12 @@ function StrangerCard({ attributes, faceName, registry, + onReveal, }: { attributes: PoolAttribute[]; faceName: string; registry: ClaimTypeRegistry | null; + onReveal: (target: RevealTarget) => Promise; }) { const name = attributes.find((f) => f.type === "name" || f.type.startsWith("name.")); const rest = attributes.filter((f) => f !== name); @@ -109,7 +112,14 @@ function StrangerCard({ <>
{name ? ( - + onReveal({ attributeId: name.attributeId, type: name.type })} + style={{ fontSize: t.md, fontWeight: 640 }} + /> ) : ( )} @@ -120,7 +130,13 @@ function StrangerCard({ {rest.map((f) => ( {f.label ?? f.type} - + onReveal({ attributeId: f.attributeId, type: f.type })} + /> ))}
@@ -153,6 +169,7 @@ export function GuidedSetup({ onFinished, onSkip, registry, + onReveal, }: { parties: Parties; authority: Authority | null; @@ -164,6 +181,15 @@ export function GuidedSetup({ onFinished: (outcome: string) => void; onSkip: () => void; registry: ClaimTypeRegistry | null; + /** + * Ask the agent for one value it kept back. + * + * Threaded in with the fix that made this screen honour the holder's own + * `sensitivity`: without it a *Show* on a genuinely withheld value has + * nothing to call, and a button that cannot do what it says is the same + * defect one step along. + */ + onReveal: (target: RevealTarget) => Promise; }) { const [step, setStep] = useState(attributes.length === 0 ? 1 : 2); const [contextId, setContextId] = useState(records[0]?.id ?? ""); @@ -225,7 +251,13 @@ export function GuidedSetup({ {attributes.map((a) => (
{a.type} - + onReveal({ attributeId: a.attributeId, type: a.type })} + />
))} @@ -268,7 +300,7 @@ export function GuidedSetup({ onCancel={() => setStep(1)} cancelLabel="Back — add more attributes" /> - + )} diff --git a/packages/extension/src/manager/panes/persona.tsx b/packages/extension/src/manager/panes/persona.tsx index c0d2577..0862299 100644 --- a/packages/extension/src/manager/panes/persona.tsx +++ b/packages/extension/src/manager/panes/persona.tsx @@ -306,6 +306,7 @@ export function PersonaPane({ if (showGuide) { return ( { onChanged: () => {}, onFinished: () => {}, onSkip: () => {}, + onReveal: async () => ({}), }), { chrome: { runtime: { sendMessage: a.sendMessage } } }, ); @@ -145,6 +146,7 @@ test("the stranger card starts empty and says so", async () => { onChanged: () => {}, onFinished: () => {}, onSkip: () => {}, + onReveal: async () => ({}), }), { chrome: { runtime: { sendMessage: a.sendMessage } } }, ); @@ -170,6 +172,7 @@ test("a completed step in the stepper is a way back to it", async () => { onChanged: () => {}, onFinished: () => {}, onSkip: () => {}, + onReveal: async () => ({}), }), { chrome: { runtime: { sendMessage: a.sendMessage } } }, ); @@ -1222,3 +1225,136 @@ test("the attribute using an unapplied type is marked on its own card", async () assert.match(ui.text(), /type not applied/); await ui.unmount(); }); + +/** + * Step back to "Add an attribute or two". + * + * The guide opens on step two the moment the holder has any attribute, and the + * panel this section is about — "N attributes so far" — is on step one, which + * is where the report's screenshot was taken. The stepper's completed circles + * are the way back. + */ +async function backToStepOne(ui: Awaited>) { + const back = ui.all('[role="button"]')[0]; + if (back) await ui.click(back); +} + +// ── The guided setup honours the holder's own decision ────────────────────── + +test("a value the holder marked SHOW IT is not drawn as bullets in the guide", async () => { + // Reported from the live console with a screenshot: `profile.github` set to + // *show it* still rendered as ●●●● in "N attributes so far". + // + // The cause was one missing prop. All three `AttributeValue` call sites in + // the guide passed `type` and `value` and never `sensitivity`, so + // `treatmentFor` fell through to the registry — and for an UNREGISTERED token + // the registry's answer is the conservative floor, `high`/`full`. The holder's + // answer existed, was stored, was returned by the agent, and was dropped on + // the way to the component. + // + // Asserted on the token the report named, and on the state the report + // described: the value legible, not the mask absent — a test for "no bullets" + // would also pass if the value vanished entirely. + const shown = { + attributeId: "a9", + type: "profile.github", + valueType: "string" as const, + value: "stormer78", + label: "github", + provenance: { kind: "selfAsserted" as const }, + sensitivity: "normal" as const, + version: 1, + updatedAt: "2026-09-09T00:00:00Z", + }; + const a = agent({}); + const ui = await render( + h(GuidedSetup, { + registry: REGISTRY, + parties: PARTIES, + authority: HOLDER, + records: CONTEXTS, + attributes: [shown], + profiles: [], + onChanged: () => {}, + onFinished: () => {}, + onSkip: () => {}, + onReveal: async () => ({}), + }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await backToStepOne(ui); + + assert.match(ui.text(), /stormer78/, "the holder said show it and the guide hid it anyway"); + assert.doesNotMatch(ui.text(), /●●●●|••••/, "a value marked show it was still masked"); + await ui.unmount(); +}); + +test("an unregistered value the holder did NOT decide on stays masked in the guide", async () => { + // The other direction, and the reason the first test is not just "never + // mask": absent is not a decision, so the registry's conservative floor is + // the right answer and must survive the fix. + const undecided = { + attributeId: "a8", + type: "profile.github", + valueType: "string" as const, + value: "stormer78", + provenance: { kind: "selfAsserted" as const }, + version: 1, + updatedAt: "2026-09-09T00:00:00Z", + }; + const a = agent({}); + const ui = await render( + h(GuidedSetup, { + registry: REGISTRY, + parties: PARTIES, + authority: HOLDER, + records: CONTEXTS, + attributes: [undecided], + profiles: [], + onChanged: () => {}, + onFinished: () => {}, + onSkip: () => {}, + onReveal: async () => ({}), + }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await backToStepOne(ui); + assert.doesNotMatch(ui.text(), /stormer78/, "an undecided unregistered value was shown in full"); + await ui.unmount(); +}); + +test("a DECLARED token keeps the registry's mask even when the holder says show it", async () => { + // §3.3: the axes are independent. The holder's `sensitivity` moves that axis + // only, and `email.work` stays `emailLocal` however they mark it. The + // unregistered exception is narrow and must not widen into this. + const declared = { + attributeId: "a7", + type: "email.work", + valueType: "string" as const, + value: "glenn@acme.example", + provenance: { kind: "selfAsserted" as const }, + sensitivity: "normal" as const, + version: 1, + updatedAt: "2026-09-09T00:00:00Z", + }; + const a = agent({}); + const ui = await render( + h(GuidedSetup, { + registry: REGISTRY, + parties: PARTIES, + authority: HOLDER, + records: CONTEXTS, + attributes: [declared], + profiles: [], + onChanged: () => {}, + onFinished: () => {}, + onSkip: () => {}, + onReveal: async () => ({}), + }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await backToStepOne(ui); + assert.doesNotMatch(ui.text(), /glenn@acme\.example/, "a declared token lost its registry mask"); + assert.match(ui.text(), /@acme\.example/, "the emailLocal mask should still show the domain"); + await ui.unmount(); +});