diff --git a/package-lock.json b/package-lock.json index d77963c..0515bd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "packages/reviewer-demo" ], "dependencies": { - "@openvtc/trust-tasks": "^0.19.1" + "@openvtc/trust-tasks": "^0.19.13" }, "engines": { "node": ">=24" @@ -2304,9 +2304,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "version": "0.19.5", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.19.5.tgz", - "integrity": "sha512-zNL532p+NSZ73MkoyZz9kegDRxQXRF7NzTpZBfSVP/PgS6DBUoi48UjzdEHqjVWhHHMtiKlaaYc2vKZzicSiQA==", + "version": "0.19.13", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.19.13.tgz", + "integrity": "sha512-D0xuRjxALdfTeOtuboOny6/E2XbAh+x2RIGhRPDElw2hU9mD14H+cx4ys0KVWdi1wJMC+57SB8M0PBR5Cptsrw==", "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { @@ -8034,7 +8034,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.19.5", + "@openvtc/trust-tasks": "^0.19.13", "@openvtc/vti-didcomm-js": "^0.10.0", "@openvtc/vti-tsp-js": "^0.3.0", "@scure/base": "^2.2.0", diff --git a/package.json b/package.json index 4adfb24..590cca8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,6 @@ "@swc/wasm": "~1.15.47" }, "dependencies": { - "@openvtc/trust-tasks": "^0.19.1" + "@openvtc/trust-tasks": "^0.19.13" } } diff --git a/packages/core/package.json b/packages/core/package.json index e2f5c37..4470552 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -130,7 +130,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.19.5", + "@openvtc/trust-tasks": "^0.19.13", "@openvtc/vti-didcomm-js": "^0.10.0", "@openvtc/vti-tsp-js": "^0.3.0", "@scure/base": "^2.2.0", diff --git a/packages/core/src/admin/persona.ts b/packages/core/src/admin/persona.ts index cc6c5bc..ef1e62d 100644 --- a/packages/core/src/admin/persona.ts +++ b/packages/core/src/admin/persona.ts @@ -602,6 +602,9 @@ export interface BindingSetParams extends PersonaHolderParams { profileId?: string | null; /** Entry ids this binding may reveal without a per-disclosure decision. */ publicEntries?: string[]; + /** What the context may call the face worn there. The context is never given + * the holder's own name for the face; omitted, it is given no name at all. */ + label?: string; expectedVersion?: number; } @@ -632,6 +635,7 @@ export async function personaBindingSet( personaDid: params.personaDid, ...(params.profileId !== undefined ? { profileId: params.profileId } : {}), ...(params.publicEntries !== undefined ? { publicEntries: params.publicEntries } : {}), + ...(params.label !== undefined ? { label: params.label } : {}), ...(params.expectedVersion !== undefined ? { expectedVersion: params.expectedVersion } : {}), }; return holderCall( diff --git a/packages/core/tests/admin.persona.mjs b/packages/core/tests/admin.persona.mjs index 5f0e395..f88525d 100644 --- a/packages/core/tests/admin.persona.mjs +++ b/packages/core/tests/admin.persona.mjs @@ -241,6 +241,31 @@ test("profileId null unbinds; omitting it leaves the binding alone", async () => assert.ok(!("profileId" in untouched.sent[0].envelope.payload)); }); +test("a binding label is sent when given and absent when not", async () => { + // The context is given this name and never the holder's own name for the + // face. Absent is a real choice — "give the context no name" — so an + // undefined label must not arrive as an empty string or a null. + const reply = { contextId: "demo", personaDid: "did:key:zP", version: 2, boundAt: "x" }; + const named = recorder(reply); + await personaBindingSet(named, { + ...PARTIES, + contextId: "demo", + personaDid: "did:key:zP", + profileId: "01P", + label: "Ada at the co-op", + }); + assert.equal(named.sent[0].envelope.payload.label, "Ada at the co-op"); + + const unnamed = recorder(reply); + await personaBindingSet(unnamed, { + ...PARTIES, + contextId: "demo", + personaDid: "did:key:zP", + profileId: "01P", + }); + assert.ok(!("label" in unnamed.sent[0].envelope.payload)); +}); + test("deleting a profile does not unbind unless asked", async () => { const bare = recorder({ profileId: "01P", existed: true }); await personaProfileDelete(bare, { ...PARTIES, profileId: "01P" }); diff --git a/packages/extension/src/manager/disclosure-currency.ts b/packages/extension/src/manager/disclosure-currency.ts new file mode 100644 index 0000000..7667b90 --- /dev/null +++ b/packages/extension/src/manager/disclosure-currency.ts @@ -0,0 +1,138 @@ +// Who still holds a value you have since changed. +// +// `persona/disclosure/history` reports, per disclosed claim, whether the value +// the verifier received is still what that persona presents in that context +// (`claimCurrency`, aligned with `claimTypes`). The agent answers it from a +// keyed hash taken at disclosure, compared with the persona's projection now — +// the history holds no value, so neither can this. +// +// ## The four answers are four, and two of them are easy to merge wrongly +// +// `changed` is the one that matters after a name change: the verifier holds an +// outdated copy, and the holder may want to send the new one. `removed` is not +// the same — the persona no longer presents that type at all, and there is +// nothing newer to send; the verifier simply keeps what it got, because a +// disclosure cannot be recalled. Folding `removed` into `changed` would offer +// to re-present something that no longer exists. +// +// `unknown` is a record made before the agent fingerprinted disclosures, or a +// predicate that disclosed no value. It is never `current`: reading it as +// current is the false all-clear the whole feature exists to avoid. +// +// An agent that predates the member omits it entirely. Every claim is then +// `unknown`, for the same reason. + +import type { DisclosureRecord } from "@openvtc/pnm-core/admin"; + +export type Currency = "current" | "changed" | "removed" | "unknown"; + +/** One claim of one disclosure, with its currency. */ +export interface DisclosedClaimCurrency { + claimType: string; + currency: Currency; +} + +/** Each claim of a record with its currency, `unknown` wherever the agent did not say. */ +export function currencyOf(record: DisclosureRecord): DisclosedClaimCurrency[] { + const said: readonly unknown[] = record.claimCurrency ?? []; + return record.claimTypes.map((claimType, i) => { + const c = said[i]; + const currency: Currency = + c === "current" || c === "changed" || c === "removed" ? c : "unknown"; + return { claimType, currency }; + }); +} + +/** One party holding a value the holder has since changed. */ +export interface Outdated { + verifierDid: string; + contextId: string; + personaDid: string; + /** The claim types that party holds an outdated copy of, in disclosure order. */ + claimTypes: string[]; + /** The most recent disclosure that left them holding it. */ + disclosedAt: string; +} + +/** + * The re-present list: every (verifier, context, persona) holding at least one + * value the persona has since changed, most recent first. + * + * Grouped by that triple because that is who would be sent the new value — the + * same verifier in two contexts, or through two personas, is two relationships. + * Only the **latest** disclosure to each triple counts: a verifier re-sent the + * current value after an older disclosure is not outdated, even though the + * older record still says `changed`. + */ +export function outdatedHolders(records: readonly DisclosureRecord[]): Outdated[] { + const latest = new Map(); + for (const r of records) { + const key = `${r.verifierDid}\u0000${r.contextId}\u0000${r.personaDid}`; + const seen = latest.get(key); + if (!seen || r.disclosedAt > seen.disclosedAt) latest.set(key, r); + } + const out: Outdated[] = []; + for (const r of latest.values()) { + const changed = currencyOf(r) + .filter((c) => c.currency === "changed") + .map((c) => c.claimType); + if (changed.length === 0) continue; + out.push({ + verifierDid: r.verifierDid, + contextId: r.contextId, + personaDid: r.personaDid, + claimTypes: [...new Set(changed)], + disclosedAt: r.disclosedAt, + }); + } + return out.sort((a, b) => b.disclosedAt.localeCompare(a.disclosedAt)); +} + +/** The words for one claim's currency — `null` for `current`, which needs none. */ +export function currencyWords(c: Currency): string | null { + switch (c) { + case "changed": + return "they hold an older value"; + case "removed": + return "you no longer show this here — they keep what they got"; + case "unknown": + return "your agent cannot say whether this is still current"; + default: + return null; + } +} + +// ## Where an edit landed +// +// `persona/attribute/put` now answers with `refreshed` (the bindings the edit +// re-pushed) and `heldByPin` (faces that pin the attribute and so did not +// follow). An edit propagating is the point of a pool and also its surprise, +// so a save that changed what nine counterparties see says so. An agent that +// predates the members omits both, and then nothing is claimed either way. + +interface EditReach { + refreshed?: readonly { profileId: string; contextId: string }[]; + heldByPin?: readonly { profileId: string }[]; +} + +/** The sentence for where an edit landed, or `null` when the agent said nothing. */ +export function editReachWords(res: EditReach): string | null { + const refreshed = res.refreshed ?? []; + const held = res.heldByPin ?? []; + if (refreshed.length === 0 && held.length === 0) return null; + const parts: string[] = []; + if (refreshed.length > 0) { + const contexts = new Set(refreshed.map((r) => r.contextId)).size; + parts.push( + `Now shown in ${refreshed.length} place${refreshed.length === 1 ? "" : "s"} ` + + `across ${contexts} context${contexts === 1 ? "" : "s"}.`, + ); + } + if (held.length > 0) { + parts.push( + `${held.length} face${held.length === 1 ? " pins" : "s pin"} the earlier value and ` + + `${held.length === 1 ? "keeps" : "keep"} showing it — that is what pinning is for.`, + ); + } + return parts.join(" "); +} diff --git a/packages/extension/src/manager/panes/persona-editors.tsx b/packages/extension/src/manager/panes/persona-editors.tsx index 9f69303..7be895b 100644 --- a/packages/extension/src/manager/panes/persona-editors.tsx +++ b/packages/extension/src/manager/panes/persona-editors.tsx @@ -47,6 +47,7 @@ import { maskedValue, treatmentFor, type Sensitivity } from "../claim-sensitivit import { revealAttributeValue } from "../reveal-value.js"; import { composeEntries, lockedRefs, preservedEntries, tickedFrom } from "../profile-entries.js"; import { personaCandidates } from "../persona-candidates.js"; +import { currencyOf, currencyWords, editReachWords, outdatedHolders } from "../disclosure-currency.js"; export const fieldStyle: React.CSSProperties = { boxSizing: "border-box", @@ -456,6 +457,8 @@ export function AttributeEditor({ const [error, setError] = useState(null); const [pending, setPending] = useState(null); const [correlation, setCorrelation] = useState(null); + /** Whether the note after a save warns of a link, or only reports where the edit landed. */ + const [linksWarned, setLinksWarned] = useState(false); const denied = holderGate(authority); @@ -560,10 +563,18 @@ export function AttributeEditor({ // refusing on correlation grounds would be the agent deciding who the // holder is allowed to be. Shown, never acted on. const shared = res.correlation?.sharedWithProfileCount ?? 0; - if (res.correlation?.severity === "high" || shared > 0) { - linked = - `Saved. ${shared} other attribute(s) hold this exact value — anyone who sees both ` + - `knows they are the same person, permanently.`; + // Where the edit landed. Kept on screen with the correlation warning + // rather than closing the editor, for the same reason: a change that + // reached nine counterparties is worth one more glance. + const reach = editReachWords(res); + const warning = + res.correlation?.severity === "high" || shared > 0 + ? `${shared} other attribute(s) hold this exact value — anyone who sees both ` + + `knows they are the same person, permanently.` + : null; + if (warning || reach) { + linked = ["Saved.", reach, warning].filter(Boolean).join(" "); + setLinksWarned(warning !== null); setCorrelation(linked); } }, @@ -720,7 +731,7 @@ export function AttributeEditor({ {error && {error}} {pending && } {correlation && ( - +
{correlation}
@@ -1364,6 +1375,18 @@ export function DisclosureHistoryPanel({ render: (d) => (
{d.claimTypes.join(", ")} + {/* Per claim, and only where there is something to say: a current + value needs no words, and a row of "current" is noise. */} + {currencyOf(d) + .filter((cl) => cl.currency !== "current") + .map((cl) => ( + + {cl.claimType}: {currencyWords(cl.currency)} + + ))} {d.rungs && d.rungs.length > 0 && ( as {d.rungs.join(", ")} )} @@ -1412,6 +1435,37 @@ export function DisclosureHistoryPanel({ {history.error && } {history.loading && !history.data && } + {history.data && (() => { + // Who holds a value you have since changed. Stated above the table + // because it is the question after a change, and the table answers + // it only row by row. A disclosure cannot be recalled; what can be + // done is to present the new value to them again, from that context. + const outdated = outdatedHolders(history.data.disclosures); + if (outdated.length === 0) return null; + return ( + +
+ + {outdated.length === 1 + ? "One party holds a value you have since changed." + : `${outdated.length} parties hold values you have since changed.`} + + {outdated.map((o) => ( + + + {o.verifierDid} + {" "} + in {o.contextId} — {o.claimTypes.join(", ")} + + ))} + + They keep what they were given. To bring them up to date, present the new value to + them again from that context. + +
+
+ ); + })()} {history.data && ( <> (profiles[0]?.profileId ?? ""); + /** What this context may call the face. Never the face's own name: that is + * the holder's, and wearing a face is not consent to share it. Empty gives + * the context no name at all. */ + const [nameForContext, setNameForContext] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [pending, setPending] = useState(null); @@ -1699,7 +1757,12 @@ export function BindingForm({ [parties.holder.did, parties.service.did, contextId, initialDid], ); useEffect(() => { - if (current.data) setProfileId(current.data.profileId ?? ""); + if (current.data) { + setProfileId(current.data.profileId ?? ""); + // `binding/set` replaces the binding, so the label it already has must + // be sent back or a face change silently clears it. + setNameForContext(current.data.label ?? ""); + } }, [current.data]); const isKnown = known?.personas.find((b) => b.personaDid === personaDid.trim()); @@ -1720,6 +1783,7 @@ export function BindingForm({ // An empty selection is an explicit take-off, not an omission: `null` // clears the binding, an absent member leaves it as it stands. profileId: profileId === "" ? null : profileId, + ...(profileId !== "" && nameForContext.trim() ? { label: nameForContext.trim() } : {}), }); const also = res.correlation?.alsoBoundPersonaCount ?? 0; outcome = @@ -1734,7 +1798,7 @@ export function BindingForm({ ); setBusy(false); if (ok) onDone(outcome); - }, [parties, contextId, contextLabel, personaDid, profileId, onDone]); + }, [parties, contextId, contextLabel, personaDid, profileId, nameForContext, onDone]); return ( + {profileId !== "" && ( + + )} + {error && {error}} {pending && } diff --git a/packages/extension/tests/manager-disclosure-currency.test.mts b/packages/extension/tests/manager-disclosure-currency.test.mts new file mode 100644 index 0000000..ac27f95 --- /dev/null +++ b/packages/extension/tests/manager-disclosure-currency.test.mts @@ -0,0 +1,80 @@ +// Who still holds a value you have since changed. +// +// The rules that carry the feature: `unknown` is never current; `removed` is +// not `changed`; and only the latest disclosure to a relationship decides +// whether that relationship is outdated. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { currencyOf, outdatedHolders, currencyWords } from "../src/manager/disclosure-currency.ts"; + +const record = (extra: Record) => + ({ + disclosureId: "01D", + contextId: "ctx", + verifierDid: "did:v", + personaDid: "did:p", + claimTypes: ["name.display"], + disclosedAt: "2026-09-01T00:00:00Z", + ...extra, + }) as never; + +test("an agent that says nothing leaves every claim unknown, never current", () => { + assert.deepEqual(currencyOf(record({ claimTypes: ["a", "b"] })), [ + { claimType: "a", currency: "unknown" }, + { claimType: "b", currency: "unknown" }, + ]); + // A value this build does not recognise is not promoted to current either. + assert.deepEqual(currencyOf(record({ claimCurrency: ["stale"] })), [ + { claimType: "name.display", currency: "unknown" }, + ]); +}); + +test("only changed claims make the re-present list — removed ones do not", () => { + const list = outdatedHolders([ + record({ claimTypes: ["name.display", "phone.mobile"], claimCurrency: ["changed", "removed"] }), + ]); + assert.equal(list.length, 1); + assert.deepEqual(list[0].claimTypes, ["name.display"], "a removed claim has nothing newer to send"); + assert.equal(outdatedHolders([record({ claimCurrency: ["removed"] })]).length, 0); + assert.equal(outdatedHolders([record({ claimCurrency: ["current"] })]).length, 0); +}); + +test("the latest disclosure to a relationship decides it", () => { + // Re-sent the new value after an older disclosure: not outdated, though the + // older record still reads `changed`. + const older = record({ disclosureId: "01A", disclosedAt: "2026-09-01T00:00:00Z", claimCurrency: ["changed"] }); + const newer = record({ disclosureId: "01B", disclosedAt: "2026-09-10T00:00:00Z", claimCurrency: ["current"] }); + assert.equal(outdatedHolders([older, newer]).length, 0); + assert.equal(outdatedHolders([newer, older]).length, 0, "order of the history must not matter"); + + // The same verifier in another context is another relationship. + const elsewhere = record({ contextId: "ctx-2", claimCurrency: ["changed"] }); + assert.deepEqual( + outdatedHolders([older, newer, elsewhere]).map((o) => o.contextId), + ["ctx-2"], + ); +}); + +test("current needs no words; the others each say what they mean", () => { + assert.equal(currencyWords("current"), null); + assert.match(currencyWords("changed")!, /older value/); + assert.match(currencyWords("removed")!, /keep what they got/); + assert.match(currencyWords("unknown")!, /cannot say/); +}); + +test("an edit says where it landed, and says nothing when the agent did not", async () => { + const { editReachWords } = await import("../src/manager/disclosure-currency.ts"); + assert.equal(editReachWords({}), null, "an older agent claims nothing either way"); + assert.equal( + editReachWords({ + refreshed: [ + { profileId: "01A", contextId: "ctx-1" }, + { profileId: "01A", contextId: "ctx-2" }, + { profileId: "01B", contextId: "ctx-2" }, + ], + heldByPin: [{ profileId: "01C" }], + }), + "Now shown in 3 places across 2 contexts. 1 face pins the earlier value and keeps showing it — that is what pinning is for.", + ); +}); diff --git a/packages/extension/tests/persona-pane.render.test.mts b/packages/extension/tests/persona-pane.render.test.mts index 6677492..933f972 100644 --- a/packages/extension/tests/persona-pane.render.test.mts +++ b/packages/extension/tests/persona-pane.render.test.mts @@ -1466,3 +1466,61 @@ test("a holder who already keeps a world is not nudged toward worlds", async () assert.doesNotMatch(screen.text(), /Worlds group them/); await screen.unmount(); }); + +// ── What a context may call a face ────────────────────────────────────────── + +test("changing a face keeps the name the context was given, and sends it", async () => { + // `binding/set` replaces the binding. A form that opened with an empty name + // field would clear the label on every face change, silently — the context + // would go from "Ada at the co-op" to no name at all because someone swapped + // which face is worn. + const a = agent({ + "vta/webvh/dids/list/1.0": { dids: [] }, + "persona/binding/list/1.0": { + personas: [{ personaDid: "did:key:zP", bound: true, claimCount: 1, label: "Ada at the co-op" }], + }, + "vta/webvh/servers/list/1.0": { servers: [] }, + "persona/binding/get/1.0": { + contextId: "vta", + personaDid: "did:key:zP", + bound: true, + profileId: "p1", + label: "Ada at the co-op", + claimCount: 1, + }, + "persona/binding/set/1.0": (p: { profileId: string }) => ({ + contextId: "vta", + personaDid: "did:key:zP", + profileId: p.profileId, + version: 3, + materialisedClaimCount: 1, + }), + }); + const ui = await render( + h(BindingForm, { + parties: PARTIES, + authority: HOLDER, + contextId: "vta", + contextLabel: "Verifiable Trust Agent", + profiles: [face("p1", "the divorce", ["f1"]), face("p2", "Work", ["f2"])], + personaDid: "did:key:zP", + onDone: () => {}, + }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await ui.settle(); + + const named = ui.all("input").find((i) => (i as HTMLInputElement).value === "Ada at the co-op"); + assert.ok(named, "the form opened without the name the context already has"); + assert.match(ui.text(), /never sees your own name for this face/); + + const wears = ui.all("select").find((s) => [...s.querySelectorAll("option")].some((o) => o.value === "p2"))!; + await ui.select(wears, "p2"); + await ui.click(ui.button("Change face")); + await ui.settle(); + + const sent = a.of("persona/binding/set")[0]?.payload as { profileId: string; label?: string }; + assert.equal(sent.profileId, "p2"); + assert.equal(sent.label, "Ada at the co-op", "a face change cleared the context's name for it"); + await ui.unmount(); +});