From 1b102e54e3a894af9af83be5d086c8577e1b1a0d Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 21 Sep 2026 20:47:38 +0800 Subject: [PATCH] feat(persona): a face says which name is its own; old values kept for a pin, and removing them Uses @openvtc/trust-tasks 0.19.14 (trustoverip/dtgwg-trust-tasks-tf#538). Face editor -- CALLS ITSELF: pick which of the attributes a face shows is its own name (the entry's `displayName` slot). A face can show a legal name and the one it goes by; this says which is the face's. The choice is offered only from what the face shows, and a name typed into the face itself is kept unless the holder picks another. composeEntriesWithSlots keeps every slot through an edit. profile/put replaces the face, so rebuilding live entries as bare {ref} -- what composeEntries does -- would strip the name on the first save. The NAME hint no longer says contexts see the face's name: since verifiable-trust-infrastructure#1597 a context sees only the label the holder chooses when a persona wears the face. Attribute strip -- kept values: when the agent keeps an earlier value because a face is pinned to it (Attribute.retainedVersions), the strip says so and which faces are the reason. "Remove the old value for good" calls persona/attribute/purge-version after a confirmation that says the pinned faces will show nothing -- not the current value -- and that anyone already shown the old value keeps it. Model in kept-versions.ts. Wiring: personaAttributePurgeVersion in core admin; the URI added to both CI holder-only guards; task-surface.json resynced (vta-sdk 0.45.1) and the recorded coverage moved 205 -> 206 with the reason. Signed-off-by: Glenn Gore --- .github/workflows/ci.yml | 6 +- package-lock.json | 10 +-- package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/admin/persona.ts | 40 ++++++++++ packages/core/task-surface.json | 76 ++++++++++++++++++- packages/core/tests/admin.persona.mjs | 15 ++++ packages/core/tests/task-surface.mjs | 7 +- .../extension/src/manager/kept-versions.ts | 52 +++++++++++++ .../src/manager/panes/persona-editors.tsx | 64 +++++++++++++++- .../src/manager/panes/persona-map.tsx | 45 +++++++++++ .../extension/src/manager/profile-entries.ts | 71 +++++++++++++++++ .../tests/manager-kept-versions.test.mts | 37 +++++++++ .../tests/manager-profile-entries.test.mts | 53 +++++++++++++ .../tests/persona-pane.render.test.mts | 43 +++++++++++ 15 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 packages/extension/src/manager/kept-versions.ts create mode 100644 packages/extension/tests/manager-kept-versions.test.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06882ca..58947e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,7 +173,7 @@ jobs: # file" structural; the assertion below fails if it is ever lost. - name: Assert the holder-scoped half of persona is confined to the console run: | - for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do + for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/attribute/purge-version/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true) if [ -n "$leaked" ]; then echo "::error::$leaked contains $task — this half of persona/* reads or writes the holder's attribute pool, which sits above every trust context. A wallet surface naming it is the boundary being crossed the wrong way; only the console may. Check for an import of @openvtc/pnm-core/admin from a wallet entry, or a shared chunk. See packages/core/src/admin/persona.ts." @@ -199,13 +199,13 @@ jobs: - name: Assert the console still carries the persona surface it is the exception for run: | bundle=packages/extension/dist/manager.js - for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do + for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/attribute/purge-version/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do if ! grep -qF "$task" "$bundle"; then echo "::error::$bundle no longer contains $task. The guard above permits it here precisely because the console's persona pane calls it; a build without it is a pane that cannot do what it offers. If the pane genuinely dropped this task, remove it from BOTH lists in the same change." exit 1 fi done - echo "OK: the console carries all thirteen holder-scoped persona tasks" + echo "OK: the console carries all fourteen holder-scoped persona tasks" # The console's isolation rests on it being one self-contained file: the # guard above names exactly one exception, so a second chunk would be a diff --git a/package-lock.json b/package-lock.json index 0515bd4..6244986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "packages/reviewer-demo" ], "dependencies": { - "@openvtc/trust-tasks": "^0.19.13" + "@openvtc/trust-tasks": "^0.19.14" }, "engines": { "node": ">=24" @@ -2304,9 +2304,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "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==", + "version": "0.19.14", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.19.14.tgz", + "integrity": "sha512-Ke1butlAPt/gjJxq+6d1cTJ57oqyNG7SQYlk91ddoU+c91b2VqP57Sx36hWSsIsuRwyMAxTe4HkHVXueWph3cg==", "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.13", + "@openvtc/trust-tasks": "^0.19.14", "@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 590cca8..d0af411 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,6 @@ "@swc/wasm": "~1.15.47" }, "dependencies": { - "@openvtc/trust-tasks": "^0.19.13" + "@openvtc/trust-tasks": "^0.19.14" } } diff --git a/packages/core/package.json b/packages/core/package.json index 4470552..01f2324 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.13", + "@openvtc/trust-tasks": "^0.19.14", "@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 ef1e62d..e88c93f 100644 --- a/packages/core/src/admin/persona.ts +++ b/packages/core/src/admin/persona.ts @@ -57,6 +57,12 @@ import { type PersonaAttributeDeletePayload, type PersonaAttributeDeleteResponsePayload, } from "@openvtc/trust-tasks/persona/attribute/delete/1.0/payload"; +import { + TYPE_URI as ATTRIBUTE_PURGE_VERSION, + RESPONSE_TYPE_URI as ATTRIBUTE_PURGE_VERSION_RESPONSE, + type PersonaAttributePurgeVersionPayload, + type PersonaAttributePurgeVersionResponsePayload, +} from "@openvtc/trust-tasks/persona/attribute/purge-version/1.0/payload"; import { TYPE_URI as PROFILE_PUT, RESPONSE_TYPE_URI as PROFILE_PUT_RESPONSE, @@ -418,6 +424,40 @@ export async function personaAttributeDelete( ); } +export interface AttributePurgeVersionParams extends PersonaHolderParams { + attributeId: string; + /** Kept versions to remove. Omit to remove every one. The current value is + * never removed here — the agent refuses it with `currentVersion`. */ + versions?: NonNullable; +} + +/** + * Permanently remove earlier versions of an attribute that the agent kept + * because a face pins them — an old name after a name change. + * + * The holder's override on retention. The faces that pinned a removed version + * come back in `stalePins`: they now show **nothing** for that entry, never the + * current value, because a pin exists so a counterparty is not shown a value + * the holder did not choose for them. `purged` empty is a successful no-op. + */ +export async function personaAttributePurgeVersion( + sender: TrustTaskSender, + params: AttributePurgeVersionParams, +): Promise { + const payload: PersonaAttributePurgeVersionPayload = { + attributeId: params.attributeId, + ...(params.versions !== undefined ? { versions: params.versions } : {}), + }; + return holderCall( + sender, + params, + ATTRIBUTE_PURGE_VERSION, + ATTRIBUTE_PURGE_VERSION_RESPONSE, + "persona/attribute/purge-version/1.0", + payload, + ); +} + // ── Profiles ──────────────────────────────────────────────────────────────── /** Every profile the holder has — **to the end of the listing**, like diff --git a/packages/core/task-surface.json b/packages/core/task-surface.json index 6697439..eb26fb0 100644 --- a/packages/core/task-surface.json +++ b/packages/core/task-surface.json @@ -2,7 +2,7 @@ "$comment": "Generated by scripts/sync-task-surface.mjs from a vta-sdk checkout. Do not hand-edit: re-run the script. Checked by tests/task-surface.mjs.", "source": { "crate": "vta-sdk", - "version": "0.38.0", + "version": "0.45.1", "scanned": "vta-sdk/src/**/*.rs" }, "tasks": [ @@ -532,6 +532,12 @@ "TASK_PERSONA_ATTRIBUTE_LIST_1_0" ] }, + { + "uri": "https://trusttasks.org/spec/persona/attribute/purge-version/1.0", + "consts": [ + "TASK_PERSONA_ATTRIBUTE_PURGE_VERSION_1_0" + ] + }, { "uri": "https://trusttasks.org/spec/persona/attribute/put/1.0", "consts": [ @@ -1125,18 +1131,48 @@ "TASK_BACKUP_FINALIZE_IMPORT_1_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/backup/finalize-import/1.1", + "consts": [ + "TASK_BACKUP_FINALIZE_IMPORT_1_1" + ] + }, + { + "uri": "https://trusttasks.org/spec/vta/backup/get-chunk/1.0", + "consts": [ + "TASK_BACKUP_GET_CHUNK_1_0" + ] + }, { "uri": "https://trusttasks.org/spec/vta/backup/initiate-export/1.0", "consts": [ "TASK_BACKUP_INITIATE_EXPORT_1_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/backup/initiate-export/1.1", + "consts": [ + "TASK_BACKUP_INITIATE_EXPORT_1_1" + ] + }, { "uri": "https://trusttasks.org/spec/vta/backup/initiate-import/1.0", "consts": [ "TASK_BACKUP_INITIATE_IMPORT_1_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/backup/initiate-import/1.1", + "consts": [ + "TASK_BACKUP_INITIATE_IMPORT_1_1" + ] + }, + { + "uri": "https://trusttasks.org/spec/vta/backup/put-chunk/1.0", + "consts": [ + "TASK_BACKUP_PUT_CHUNK_1_0" + ] + }, { "uri": "https://trusttasks.org/spec/vta/contexts/create/1.0", "consts": [ @@ -1209,6 +1245,12 @@ "TASK_DID_TEMPLATES_CREATE_2_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/did-templates/create/3.0", + "consts": [ + "TASK_DID_TEMPLATES_CREATE_3_0" + ] + }, { "uri": "https://trusttasks.org/spec/vta/did-templates/delete/2.0", "consts": [ @@ -1221,12 +1263,24 @@ "TASK_DID_TEMPLATES_GET_2_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/did-templates/get/3.0", + "consts": [ + "TASK_DID_TEMPLATES_GET_3_0" + ] + }, { "uri": "https://trusttasks.org/spec/vta/did-templates/list/2.0", "consts": [ "TASK_DID_TEMPLATES_LIST_2_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/did-templates/list/3.0", + "consts": [ + "TASK_DID_TEMPLATES_LIST_3_0" + ] + }, { "uri": "https://trusttasks.org/spec/vta/did-templates/render/2.0", "consts": [ @@ -1239,6 +1293,12 @@ "TASK_DID_TEMPLATES_UPDATE_2_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/did-templates/update/3.0", + "consts": [ + "TASK_DID_TEMPLATES_UPDATE_3_0" + ] + }, { "uri": "https://trusttasks.org/spec/vta/management/reload-services/1.0", "consts": [ @@ -1503,6 +1563,20 @@ "TYPE" ] }, + { + "uri": "https://trusttasks.org/spec/vtc/join-requests/supplement/0.1", + "consts": [ + "JOIN_REQUEST_SUPPLEMENT_RESPONSE_TYPE", + "JOIN_REQUEST_SUPPLEMENT_TYPE" + ] + }, + { + "uri": "https://trusttasks.org/spec/vtc/join-requests/withdraw/0.1", + "consts": [ + "JOIN_REQUEST_WITHDRAW_RESPONSE_TYPE", + "JOIN_REQUEST_WITHDRAW_TYPE" + ] + }, { "uri": "https://trusttasks.org/spec/vtc/members/removal-notice/0.1", "consts": [ diff --git a/packages/core/tests/admin.persona.mjs b/packages/core/tests/admin.persona.mjs index f88525d..756cb9f 100644 --- a/packages/core/tests/admin.persona.mjs +++ b/packages/core/tests/admin.persona.mjs @@ -16,6 +16,7 @@ import { personaAttributeList, personaAttributePut, personaAttributeDelete, + personaAttributePurgeVersion, personaProfileList, personaProfileGet, personaProfilePut, @@ -68,6 +69,12 @@ test("every task names its 1.0 URI, request and response", async () => { "persona/attribute/delete/1.0", { attributeId: "01J", existed: true }, ], + [ + personaAttributePurgeVersion, + { ...PARTIES, attributeId: "01J", versions: [3] }, + "persona/attribute/purge-version/1.0", + { attributeId: "01J", purged: [3] }, + ], [personaProfileList, { ...PARTIES }, "persona/profile/list/1.0", { profiles: [] }], [ personaProfileGet, @@ -605,3 +612,11 @@ test("deleting a facet sends only the facet — there is no cascade to send", as assert.deepEqual(Object.keys(payload).sort(), ["facetId"]); assert.equal(res.releasedFaces, 2, "the released-face count is what a screen reads"); }); + +test("purging every kept version sends no versions member at all", async () => { + // Omitted is "all of them"; an empty array would be a schema violation, and + // a client that sent one for "all" would be refused for doing the obvious. + const all = recorder({ attributeId: "01J", purged: [3, 5] }); + await personaAttributePurgeVersion(all, { ...PARTIES, attributeId: "01J" }); + assert.deepEqual(all.sent[0].envelope.payload, { attributeId: "01J" }); +}); diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index d10340d..3225c3e 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -360,7 +360,12 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // resolve them. So this is not the agent growing seven families — it is a // snapshot that had been under-counting the denominator, and with it the gap // this number exists to keep reviewable. - const expected = 205; + // + // 205 → 206 is `persona/attribute/purge-version/1.0` — removing an earlier + // value the agent kept for a pinned face (dtgwg-trust-tasks-tf#538). The + // canonical total moves 235 → 240 with the snapshot resynced from vta-sdk + // 0.45.1; the other four are families the agent gained since, not this one. + const expected = 206; assert.equal( implemented.size, expected, diff --git a/packages/extension/src/manager/kept-versions.ts b/packages/extension/src/manager/kept-versions.ts new file mode 100644 index 0000000..2bb6597 --- /dev/null +++ b/packages/extension/src/manager/kept-versions.ts @@ -0,0 +1,52 @@ +// Earlier values the agent keeps because a face pins them. +// +// After a name change, a face pinned to the old version — the bank that +// verified the old name — keeps showing it, so the agent keeps that version. +// `Attribute.retainedVersions` says which versions are kept and which faces are +// the reason. A holder who overwrote a value may reasonably believe it gone; +// this is where the console tells them it is not, and offers to remove it. +// +// Removing it does not make those faces show the new value: a pin exists so a +// counterparty is not shown a value the holder did not choose for them. They +// show nothing for that entry until the holder repins or edits them. The words +// here say that, because "remove the old name" reads like "update the bank". + +import type { PoolAttribute } from "@openvtc/pnm-core/admin"; + +export interface KeptVersion { + version: number; + /** The faces pinning it, by the holder's own name where the console has one. */ + faces: string[]; +} + +/** What the agent keeps of this attribute beyond its current value. */ +export function keptVersions( + attribute: PoolAttribute, + profiles: readonly { profileId: string; name: string }[], +): KeptVersion[] { + return (attribute.retainedVersions ?? []).map((k) => ({ + version: k.version, + faces: k.pinnedBy.map((id) => profiles.find((p) => p.profileId === id)?.name ?? "a face"), + })); +} + +/** One line for the strip, or `null` when nothing is kept. */ +export function keptWords(kept: readonly KeptVersion[]): string | null { + if (kept.length === 0) return null; + const faces = [...new Set(kept.flatMap((k) => k.faces))]; + const who = faces.length === 1 ? faces[0] : `${faces.slice(0, -1).join(", ")} and ${faces.at(-1)}`; + return kept.length === 1 + ? `An earlier value is still kept, because ${who} ${faces.length === 1 ? "is" : "are"} pinned to it.` + : `${kept.length} earlier values are still kept, because ${who} ${faces.length === 1 ? "is" : "are"} pinned to them.`; +} + +/** What removing them does to the faces that pinned them. */ +export function purgeConsequence(kept: readonly KeptVersion[]): string { + const faces = [...new Set(kept.flatMap((k) => k.faces))]; + return ( + `${faces.join(", ")} will show nothing for this afterwards — not the current value, ` + + `which you did not choose for ${faces.length === 1 ? "it" : "them"}. Repin or edit ` + + `${faces.length === 1 ? "it" : "them"} if that is not what you want. Anyone already shown ` + + `the old value keeps it.` + ); +} diff --git a/packages/extension/src/manager/panes/persona-editors.tsx b/packages/extension/src/manager/panes/persona-editors.tsx index 7be895b..c9d7e26 100644 --- a/packages/extension/src/manager/panes/persona-editors.tsx +++ b/packages/extension/src/manager/panes/persona-editors.tsx @@ -45,7 +45,14 @@ import { holderGate } from "../holder-gate.js"; import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; import { maskedValue, treatmentFor, type Sensitivity } from "../claim-sensitivity.js"; import { revealAttributeValue } from "../reveal-value.js"; -import { composeEntries, lockedRefs, preservedEntries, tickedFrom } from "../profile-entries.js"; +import { + composeEntriesWithSlots, + displayNameIsInline, + displayNameOf, + lockedRefs, + preservedEntries, + tickedFrom, +} from "../profile-entries.js"; import { personaCandidates } from "../persona-candidates.js"; import { currencyOf, currencyWords, editReachWords, outdatedHolders } from "../disclosure-currency.js"; @@ -765,6 +772,9 @@ export function AttributeEditor({ } +/** The `CALLS ITSELF` option meaning "leave the inline name as it is". */ +const KEEP_INLINE = "\u0000inline"; + export function ProfileEditor({ parties, authority, @@ -798,6 +808,13 @@ export function ProfileEditor({ const [selected, setSelected] = useState>( () => new Set(tickedFrom(existing?.entries ?? [])), ); + /** The attribute this face calls itself by — its `displayName` slot. `""` + * is "no name". An inline name, typed into the face and not in the pool, is + * kept as it is unless the holder picks something else. */ + const inlineName = useMemo(() => displayNameIsInline(existing?.entries ?? []), [existing]); + const [nameAttr, setNameAttr] = useState( + () => displayNameOf(existing?.entries ?? []) ?? (inlineName ? KEEP_INLINE : ""), + ); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [pending, setPending] = useState(null); @@ -816,7 +833,13 @@ export function ProfileEditor({ setBusy(true); setError(null); setPending(null); - const entries = composeEntries(existing?.entries ?? [], selected); + // `undefined` leaves the name as the face has it — the inline case, which + // the tick list cannot express and so must not overwrite. + const entries = composeEntriesWithSlots( + existing?.entries ?? [], + selected, + nameAttr === KEEP_INLINE ? undefined : nameAttr === "" ? null : nameAttr, + ); const ok = await runMutation( async () => { await personaProfilePut(managerSender, { @@ -838,7 +861,7 @@ export function ProfileEditor({ ); setBusy(false); if (ok) onDone(); - }, [parties, name, selected, existing, onDone]); + }, [parties, name, selected, nameAttr, existing, onDone]); // Reported from a ref, not from the dependency list. `onPreview` is nearly // always an inline arrow, so keying the effect on it would fire on every @@ -875,7 +898,8 @@ export function ProfileEditor({ placeholder="work" /> - Contexts see this name too. Pick one that says who you are there, not where you use it. + Only you see this name. A community is told whatever you choose when a persona there + wears this face — or nothing. @@ -921,6 +945,38 @@ export function ProfileEditor({ + {(() => { + // Offered from what the face actually shows: naming it by an + // attribute it does not present would be a name nobody is shown. + const shown = attributes.filter( + (a) => selected.has(a.attributeId) || preservedRefs.has(a.attributeId), + ); + return ( + + ); + })()} + {preserved.length > 0 && (
diff --git a/packages/extension/src/manager/panes/persona-map.tsx b/packages/extension/src/manager/panes/persona-map.tsx index 3ebce3b..03f14c8 100644 --- a/packages/extension/src/manager/panes/persona-map.tsx +++ b/packages/extension/src/manager/panes/persona-map.tsx @@ -20,6 +20,8 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { personaAttributeDelete, + personaAttributeList, + personaAttributePurgeVersion, personaCorrelationAnalyze, personaFacetPut, personaFacetDelete, @@ -29,6 +31,7 @@ import { type PoolAttribute, type PoolProfile, } from "@openvtc/pnm-core/admin"; +import { keptVersions, keptWords, purgeConsequence } from "../kept-versions.js"; import type { ContextRecord } from "@openvtc/pnm-core"; import { Button, Note, Pill } from "../../ui.js"; import { c, t, font } from "../../theme.js"; @@ -1679,6 +1682,48 @@ function DetailStrip({ ))} {col("Last left", lastLeft((d) => d.claimTypes.includes(attribute.type)))}
+ {raw && keptWords(keptVersions(raw, profiles)) && ( + +
+ {keptWords(keptVersions(raw, profiles))} + + That is what pinning is for — a counterparty that verified the old value keeps + seeing it until you tell them otherwise. + +
+ > + label="Remove the old value for good" + preview={async () => { + // Asked again so the decision is about what is kept now. + const fresh = await personaAttributeList(managerSender, { + ...parties, + typePrefix: raw.type, + }); + const now = fresh.find((a) => a.attributeId === raw.attributeId); + return now ? keptVersions(now, profiles) : []; + }} + renderPreview={(kept) => + kept.length === 0 ? ( + Nothing is kept any more. + ) : ( + <> + This cannot be undone. + {purgeConsequence(kept)} + + ) + } + commit={async () => { + await personaAttributePurgeVersion(managerSender, { + ...parties, + attributeId: raw.attributeId, + }); + }} + onDone={onChanged} + /> +
+
+
+ )}
diff --git a/packages/extension/src/manager/profile-entries.ts b/packages/extension/src/manager/profile-entries.ts index dcaf0ee..07f49af 100644 --- a/packages/extension/src/manager/profile-entries.ts +++ b/packages/extension/src/manager/profile-entries.ts @@ -86,3 +86,74 @@ export function composeEntries( .map((ref) => ({ ref })); return [...live, ...preserved]; } + +// ── Slots ─────────────────────────────────────────────────────────────────── +// +// Any entry may carry a `slot` naming the role it plays in the face — +// `displayName` answers "what does this face call itself", which a face holding +// a legal name and a preferred one cannot answer by claim type. `profile/put` +// replaces the whole face, so the rules that keep a preserved entry from +// vanishing apply to a slot too: rebuilding live entries as bare `{ref}` would +// strip every slot on them, silently, on the first edit. + +/** The slot `displayName`. */ +export const DISPLAY_NAME = "displayName"; + +function slotOf(entry: PoolProfileEntry): string | undefined { + return (entry as { slot?: string }).slot; +} + +function withSlot(entry: E, slot: string | undefined): E { + const { slot: _drop, ...rest } = entry as E & { slot?: string }; + return (slot === undefined ? rest : { ...rest, slot }) as E; +} + +/** The attribute the face calls itself by, or null when no entry says. An + * inline name has no attribute, and reads as null here too. */ +export function displayNameOf(entries: readonly PoolProfileEntry[]): string | null { + const e = entries.find((x) => slotOf(x) === DISPLAY_NAME); + return e ? refOf(e) : null; +} + +/** Whether a face names itself with a value typed only into the face — a + * choice the tick list cannot make, and so must not overwrite. */ +export function displayNameIsInline(entries: readonly PoolProfileEntry[]): boolean { + return entries.some((x) => slotOf(x) === DISPLAY_NAME && refOf(x) === null); +} + +/** + * {@link composeEntries}, carrying slots through and setting `displayName`. + * + * `displayName` is the attribute to mark, `null` to mark none, or `undefined` + * to leave whatever the face already says. Every other slot is kept where it + * was: a live entry keeps the slot its attribute had, a preserved entry keeps + * its own. The one slot moved is `displayName`, and it moves whole — one entry + * holds it afterwards, or none, never two (the agent would refuse two). + */ +export function composeEntriesWithSlots( + existing: readonly PoolProfileEntry[], + ticked: Iterable, + displayName?: string | null, +): PoolProfileEntry[] { + const liveSlots = new Map(); + for (const e of existing) { + const s = slotOf(e); + if (isPlainRef(e) && s !== undefined) liveSlots.set(refOf(e) as string, s); + } + const composed = composeEntries(existing, ticked).map((e) => + isPlainRef(e) ? withSlot(e, liveSlots.get(refOf(e) as string)) : e, + ); + if (displayName === undefined) return composed; + + // Clear it everywhere, then set it on the first entry naming the attribute. + let placed = false; + return composed.map((e) => { + const s = slotOf(e); + const cleared = s === DISPLAY_NAME ? withSlot(e, undefined) : e; + if (!placed && displayName !== null && refOf(e) === displayName && slotOf(cleared) === undefined) { + placed = true; + return withSlot(cleared, DISPLAY_NAME); + } + return cleared; + }); +} diff --git a/packages/extension/tests/manager-kept-versions.test.mts b/packages/extension/tests/manager-kept-versions.test.mts new file mode 100644 index 0000000..c0e1373 --- /dev/null +++ b/packages/extension/tests/manager-kept-versions.test.mts @@ -0,0 +1,37 @@ +// What the console says about values kept for a pinned face. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { keptVersions, keptWords, purgeConsequence } from "../src/manager/kept-versions.ts"; + +const attr = (retained?: unknown) => + ({ attributeId: "01A", type: "name.legal", version: 9, ...(retained ? { retainedVersions: retained } : {}) }) as never; +const FACES = [{ profileId: "01B", name: "Bank" }, { profileId: "01T", name: "Tax office" }]; + +test("an agent that keeps nothing, or predates the member, says nothing", () => { + assert.deepEqual(keptVersions(attr(), FACES), []); + assert.equal(keptWords([]), null); +}); + +test("kept versions are named by the faces that are the reason", () => { + const kept = keptVersions(attr([{ version: 3, pinnedBy: ["01B", "01T"] }]), FACES); + assert.deepEqual(kept, [{ version: 3, faces: ["Bank", "Tax office"] }]); + assert.equal( + keptWords(kept), + "An earlier value is still kept, because Bank and Tax office are pinned to it.", + ); +}); + +test("purging says the faces show nothing — not the new value", () => { + // The misreading this exists to prevent: "remove the old name" heard as + // "update the bank". + const words = purgeConsequence([{ version: 3, faces: ["Bank"] }]); + assert.match(words, /Bank will show nothing/); + assert.match(words, /not the current value/); + assert.match(words, /already shown the old value keeps it/); +}); + +test("a face the console does not hold is still counted", () => { + const kept = keptVersions(attr([{ version: 3, pinnedBy: ["01Z"] }]), FACES); + assert.deepEqual(kept[0].faces, ["a face"]); +}); diff --git a/packages/extension/tests/manager-profile-entries.test.mts b/packages/extension/tests/manager-profile-entries.test.mts index 563108d..2c8b85f 100644 --- a/packages/extension/tests/manager-profile-entries.test.mts +++ b/packages/extension/tests/manager-profile-entries.test.mts @@ -125,3 +125,56 @@ test("a profile with nothing to preserve composes exactly the ticks", () => { test("a repeated tick is written once", () => { assert.deepEqual(composeEntries([], ["x", "x"]), [{ ref: "x" }]); }); + +// ── Slots ─────────────────────────────────────────────────────────────────── + +test("an edit keeps every slot where it was", async () => { + // `profile/put` replaces the face: a live entry rebuilt as a bare `{ref}` + // would lose its slot on every save, silently. + const { composeEntriesWithSlots } = await import("../src/manager/profile-entries.ts"); + const named = [ + { ref: "a-name", slot: "displayName" }, + { ref: "a-mail", slot: "primaryEmail" }, + { ref: "a-pinned", pinVersion: 3, slot: "primaryPhone" }, + ]; + assert.deepEqual(composeEntriesWithSlots(named, ["a-name", "a-mail"]), named); +}); + +test("choosing a name moves displayName to exactly one entry", async () => { + const { composeEntriesWithSlots, displayNameOf } = await import( + "../src/manager/profile-entries.ts" + ); + const before = [{ ref: "legal", slot: "displayName" }, { ref: "known-as" }]; + const after = composeEntriesWithSlots(before, ["legal", "known-as"], "known-as"); + assert.deepEqual(after, [{ ref: "legal" }, { ref: "known-as", slot: "displayName" }]); + assert.equal(displayNameOf(after), "known-as"); + // None. + const none = composeEntriesWithSlots(before, ["legal", "known-as"], null); + assert.equal(displayNameOf(none), null); + assert.ok(none.every((e) => !("slot" in e))); +}); + +test("a name kept on a pinned entry can be chosen, and its pin is untouched", async () => { + const { composeEntriesWithSlots } = await import("../src/manager/profile-entries.ts"); + const out = composeEntriesWithSlots([{ ref: "a-pinned", pinVersion: 3 }], [], "a-pinned"); + assert.deepEqual(out, [{ ref: "a-pinned", pinVersion: 3, slot: "displayName" }]); +}); + +test("an entry already playing another role is not also made the name", async () => { + // One entry, one slot: the agent's schema carries a single `slot` per entry. + const { composeEntriesWithSlots, displayNameOf } = await import( + "../src/manager/profile-entries.ts" + ); + const out = composeEntriesWithSlots([{ ref: "a-mail", slot: "primaryEmail" }], ["a-mail"], "a-mail"); + assert.deepEqual(out, [{ ref: "a-mail", slot: "primaryEmail" }]); + assert.equal(displayNameOf(out), null); +}); + +test("leaving the name alone leaves an inline name alone", async () => { + const { composeEntriesWithSlots, displayNameIsInline } = await import( + "../src/manager/profile-entries.ts" + ); + const inlineName = { ...INLINE, slot: "displayName" }; + const out = composeEntriesWithSlots([inlineName, LIVE], ["a-live"]); + assert.ok(displayNameIsInline(out), "an untouched edit took the face's own name away"); +}); diff --git a/packages/extension/tests/persona-pane.render.test.mts b/packages/extension/tests/persona-pane.render.test.mts index 933f972..c8c9544 100644 --- a/packages/extension/tests/persona-pane.render.test.mts +++ b/packages/extension/tests/persona-pane.render.test.mts @@ -1524,3 +1524,46 @@ test("changing a face keeps the name the context was given, and sends it", async assert.equal(sent.label, "Ada at the co-op", "a face change cleared the context's name for it"); await ui.unmount(); }); + +// ── An old value kept for a pinned face ───────────────────────────────────── + +test("an attribute kept for a pinned face says so, and removing it asks first", async () => { + // A holder who changed their name may reasonably believe the old one gone. + // Selecting the attribute is where they learn it is kept, and for whom. + const kept = [ + { ...FACTS[0], retainedVersions: [{ version: 1, updatedAt: "x", pinnedBy: ["p9"] }] }, + FACTS[1], + ]; + const bank = face("p9", "Bank", []); + const graph = buildGraph(kept as never, [DEV, bank], [knownAs("openvtc", "OpenVTC")]); + const a = agent({ + "persona/attribute/list/1.0": { attributes: kept }, + "persona/attribute/purge-version/1.0": (p: { attributeId: string }) => ({ + attributeId: p.attributeId, + purged: [1], + stalePins: [{ profileId: "p9", pinVersion: 1 }], + }), + }); + const ui = await render( + h(IdentityMap, { + registry: REGISTRY, + parties: PARTIES, + authority: HOLDER, + graph, + attributes: kept as never, + profiles: [DEV, bank], + records: CONTEXTS, + history: [], + onChanged: () => {}, + }), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await ui.click(ui.byText("div", "name.legal")!); + assert.match(ui.text(), /An earlier value is still kept, because Bank is pinned to it/); + + await ui.click(ui.button("Remove the old value for good")); + await ui.settle(); + assert.match(ui.text(), /Bank will show nothing for this afterwards/); + assert.equal(a.of("purge-version").length, 0, "it purged before the holder confirmed"); + await ui.unmount(); +});