From 297fb43a2ed098e6a06ae4b3822dbb46081d7dce Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 11:51:39 +0200 Subject: [PATCH] docs(persona): the on-screen word for an attribute is "attribute", not "fact" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vocabulary table translated `attribute` to **fact** on screen. That was wrong in two independent ways, and the console is where it showed. It asserts what the model cannot promise. What a holder keeps here is self-asserted until a credential backs it, and the whole point of a face is that a person chooses what to show: an old value, a value pinned to a version, a value overridden for one context, or a value that is simply not true. The card said *fact* directly above the provenance line that said *you said so*. And the word was already spent. `fact` is the VTC ceremony engine's term for a *verified* policy input (`vtc-service/src/ceremony/facts.rs`, `Facts` assembly, every `.rego`) — very nearly the opposite meaning, in the same product, which the table's own rule forbids. The friendlier candidates fail that rule too: `detail` is the persona audit envelope's field, `trait` is a Rust keyword, `entry` names an entry in a face, `value` is the field inside an attribute. So the spec word comes to the screen and the table stops translating this one row. Truth is carried by the provenance beneath the value, never by the noun. - identity map, editors, guided setup and the holder-gate caution now say attribute; `FactNode`/`factIds`/`factReach`/`kind: "fact"` renamed with them - the vocabulary guard in manager-holder-gate now bans `fact` and permits `attribute`, with the reasoning in place so it is not "fixed" back - step-up fixture follows the agent's card text (VTI change lands alongside) 309 extension tests and 548 core tests pass; `tsc -b` clean across workspaces. Signed-off-by: Glenn Gore --- packages/core/src/admin/persona.ts | 2 +- packages/core/src/persona/consent-view.ts | 2 +- packages/core/src/persona/contacts.ts | 2 +- packages/core/src/persona/step-up.ts | 2 +- packages/core/tests/persona.step-up.mjs | 6 +- packages/extension/src/confirm.tsx | 2 +- packages/extension/src/disclosure-consent.tsx | 2 +- .../src/manager/claim-sensitivity.ts | 8 +- packages/extension/src/manager/holder-gate.ts | 2 +- .../extension/src/manager/identity-graph.ts | 54 ++++---- .../src/manager/panes/persona-editors.tsx | 56 +++++---- .../src/manager/panes/persona-map.tsx | 116 +++++++++--------- .../src/manager/panes/persona-setup.tsx | 36 +++--- .../extension/src/manager/panes/persona.tsx | 12 +- .../extension/src/manager/persona-flow.ts | 8 +- .../extension/src/manager/profile-entries.ts | 2 +- .../tests/manager-claim-sensitivity.test.mts | 4 +- .../tests/manager-holder-gate.test.mts | 6 +- .../tests/manager-identity-graph.test.mts | 42 +++---- .../tests/manager-persona-guide.test.mts | 8 +- .../tests/manager-profile-entries.test.mts | 2 +- .../tests/persona-pane.render.test.mts | 32 ++--- 22 files changed, 206 insertions(+), 200 deletions(-) diff --git a/packages/core/src/admin/persona.ts b/packages/core/src/admin/persona.ts index 67aceeb..5e13763 100644 --- a/packages/core/src/admin/persona.ts +++ b/packages/core/src/admin/persona.ts @@ -209,7 +209,7 @@ export interface AttributePutParams extends PersonaHolderParams { type: string; valueType: AttributeValueType; /** - * The fact itself. Must agree with `valueType`; the agent refuses a document + * The value itself. Must agree with `valueType`; the agent refuses a document * where it does not. * * Typed `unknown` deliberately. The published schema places no type diff --git a/packages/core/src/persona/consent-view.ts b/packages/core/src/persona/consent-view.ts index c7b28f9..ea60f2b 100644 --- a/packages/core/src/persona/consent-view.ts +++ b/packages/core/src/persona/consent-view.ts @@ -111,7 +111,7 @@ function describePredicate(p: NonNullable): string { }[p.op] ?? p.op; const arg = typeof p.arg === "string" ? p.arg : JSON.stringify(p.arg); // The trailing clause is the point of the row, not a footnote: this rung is - // the only one where the verifier learns a fact without receiving a value. + // the only one where the verifier learns something without receiving a value. return `proves ${op} ${arg} — the value itself is not sent`; } diff --git a/packages/core/src/persona/contacts.ts b/packages/core/src/persona/contacts.ts index 8cd30e5..058265c 100644 --- a/packages/core/src/persona/contacts.ts +++ b/packages/core/src/persona/contacts.ts @@ -4,7 +4,7 @@ // identity out, these record what came back the other way. A contact is stored // **as received** and is never merged into the holder's own attributes — that // separation is the point. A contact is somebody else's account of themselves, -// not a fact the holder is asserting, and a store that blurred the two would +// not an attribute the holder is asserting, and a store that blurred the two would // let a peer's claim about their own name be re-presented as the holder's. // // Every contact is filed against `knownByPersona`: which of the holder's own diff --git a/packages/core/src/persona/step-up.ts b/packages/core/src/persona/step-up.ts index c436bc9..23dafdc 100644 --- a/packages/core/src/persona/step-up.ts +++ b/packages/core/src/persona/step-up.ts @@ -102,7 +102,7 @@ export interface DisclosureStepUpRequired { * Named for what it is. Nothing in it may be shown to a human or signed over * until {@link verifyDisclosureStepUp} has passed — the spec rule is that a * consumer verifies the proof *before* surfacing the reason, and here the - * reason includes the list of facts about to leave. + * reason includes the list of attributes about to leave. */ unverifiedApproveRequest: Record; } diff --git a/packages/core/tests/persona.step-up.mjs b/packages/core/tests/persona.step-up.mjs index 5250ba8..4e05ccf 100644 --- a/packages/core/tests/persona.step-up.mjs +++ b/packages/core/tests/persona.step-up.mjs @@ -39,11 +39,11 @@ async function approveRequest({ as = AGENT, previewId = PREVIEW, ctx = {} } = {} subject: "did:key:zHolder", sessionId: "sess-42", challenge: "a".repeat(32), - reason: "Approve disclosing 1 fact to did:key:zVerifier", + reason: "Approve disclosing 1 attribute to did:key:zVerifier", ext: { [AUTHZ_EXT]: { type: CONTEXT_TYPE, - summary: "Approve disclosing 1 fact to did:key:zVerifier", + summary: "Approve disclosing 1 attribute to did:key:zVerifier", risk: "high", action: { kind: "disclose", @@ -135,7 +135,7 @@ test("what the holder is shown comes out of the signature", async () => { assert.deepEqual(res.context.claimTypes, ["payment.card"]); assert.equal(res.context.verifierDid, "did:key:zVerifier"); assert.equal(res.context.purpose, "checkout"); - assert.equal(res.context.summary, "Approve disclosing 1 fact to did:key:zVerifier"); + assert.equal(res.context.summary, "Approve disclosing 1 attribute to did:key:zVerifier"); assert.equal(res.request.challenge, "a".repeat(32)); }); diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index fb1cfee..d3cb1e5 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -44,7 +44,7 @@ const isTaskConsent = params.get("kind") === "task"; // `kind=disclosure` selects the persona-disclosure surface. It is its own // surface rather than a variant of the login prompt because what it authorizes // is different in kind: the login prompt says "let this site sign you in", and -// this one says "let this verifier keep these facts about you". Sharing a +// this one says "let this verifier keep these attributes about you". Sharing a // component would mean one screen trying to word both. const isDisclosureConsent = params.get("kind") === "disclosure"; // The biometric-gated approver surface: Approve must run a fresh WebAuthn diff --git a/packages/extension/src/disclosure-consent.tsx b/packages/extension/src/disclosure-consent.tsx index 834d05e..221b3ac 100644 --- a/packages/extension/src/disclosure-consent.tsx +++ b/packages/extension/src/disclosure-consent.tsx @@ -16,7 +16,7 @@ // - **Withheld rows are drawn as warnings, not as claims.** They are the only // rows whose presence makes the disclosure smaller than it looks. // - **A predicate is drawn in the positive colour.** It is the strongest -// outcome on the screen — the verifier learns a fact and receives no value +// outcome on the screen — the verifier learns something and receives no value // — and drawing it in the same ink as an ordinary disclosure would hide the // one row where the holder gave away least. // - **Linkability leads when it is not `none`.** It sits above the claim list diff --git a/packages/extension/src/manager/claim-sensitivity.ts b/packages/extension/src/manager/claim-sensitivity.ts index d2655ca..ff756dd 100644 --- a/packages/extension/src/manager/claim-sensitivity.ts +++ b/packages/extension/src/manager/claim-sensitivity.ts @@ -1,4 +1,4 @@ -// How carefully a fact's value is shown to the person who owns it. +// How carefully an attribute's value is shown to the person who owns it. // // ## This is not a security control, and saying so is the point // @@ -133,7 +133,7 @@ const REGISTERED: Readonly> = { * §4's first rule is a per-attribute override the holder set explicitly, which * wins over the registry. No field carries one on the wire yet, so nothing here * can read it; when one exists it belongs *above* this call, not inside it, - * because "the holder decided" and "the registry says" are different facts and + * because "the holder decided" and "the registry says" are different attributes and * a UI that wants to explain the difference needs both. * * **The prefix walk is rule 3, and it only ever tightens.** An unregistered @@ -274,12 +274,12 @@ function emailLocal(text: string): string { } /** - * What to draw for a fact, and whether a *Show* control belongs beside it. + * What to draw for an attribute, and whether a *Show* control belongs beside it. * * `masked` is the caller's cue for two separate things and both matter: a * reveal control, and a rendering distinct from an absent value. A pane that * greys a mask the way it greys "not requested" has told the operator that a - * fact they hold is a fact they do not. + * attribute they hold is an attribute they do not. */ export function maskedFact(type: string, text: string): { text: string; masked: boolean } { const treatment = treatmentOf(type); diff --git a/packages/extension/src/manager/holder-gate.ts b/packages/extension/src/manager/holder-gate.ts index 45a19a3..6f97fab 100644 --- a/packages/extension/src/manager/holder-gate.ts +++ b/packages/extension/src/manager/holder-gate.ts @@ -60,7 +60,7 @@ export function holderGate(authority: Authority | null): string | null { if (!authority) return null; if (isUnscopedHolder(authority)) return null; return ( - "Your facts sit above every context, so reaching them takes authority of its own: " + + "Your attributes sit above every context, so reaching them takes authority of its own: " + "an agent credential with no context restriction, or one granted the " + "`persona-holder` capability. This console cannot see which you have — " + "`auth/whoami` reports roles and scopes, not capabilities — so it does not stop " + diff --git a/packages/extension/src/manager/identity-graph.ts b/packages/extension/src/manager/identity-graph.ts index f558474..797e4ec 100644 --- a/packages/extension/src/manager/identity-graph.ts +++ b/packages/extension/src/manager/identity-graph.ts @@ -1,21 +1,21 @@ -// The identity map's model: facts, faces, contexts, and what reaches what. +// The identity map's model: attributes, faces, contexts, and what reaches what. // -// The persona pane draws one picture of the whole model — facts on top, faces +// The persona pane draws one picture of the whole model — attributes on top, faces // in the middle, contexts below — and lights up everything a selection reaches. -// Which things light up is the substance of the picture: a fact that reaches +// Which things light up is the substance of the picture: an attribute that reaches // two contexts through one face is the holder's linkage made visible, and a // context that lights the wrong face is a lie about what it holds. So the graph // and the reach computation live here, out of the component, with no DOM and // no relative imports, where they can be tested the way `profile-entries.ts` // was. // -// Vocabulary on screen follows `design-docs/persona-vocabulary.md`: a fact, a +// Vocabulary on screen follows `design-docs/persona-vocabulary.md`: an attribute, a // face, a context, a persona that *wears* a face. The identifiers below keep the // spec's names where they name wire records (`profileId`, `personaDid`). import type { PoolAttribute, PoolProfile, PoolProfileEntry } from "@openvtc/pnm-core/admin"; -export interface FactNode { +export interface AttributeNode { id: string; type: string; label?: string | undefined; @@ -29,10 +29,10 @@ export interface FactNode { export interface FaceNode { id: string; name: string; - /** Facts this face selects by live reference. */ - factIds: string[]; + /** Attributes this face selects by live reference. */ + attributeIds: string[]; /** Entries that are not a live reference — pinned, overridden, inline. They - * still reach a context; they just do not draw to a fact card. */ + * still reach a context; they just do not draw to an attribute card. */ preserved: number; entries: PoolProfileEntry[]; version: number; @@ -65,14 +65,14 @@ export interface FaceLink { } export interface IdentityGraph { - facts: FactNode[]; + attributes: AttributeNode[]; faces: FaceNode[]; contexts: ContextNode[]; links: FaceLink[]; } export type Selection = - | { kind: "fact"; id: string } + | { kind: "attribute"; id: string } | { kind: "face"; id: string } | { kind: "context"; id: string } | { kind: "persona"; contextId: string; did: string }; @@ -111,7 +111,7 @@ export function buildGraph( profiles: readonly PoolProfile[], contexts: readonly ContextInput[], ): IdentityGraph { - const facts: FactNode[] = attributes.map((a) => ({ + const attributeNodes: AttributeNode[] = attributes.map((a) => ({ id: a.attributeId, type: a.type, label: a.label, @@ -127,7 +127,7 @@ export function buildGraph( return { id: p.profileId, name: p.name, - factIds: refs, + attributeIds: refs, preserved: p.entries.length - refs.length, entries: p.entries, version: p.version, @@ -166,28 +166,28 @@ export function buildGraph( .filter(([, list]) => list.length > 1) .map(([faceId, list]) => ({ faceId, wearers: list })); - return { facts, faces, contexts: contextNodes, links }; + return { attributes: attributeNodes, faces, contexts: contextNodes, links }; } /** Everything a selection reaches, in every direction it can reach. */ export interface Reach { - factIds: Set; + attributeIds: Set; faceIds: Set; personaKeys: Set; contextIds: Set; } function empty(): Reach { - return { factIds: new Set(), faceIds: new Set(), personaKeys: new Set(), contextIds: new Set() }; + return { attributeIds: new Set(), faceIds: new Set(), personaKeys: new Set(), contextIds: new Set() }; } /** * What lights up. * - * Reach runs **downwards from a fact** — the faces that select it, the personas + * Reach runs **downwards from an attribute** — the faces that select it, the personas * that wear those faces, the contexts they are in — and **upwards from a - * context** — its personas' faces and those faces' facts. A face reaches both - * ways. That asymmetry is the model: a fact's reach is where it *goes*; a + * context** — its personas' faces and those faces' attributes. A face reaches both + * ways. That asymmetry is the model: an attribute's reach is where it *goes*; a * context's reach is what it *holds*. * * Nothing here reads a context's copy. The picture is drawn from the pool and @@ -199,7 +199,7 @@ export function reachOf(graph: IdentityGraph, selection: Selection | null): Reac const out = empty(); if (!selection) return out; - const facesWithFact = (factId: string) => graph.faces.filter((f) => f.factIds.includes(factId)); + const facesWithAttribute = (attributeId: string) => graph.faces.filter((f) => f.attributeIds.includes(attributeId)); const wearersOf = (faceId: string) => graph.contexts.flatMap((c) => c.personas.filter((p) => p.faceId === faceId)); @@ -213,13 +213,13 @@ export function reachOf(graph: IdentityGraph, selection: Selection | null): Reac const lightFaceUp = (faceId: string) => { out.faceIds.add(faceId); const face = graph.faces.find((f) => f.id === faceId); - for (const id of face?.factIds ?? []) out.factIds.add(id); + for (const id of face?.attributeIds ?? []) out.attributeIds.add(id); }; switch (selection.kind) { - case "fact": - out.factIds.add(selection.id); - for (const face of facesWithFact(selection.id)) lightFaceDown(face.id); + case "attribute": + out.attributeIds.add(selection.id); + for (const face of facesWithAttribute(selection.id)) lightFaceDown(face.id); break; case "face": lightFaceUp(selection.id); @@ -246,13 +246,13 @@ export function reachOf(graph: IdentityGraph, selection: Selection | null): Reac return out; } -/** The faces a fact reaches, and the contexts beyond them — the sentence the +/** The faces an attribute reaches, and the contexts beyond them — the sentence the * detail strip says: "reaches 2 contexts through Glenn – Developer". */ -export function factReach( +export function attributeReach( graph: IdentityGraph, - factId: string, + attributeId: string, ): { faces: FaceNode[]; contextIds: string[]; wearers: PersonaNode[] } { - const faces = graph.faces.filter((f) => f.factIds.includes(factId)); + const faces = graph.faces.filter((f) => f.attributeIds.includes(attributeId)); const wearers = graph.contexts.flatMap((c) => c.personas.filter((p) => p.faceId !== null && faces.some((f) => f.id === p.faceId)), ); diff --git a/packages/extension/src/manager/panes/persona-editors.tsx b/packages/extension/src/manager/panes/persona-editors.tsx index 32524d3..f0e6994 100644 --- a/packages/extension/src/manager/panes/persona-editors.tsx +++ b/packages/extension/src/manager/panes/persona-editors.tsx @@ -6,9 +6,11 @@ // (`persona-setup.tsx`) compose the same forms rather than two drifting copies. // The pane (`persona.tsx`) decides which of the two to show. // -// Copy follows `design-docs/persona-vocabulary.md`: on screen it is a fact, a -// face, a context, and a persona that wears a face. The code keeps the spec's -// names (`attribute`, `profile`, `binding`) where they name wire records. +// Copy follows `design-docs/persona-vocabulary.md`: on screen it is an +// attribute, a face, a context, and a persona that wears a face. `attribute` +// is the one term the table does not translate — the screen says what the wire +// says — because the friendlier word it used to carry, `fact`, claimed a truth +// self-asserted values do not have. `profile` and `binding` still translate. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -67,7 +69,7 @@ export function Label({ children }: { children: React.ReactNode }) { * `value` really is arbitrary JSON — a string attribute's value is a string — * so this must not assume an object, and must not print `[object Object]` for * the one case that is. `undefined` is the answer to a metadata-only listing - * and says so, rather than rendering as an empty cell that reads like a fact + * and says so, rather than rendering as an empty cell that reads like an attribute * with no value. * * **Not exported, and that is the enforcement.** Every value this pane draws @@ -86,7 +88,7 @@ function formatValue(value: unknown): { text: string; withheld: boolean } { } /** - * A fact's value, hidden if its type says it should be, with a *Show* beside + * An attribute's value, hidden if its type says it should be, with a *Show* beside * it when it is. * * Every place this pane draws a value goes through here, because "wherever it @@ -102,12 +104,12 @@ function formatValue(value: unknown): { text: string; withheld: boolean } { * let a UI string in this component imply otherwise. * * **Reveal is per value and lives in this component.** Not lifted to the pane - * keyed by fact id, which would be a store of "things the operator has + * keyed by attribute id, which would be a store of "things the operator has * unhidden" — one that survives selection changes, outlives the card the person * was looking at, and is one refactor away from a *Show all*. Local state * cannot become that: it dies with the element, so leaving the pane, reloading * the console or navigating anywhere re-hides everything, and revealing the - * same fact in two places is two deliberate acts rather than one. + * same attribute in two places is two deliberate acts rather than one. */ export function FactValue({ type, @@ -132,7 +134,7 @@ export function FactValue({ style={{ // A masked value is drawn at full strength; `c.faint` is this pane's // word for "the agent did not send one". Greying the mask too would - // make a fact the holder has look exactly like a fact they do not, + // make an attribute the holder has look exactly like an attribute they do not, // and the difference is the one thing a hidden value must still say. color: withheld ? c.faint : c.text, ...(masked && !shown ? { fontFamily: font.mono, letterSpacing: 0.5 } : {}), @@ -143,8 +145,8 @@ export function FactValue({ {masked && ( {onCancel && ( + label="Delete" preview={async () => { // Asked again rather than read off the map, so the answer is // current at the moment of the decision. const current = await personaProfileList(managerSender, parties); - return current.filter((p) => p.entries.some((e) => "ref" in e && e.ref === fact.id)); + return current.filter((p) => p.entries.some((e) => "ref" in e && e.ref === attribute.id)); }} renderPreview={(faces) => ( <> - Deleting a fact cannot be undone. - {fact.type} + Deleting an attribute cannot be undone. + {attribute.type} {faces.length === 0 ? ( No face shows it, so nothing stops showing anything. ) : ( <> {faces.length} face(s) show it and will stop: {faces.map((f) => f.name).join(", ")} - Every persona wearing one of those shows one fact fewer from the next hand-over onwards. Nothing already shared is affected — that has left. + Every persona wearing one of those shows one attribute fewer from the next hand-over onwards. Nothing already shared is affected — that has left. )} @@ -874,7 +874,7 @@ function DetailStrip({ needsForce={(faces) => faces.length > 0} forceLabel="Remove it from those faces too" commit={async (force) => { - await personaAttributeDelete(managerSender, { ...parties, attributeId: fact.id, cascade: force }); + await personaAttributeDelete(managerSender, { ...parties, attributeId: attribute.id, cascade: force }); }} onDone={onChanged} /> @@ -896,11 +896,11 @@ function DetailStrip({ Face {face.name} - shows {face.entries.length} fact{face.entries.length === 1 ? "" : "s"}{face.preserved > 0 ? ` (${face.preserved} pinned, shown differently, or only here)` : ""} + shows {face.entries.length} attribute{face.entries.length === 1 ? "" : "s"}{face.preserved > 0 ? ` (${face.preserved} pinned, shown differently, or only here)` : ""} {col("Worn by", wearers.length === 0 ? ( - Nobody yet. No context receives these facts. + Nobody yet. No context receives these attributes. ) : ( <> {wearers.map((w) => ( @@ -947,7 +947,7 @@ function DetailStrip({ ) : ( ctx.personas.filter((p) => p.faceId).map((p) => ( - a copy of {p.faceName ?? "a face"}, worn by {personaLabel(p.did)} · {p.claimCount} fact{p.claimCount === 1 ? "" : "s"} + a copy of {p.faceName ?? "a face"}, worn by {personaLabel(p.did)} · {p.claimCount} attribute{p.claimCount === 1 ? "" : "s"} )) ))} @@ -982,7 +982,7 @@ function DetailStrip({ {col("Wears", p.faceId ? ( <> - {p.faceName ?? "a face"} · {p.claimCount} fact{p.claimCount === 1 ? "" : "s"} copied into this context + {p.faceName ?? "a face"} · {p.claimCount} attribute{p.claimCount === 1 ? "" : "s"} copied into this context {link && ( {link.wearers.filter((w) => !(w.did === p.did && w.contextId === p.contextId)).map((w) => `${personaLabel(w.did)} in ${labelOf(w.contextId)}`).join(", ")} wear{link.wearers.length === 2 ? "s" : ""} the same face — same person to anyone who sees both. @@ -990,7 +990,7 @@ function DetailStrip({ )} ) : ( - Nothing. This persona is known here but shows no facts. + Nothing. This persona is known here but shows no attributes. ))} {col("Last left", lastLeft((d) => d.personaDid === p.did && d.contextId === ctx.id))} diff --git a/packages/extension/src/manager/panes/persona-setup.tsx b/packages/extension/src/manager/panes/persona-setup.tsx index 4fff199..7b8a683 100644 --- a/packages/extension/src/manager/panes/persona-setup.tsx +++ b/packages/extension/src/manager/panes/persona-setup.tsx @@ -1,10 +1,10 @@ -// The first five minutes: a fact, a face, a context. +// The first five minutes: an attribute, a face, a context. // // Shown while the holder has no face yet — the one state in which the identity // map has nothing to draw and a stack of empty bands would answer "what do I // do?" with silence. Three steps, in the order the model runs, each saying what // it changes before it is done; and beside the second, the exact card someone -// would receive, updating as facts are ticked, because that card is the whole +// would receive, updating as attributes are ticked, because that card is the whole // point of a face and the thing a person cannot picture from a list of // checkboxes. // @@ -26,7 +26,7 @@ type Step = 1 | 2 | 3; function Stepper({ step, reachable, onGo }: { step: Step; reachable: (s: Step) => boolean; onGo: (s: Step) => void }) { const items: [Step, string][] = [ - [1, "Add a fact or two"], + [1, "Add an attribute or two"], [2, "Make a face"], [3, "Be known somewhere"], ]; @@ -82,10 +82,10 @@ function Stepper({ step, reachable, onGo }: { step: Step; reachable: (s: Step) = /** The card a stranger would receive from a face — the thing a tick list * cannot convey. Rendered from the pool directly: at this step nothing has * been pushed anywhere yet, so the pool is the only source. */ -function StrangerCard({ facts, faceName }: { facts: PoolAttribute[]; faceName: string }) { - const name = facts.find((f) => f.type === "name" || f.type.startsWith("name.")); - const rest = facts.filter((f) => f !== name); - const provable = facts.some((f) => f.provenance.kind === "credentialBacked" && !f.stale); +function StrangerCard({ attributes, faceName }: { attributes: PoolAttribute[]; faceName: string }) { + const name = attributes.find((f) => f.type === "name" || f.type.startsWith("name.")); + const rest = attributes.filter((f) => f !== name); + const provable = attributes.some((f) => f.provenance.kind === "credentialBacked" && !f.stale); return (
@@ -93,7 +93,7 @@ function StrangerCard({ facts, faceName }: { facts: PoolAttribute[]; faceName: s Exactly this, and nothing else. It changes as you tick.
- {facts.length === 0 ? ( + {attributes.length === 0 ? ( Nothing ticked. A stranger would receive an empty card. ) : ( <> @@ -118,7 +118,7 @@ function StrangerCard({ facts, faceName }: { facts: PoolAttribute[]; faceName: s )}
- {facts.length > 0 && !provable && ( + {attributes.length > 0 && !provable && ( Nothing here is proven. These are things you said. A context can pass them on, but cannot show anyone they are true. @@ -174,7 +174,7 @@ export function GuidedSetup({ {denied && {denied}} reachableStep(s, { facts: attributes.length, faces: profiles.length })} + reachable={(s) => reachableStep(s, { attributes: attributes.length, faces: profiles.length })} onGo={setStep} /> @@ -188,7 +188,7 @@ export function GuidedSetup({ onDone={onChanged} /> {attributes.length > 0 && ( - +
{attributes.map((a) => (
@@ -205,13 +205,13 @@ export function GuidedSetup({

- A fact is one thing about you, held once — a name, a phone number, a handle. Nothing you add here - is visible anywhere yet. Faces choose among facts; a context receives a copy only when a persona + An attribute is one thing about you, held once — a name, a phone number, a handle. Nothing you add here + is visible anywhere yet. Faces choose among attributes; a context receives a copy only when a persona there wears one.

Two or three are plenty to start. You can always add more, and a face only ever shows what you - ticked — facts you add later stay out until you say otherwise. + ticked — attributes you add later stay out until you say otherwise.

@@ -234,9 +234,9 @@ export function GuidedSetup({ setStep(3); }} onCancel={() => setStep(1)} - cancelLabel="Back — add more facts" + cancelLabel="Back — add more attributes" /> - +
)} @@ -275,11 +275,11 @@ export function GuidedSetup({

- A persona is the identifier a context knows you by. Putting a face on it copies the face's facts + A persona is the identifier a context knows you by. Putting a face on it copies the face's attributes down into that context — and only that context. Nothing reads back up.

- Edit a fact later and every copy updates. Wear the same face in two contexts and anyone who sees + Edit an attribute later and every copy updates. Wear the same face in two contexts and anyone who sees you in both knows you are one person — the map will show that link the moment it exists.

diff --git a/packages/extension/src/manager/panes/persona.tsx b/packages/extension/src/manager/panes/persona.tsx index befdad6..d791c98 100644 --- a/packages/extension/src/manager/panes/persona.tsx +++ b/packages/extension/src/manager/panes/persona.tsx @@ -5,13 +5,13 @@ // // Everywhere else in the console, a context is the compartment: keys, DIDs, // memory and app-state all live inside one. The attribute pool and the profiles -// over it do not. There is one person here, with one set of facts about +// over it do not. There is one person here, with one set of attributes about // themselves, and the contexts are the places they choose to be known. // // So the pane is a picture rather than a filtered list — the identity map in -// `persona-map.tsx`: facts on top, faces in the middle, contexts below, with the +// `persona-map.tsx`: attributes on top, faces in the middle, contexts below, with the // one-way line drawn between. And for the holder who has nothing yet, the -// guided setup in `persona-setup.tsx`: a fact, a face, a context, in the order +// guided setup in `persona-setup.tsx`: an attribute, a face, a context, in the order // the model runs. This file loads what both need and decides which to show. // // ## The boundary runs through the middle of it @@ -40,7 +40,7 @@ // // ## Words // -// On screen: a fact, a face, a context, a persona that wears a face. See +// On screen: an attribute, a face, a context, a persona that wears a face. See // `design-docs/persona-vocabulary.md`. In code the spec's names stay where they // name wire records. @@ -180,7 +180,7 @@ export function PersonaPane({ if (attributes.error) { return (
- +
); } @@ -191,7 +191,7 @@ export function PersonaPane({ } if (!attributes.data || !profiles.data) return ; - // No face means nothing for the map to draw. A holder with facts and no face + // No face means nothing for the map to draw. A holder with attributes and no face // lands on step two; one with nothing on step one. if (showGuide) { return ( diff --git a/packages/extension/src/manager/persona-flow.ts b/packages/extension/src/manager/persona-flow.ts index ec9715f..8c1e5f2 100644 --- a/packages/extension/src/manager/persona-flow.ts +++ b/packages/extension/src/manager/persona-flow.ts @@ -31,18 +31,18 @@ export function showsGuide(state: { faces: number | null; guiding: boolean; skip /** * Whether a holder can jump to a step of the guided setup. * - * Step one is always reachable — going back to add another fact is the most + * Step one is always reachable — going back to add another attribute is the most * ordinary thing a person wants here, and until the stepper answered clicks * the only route was a button labelled "Cancel", which reads as abandoning the * whole flow rather than stepping back one. * * The later two are reachable only once they have something to work on: a face - * cannot be composed out of no facts, and a persona cannot wear a face that + * cannot be composed out of no attributes, and a persona cannot wear a face that * does not exist. Reaching them empty would present a form whose every control * refuses, which is a worse answer than not offering the step. */ -export function reachableStep(step: 1 | 2 | 3, have: { facts: number; faces: number }): boolean { +export function reachableStep(step: 1 | 2 | 3, have: { attributes: number; faces: number }): boolean { if (step === 1) return true; - if (step === 2) return have.facts > 0; + if (step === 2) return have.attributes > 0; return have.faces > 0; } diff --git a/packages/extension/src/manager/profile-entries.ts b/packages/extension/src/manager/profile-entries.ts index 80c4348..dcaf0ee 100644 --- a/packages/extension/src/manager/profile-entries.ts +++ b/packages/extension/src/manager/profile-entries.ts @@ -19,7 +19,7 @@ // - **Seed the ticks from every `ref`** — pinned and overridden entries have // one too — and saving writes the same attribute twice: once live, from the // tick, and once pinned, from the entry that was carried through. One -// apparently unchanged edit, two entries for one fact. +// apparently unchanged edit, two entries for one attribute. // // So the split is by entry *form*, not by whether an entry has a `ref`. diff --git a/packages/extension/tests/manager-claim-sensitivity.test.mts b/packages/extension/tests/manager-claim-sensitivity.test.mts index c297068..75ccc30 100644 --- a/packages/extension/tests/manager-claim-sensitivity.test.mts +++ b/packages/extension/tests/manager-claim-sensitivity.test.mts @@ -122,8 +122,8 @@ test("emailLocal keeps the domain and nothing else, and refuses to guess", () => test("a hidden value is not an empty one", () => { // A mask that rendered as nothing — or as the pane's word for a value the - // agent did not send — would tell the operator that a fact they hold is a - // fact they do not. `masked` is what the pane draws differently on; it must + // agent did not send — would tell the operator that an attribute they hold is a + // attribute they do not. `masked` is what the pane draws differently on; it must // be set, and the text must not be blank. const hidden = maskedFact("gov.id.passport", "X1234567"); assert.equal(hidden.masked, true); diff --git a/packages/extension/tests/manager-holder-gate.test.mts b/packages/extension/tests/manager-holder-gate.test.mts index 7cbde4e..481805d 100644 --- a/packages/extension/tests/manager-holder-gate.test.mts +++ b/packages/extension/tests/manager-holder-gate.test.mts @@ -36,7 +36,11 @@ test("a context-scoped admin is cautioned, not refused", () => { test("the caution speaks the agreed vocabulary", () => { const note = holderGate(authority(["application"], ["work"]))!; - for (const banned of ["attribute", "profile", "binding", "disclosure", "provenance"]) { + // `attribute` came *off* this list: the table now uses the spec word on + // screen too, because the word it used to translate to — `fact` — asserted + // a truth the model cannot promise and already meant a verified policy input + // in `vtc-service`. See "Why not 'fact'" in the vocabulary guide. + for (const banned of ["fact", "profile", "binding", "disclosure", "provenance"]) { assert.ok( !note.toLowerCase().includes(banned), `"${banned}" is kept off the screen (design-docs/persona-vocabulary.md): ${note}`, diff --git a/packages/extension/tests/manager-identity-graph.test.mts b/packages/extension/tests/manager-identity-graph.test.mts index 4cc248a..4b36a0c 100644 --- a/packages/extension/tests/manager-identity-graph.test.mts +++ b/packages/extension/tests/manager-identity-graph.test.mts @@ -1,8 +1,8 @@ // The identity map's reach — what lights up, and in which direction. // -// A fact's reach is where it goes; a context's reach is what it holds. Get the -// direction wrong and the picture claims a context holds a fact it was never -// given, or that a fact reaches a context it does not — the second being the +// An attribute's reach is where it goes; a context's reach is what it holds. Get the +// direction wrong and the picture claims a context holds an attribute it was never +// given, or that an attribute reaches a context it does not — the second being the // holder concluding no linkage exists when one does. Every case here has a // paired positive, because a reach function that lights nothing satisfies every // "does not light" assertion there is. @@ -11,7 +11,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildGraph, - factReach, + attributeReach, personaKey, reachOf, type ContextInput, @@ -54,10 +54,10 @@ const CTXS = [ ]; const G = buildGraph(ATTRS, FACES, CTXS); -test("a face's fact ids are its live references only", () => { +test("a face's attribute ids are its live references only", () => { // A pinned entry still reaches a context, but it does not draw to the live - // card — that card would then read as "changes when the fact does". - assert.deepEqual(G.faces.find((f) => f.id === "F-pub")?.factIds, ["f-name"]); + // card — that card would then read as "changes when the attribute does". + assert.deepEqual(G.faces.find((f) => f.id === "F-pub")?.attributeIds, ["f-name"]); assert.equal(G.faces.find((f) => f.id === "F-pub")?.preserved, 1); }); @@ -69,8 +69,8 @@ test("a face worn by two personas is a link; a face worn by one is not", () => { ); }); -test("a fact reaches down: its faces, their wearers, their contexts", () => { - const r = reachOf(G, { kind: "fact", id: "f-phone" }); +test("an attribute reaches down: its faces, their wearers, their contexts", () => { + const r = reachOf(G, { kind: "attribute", id: "f-phone" }); assert.deepEqual([...r.faceIds], ["F-dev"]); assert.deepEqual([...r.contextIds].sort(), ["openvtc", "vta"]); assert.ok(r.personaKeys.has(personaKey("openvtc", "did:a"))); @@ -79,33 +79,33 @@ test("a fact reaches down: its faces, their wearers, their contexts", () => { assert.ok(!r.personaKeys.has(personaKey("vta", "did:c"))); }); -test("a fact in two faces reaches through both", () => { - const r = reachOf(G, { kind: "fact", id: "f-name" }); +test("an attribute in two faces reaches through both", () => { + const r = reachOf(G, { kind: "attribute", id: "f-name" }); assert.deepEqual([...r.faceIds].sort(), ["F-dev", "F-pub"]); }); -test("a fact only pinned, in a face nobody wears, reaches nowhere", () => { - const r = reachOf(G, { kind: "fact", id: "f-signal" }); +test("an attribute only pinned, in a face nobody wears, reaches nowhere", () => { + const r = reachOf(G, { kind: "attribute", id: "f-signal" }); assert.equal(r.faceIds.size, 0); assert.equal(r.contextIds.size, 0); }); -test("a context reaches up: its personas' faces and those faces' facts — not every fact", () => { +test("a context reaches up: its personas' faces and those faces' attributes — not every attribute", () => { const r = reachOf(G, { kind: "context", id: "openvtc" }); assert.deepEqual([...r.faceIds], ["F-dev"]); - assert.deepEqual([...r.factIds].sort(), ["f-name", "f-phone"]); - assert.ok(!r.factIds.has("f-signal"), "a context must not light a fact it was never given"); + assert.deepEqual([...r.attributeIds].sort(), ["f-name", "f-phone"]); + assert.ok(!r.attributeIds.has("f-signal"), "a context must not light an attribute it was never given"); }); test("a context where nobody is known lights only itself", () => { const r = reachOf(G, { kind: "context", id: "webvh" }); assert.deepEqual([...r.contextIds], ["webvh"]); - assert.equal(r.faceIds.size + r.factIds.size + r.personaKeys.size, 0); + assert.equal(r.faceIds.size + r.attributeIds.size + r.personaKeys.size, 0); }); test("a face reaches both ways", () => { const r = reachOf(G, { kind: "face", id: "F-dev" }); - assert.deepEqual([...r.factIds].sort(), ["f-name", "f-phone"]); + assert.deepEqual([...r.attributeIds].sort(), ["f-name", "f-phone"]); assert.deepEqual([...r.contextIds].sort(), ["openvtc", "vta"]); }); @@ -121,7 +121,7 @@ test("a persona lights its own context and face, not its neighbours'", () => { test("no selection lights nothing", () => { const r = reachOf(G, null); - assert.equal(r.factIds.size + r.faceIds.size + r.contextIds.size + r.personaKeys.size, 0); + assert.equal(r.attributeIds.size + r.faceIds.size + r.contextIds.size + r.personaKeys.size, 0); }); test("an unreadable context is carried as unreadable, not as empty", () => { @@ -132,8 +132,8 @@ test("an unreadable context is carried as unreadable, not as empty", () => { assert.equal(g.contexts[0]?.personas.length, 0); }); -test("factReach says where a fact goes in the words the strip uses", () => { - const r = factReach(G, "f-phone"); +test("attributeReach says where an attribute goes in the words the strip uses", () => { + const r = attributeReach(G, "f-phone"); assert.deepEqual(r.faces.map((f) => f.name), ["Developer"]); assert.deepEqual(r.contextIds.sort(), ["openvtc", "vta"]); assert.equal(r.wearers.length, 2); diff --git a/packages/extension/tests/manager-persona-guide.test.mts b/packages/extension/tests/manager-persona-guide.test.mts index 12c5d61..d2081ec 100644 --- a/packages/extension/tests/manager-persona-guide.test.mts +++ b/packages/extension/tests/manager-persona-guide.test.mts @@ -69,16 +69,16 @@ test("skipping is sticky, even when the last face goes", () => { import { reachableStep } from "../src/manager/persona-flow.ts"; -const reach = (step: 1 | 2 | 3, facts: number, faces: number) => reachableStep(step, { facts, faces }); +const reach = (step: 1 | 2 | 3, attributes: number, faces: number) => reachableStep(step, { attributes, faces }); test("step one is always reachable, including from a standing start", () => { assert.equal(reach(1, 0, 0), true); assert.equal(reach(1, 3, 1), true); }); -test("a face cannot be composed out of no facts", () => { +test("a face cannot be composed out of no attributes", () => { assert.equal(reach(2, 0, 0), false); - // The pair: one fact is enough to have something to tick. + // The pair: one attribute is enough to have something to tick. assert.equal(reach(2, 1, 0), true); }); @@ -90,5 +90,5 @@ test("a persona cannot wear a face that does not exist", () => { test("an unreachable step is refused rather than shown empty", () => { // Reaching a step with nothing to work on presents a form whose every // control refuses — a worse answer than not offering the step at all. - assert.equal(reach(2, 0, 1), false, "facts, not faces, are what step two needs"); + assert.equal(reach(2, 0, 1), false, "attributes, not faces, are what step two needs"); }); diff --git a/packages/extension/tests/manager-profile-entries.test.mts b/packages/extension/tests/manager-profile-entries.test.mts index f77186a..563108d 100644 --- a/packages/extension/tests/manager-profile-entries.test.mts +++ b/packages/extension/tests/manager-profile-entries.test.mts @@ -74,7 +74,7 @@ test("an unchanged edit round-trips the profile exactly", () => { test("no attribute is ever named twice", () => { // The duplicate bug: a pinned entry has a `ref`, so seeding the ticks from // every ref ticks its attribute AND carries the pinned entry through. The - // profile then presents one fact through two entries, from an edit in which + // profile then presents one attribute through two entries, from an edit in which // the operator touched nothing. const ticked = ["a-live", "a-pinned", "a-overridden"]; const out = composeEntries(ALL, ticked); diff --git a/packages/extension/tests/persona-pane.render.test.mts b/packages/extension/tests/persona-pane.render.test.mts index e47358e..d87960a 100644 --- a/packages/extension/tests/persona-pane.render.test.mts +++ b/packages/extension/tests/persona-pane.render.test.mts @@ -21,7 +21,7 @@ import { buildGraph } from "../src/manager/identity-graph.js"; const HOLDER = { session: { id: "s" }, roles: ["admin"], scopes: [] }; -const fact = (id: string, type: string, value: string) => ({ +const attribute = (id: string, type: string, value: string) => ({ attributeId: id, type, valueType: "string" as const, @@ -50,7 +50,7 @@ const context = (id: string, name: string) => ({ // The fixture is a registered token because these tests are about something // else; the masking of an unregistered one is asserted deliberately further // down. -const FACTS = [fact("f1", "name.legal", "Glenn Gore"), fact("f2", "phone.mobile", "+65 8262 2325")]; +const FACTS = [attribute("f1", "name.legal", "Glenn Gore"), attribute("f2", "phone.mobile", "+65 8262 2325")]; const CONTEXTS = [context("openvtc", "OpenVTC"), context("vta", "Verifiable Trust Agent")]; // ── The blank pane (#179) ─────────────────────────────────────────────────── @@ -77,12 +77,12 @@ test("making a face does not loop the renderer", async () => { { chrome: { runtime: { sendMessage: a.sendMessage } } }, ); - assert.match(ui.text(), /Make a face/, "the guide should be on step two with facts and no face"); + assert.match(ui.text(), /Make a face/, "the guide should be on step two with attributes and no face"); assert.match(ui.text(), /What a stranger would receive/); // Ticking is what drove the loop: the scrape ran, set state, and re-rendered. const boxes = ui.all('input[type="checkbox"]'); - assert.ok(boxes.length >= 2, `expected a tick per fact, saw ${boxes.length}`); + assert.ok(boxes.length >= 2, `expected a tick per attribute, saw ${boxes.length}`); await ui.check(boxes[0]!); await ui.check(boxes[1]!); @@ -96,7 +96,7 @@ test("making a face does not loop the renderer", async () => { test("the stranger card starts empty and says so", async () => { // The paired negative: an empty card is a real state with its own sentence, // not a blank area. Without this, the assertion above is satisfied by a card - // that shows every fact regardless of the ticks. + // that shows every attribute regardless of the ticks. const a = agent({}); const ui = await render( h(GuidedSetup, { @@ -112,7 +112,7 @@ test("the stranger card starts empty and says so", async () => { { chrome: { runtime: { sendMessage: a.sendMessage } } }, ); assert.match(ui.text(), /Nothing ticked/); - assert.doesNotMatch(ui.text(), /Glenn Gore/, "an unticked fact must not appear on the card"); + assert.doesNotMatch(ui.text(), /Glenn Gore/, "an unticked attribute must not appear on the card"); await ui.unmount(); }); @@ -137,7 +137,7 @@ test("a completed step in the stepper is a way back to it", async () => { ); assert.match(ui.text(), /Make a face/); - const backToOne = ui.byText('[role="button"]', "Add a fact or two"); + const backToOne = ui.byText('[role="button"]', "Add an attribute or two"); assert.ok(backToOne, "the completed first step should be pressable"); await ui.click(backToOne!); assert.match(ui.text(), /Why start here/, "clicking step one should return to it"); @@ -330,7 +330,7 @@ test("a context the agent would not answer for is not folded away as empty", asy // ── Values a shoulder should not collect (#185) ───────────────────────────── // -// The console draws the holder's own facts, so a passport number sits on screen +// The console draws the holder's own attributes, so a passport number sits on screen // for as long as the pane is open — through a screen share, a screenshot, and // anyone walking past. Hiding it is worth doing and is worth being precise // about what it is: the value was fetched before any of this ran, so this @@ -343,10 +343,10 @@ test("a context the agent would not answer for is not folded away as empty", asy // only a render sees it. const SECRETS = [ - fact("f1", "name.legal", "Glenn Gore"), - fact("f2", "phone.mobile", "+65 8262 2325"), - fact("f3", "gov.id.passport", "X1234567"), - fact("f4", "x:acme.badge", "BADGE-99"), + attribute("f1", "name.legal", "Glenn Gore"), + attribute("f2", "phone.mobile", "+65 8262 2325"), + attribute("f3", "gov.id.passport", "X1234567"), + attribute("f4", "x:acme.badge", "BADGE-99"), ]; /** The map, mounted over `SECRETS` with nothing selected. */ @@ -385,7 +385,7 @@ test("a sensitive value is not on the map until it is asked for", async () => { // A hidden value is drawn, not omitted. Rendering nothing — or rendering the // pane's phrase for a value the agent did not send — would say the holder - // does not have a fact they do have. + // does not have an attribute they do have. assert.match(screen, /••••/, "a hidden value still occupies its row"); assert.doesNotMatch(screen, /not requested/, "hidden is not the same state as absent"); assert.match(screen, /•••• 25/, "the tail the holder recognises their own number by survives"); @@ -396,7 +396,7 @@ test("a sensitive value is not on the map until it is asked for", async () => { test("Show reveals one value, and only the one that was pressed", async () => { const ui = await mapOverSecrets(); const controls = shows(ui); - assert.equal(controls.length, 3, "one control per hidden fact, and never a single global one"); + assert.equal(controls.length, 3, "one control per hidden attribute, and never a single global one"); await ui.click(controls[0]!); const screen = ui.text(); @@ -404,11 +404,11 @@ test("Show reveals one value, and only the one that was pressed", async () => { assert.doesNotMatch(screen, /X1234567/, "and reveals nothing else"); assert.doesNotMatch(screen, /BADGE-99/); - // The card underneath is a click target — it selects the fact and opens the + // The card underneath is a click target — it selects the attribute and opens the // strip below the map. Revealing a value must not do that too: the operator // pressed Show, and the screen they were reading changing under them is the // symptom of a missing `stopPropagation`. - assert.doesNotMatch(screen, /Last left/, "revealing a value must not also select the fact"); + assert.doesNotMatch(screen, /Last left/, "revealing a value must not also select the attribute"); await ui.unmount(); });