From d74636fc4298bd22622534cb637a2dabe9468a9a Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 12:57:23 +0200 Subject: [PATCH] feat(persona): the holder decides what happens to a value, and "not decided" is an answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sensitivity` and `release` are per-attribute members the holder sets: how carefully a value is shown to them, and what it takes to let it leave. Absent means they decided nothing and the claim-type registry answers. The console read neither and wrote neither, so the only visibility rule in force was a vendored table — which is why an attribute typed `profile.github` was masked and withheld with no way to say otherwise. The editor now asks both questions, each with three options, because "let your agent decide" is a state and not a synonym for the registry's current answer. Choosing it writes the member **absent**: `persona/attribute/put` warns that sending back a *resolved* value freezes the attribute to today's table, so a later tightening of the registry would protect every new attribute and leave this one exposed. That same replace semantics had already broken something quietly. A put replaces the whole record, and the editor never mentioned these members — so every save cleared whatever the holder had decided, and nothing in the response said so. The editor now reads them off the record it loaded and sends them back untouched. - `AttributePutParams.sensitivity` / `.release` in `@openvtc/pnm-core/admin`, spread conditionally, with the reason for the conditional written where the next person will edit it. - `treatmentFor` in `claim-sensitivity.ts` applies the holder's decision over the registry's and reports which spoke, so a pane can say "you decided" without putting the registry's words in the holder's mouth. `treatmentOf` stays the registry-only answer, exactly where its docstring said this rule would go. - One narrow exception, and it is why the feature does anything visible: a declared token keeps the registry's mask (§3.3 — the axes are independent, so `phone.mobile` stays `•• 25` however it is marked), while an unregistered one's mask follows the holder. `UNREGISTERED` is a single conservative answer covering both axes *because nobody had reasoned about the token*; the holder deciding is the decision it stood in for. Without this, marking your own `profile.github` as showable still drew four bullets. - `maskedFact` now gates on the mask style rather than on `sensitivity`. They are independent per §3.3, and gating on `high` meant `email.*` (`normal` / `emailLocal`) was called hidden by `isSensitive` and drawn in full by the renderer — a promised *Show* button that never appeared. - The map carries both decisions through to `FactValue`, and the detail strip names them: "You decided: kept back until you ask · approved again every time it leaves." Two rows added to `design-docs/persona-vocabulary.md` for the on-screen words — *showing it to you*, *letting it leave*, *let your agent decide* — per that table's own rule about adding copy. 13 new tests (352 extension, 552 core), `tsc -b` clean, `npm run build` clean. Signed-off-by: Glenn Gore --- CLAUDE.md | 33 +++++ packages/core/src/admin/persona.ts | 51 +++++++ packages/core/tests/admin.persona.mjs | 52 +++++++ .../src/manager/claim-sensitivity.ts | 111 ++++++++++++-- .../extension/src/manager/identity-graph.ts | 11 ++ .../src/manager/panes/persona-editors.tsx | 111 +++++++++++++- .../src/manager/panes/persona-map.tsx | 28 +++- .../tests/manager-claim-sensitivity.test.mts | 76 ++++++++++ .../tests/persona-pane.render.test.mts | 135 +++++++++++++++++- 9 files changed, 587 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 258f2cc..368f741 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -412,6 +412,39 @@ version with extra steps); masking a withheld placeholder, which claims a value is being held back when none arrived; matching a reveal by position rather than `attributeId`; or a *Hide* that only covers what a press fetched. +**The holder outranks the registry, and "not decided" is a state.** +`sensitivity` and `release` are per-attribute members that are present **only** +where the holder chose one; absent means the claim-type registry answers. +`treatmentFor` applies the first over the second and reports which spoke, so a +pane can say *you decided* without ever putting the registry's answer in the +holder's mouth. The editor offers three options per question, and *let your +agent decide* writes the member **absent** — never the resolved default, because +`persona/attribute/put` is a **replace** and freezing today's answer means a +later tightening of the registry protects every new attribute and leaves this +one exposed. The same replace semantics are why an editor must send back the +decisions it loaded: omitting them silently cleared the holder's gate on every +save, and nothing in the response said so. + +**One narrow exception, and it is the reason the feature works at all.** A +holder's `sensitivity` moves that axis only — a declared token keeps the +registry's mask (§3.3: the axes are independent, and `phone.mobile` stays +`•• 25` however the holder marks it). For an **unregistered** token there is no +such statement to respect: `UNREGISTERED` is one conservative answer covering +both axes precisely because nobody had reasoned about the token, so the holder +deciding is the decision it stood in for, and the mask follows them. Without it, +marking your own `profile.github` as showable still drew four bullets, by a rule +justified only by nobody having looked. + +**Masking follows the mask style, not the sensitivity.** They are independent +per §3.3; `maskedFact` gated on `high` anyway, so `email.*` (`normal` / +`emailLocal`) was called hidden by `isSensitive` and drawn in full by the +renderer — a promised *Show* button that never appeared. + +**What breaks it:** writing a resolved default into `sensitivity` or `release`; +an editor that omits them and so clears them; treating absent as `normal` +(`treatmentFor`'s `source` is the difference); extending the unregistered-mask +rule to declared tokens; or gating a mask on `sensitivity` again. + **A `release: stepUp` disclosure is refused, and the refusal is returned rather than thrown.** `payment.*` and `gov.*` resolve to `release: stepUp` in the registry, so the agent refuses `persona/disclosure/present` until it holds a diff --git a/packages/core/src/admin/persona.ts b/packages/core/src/admin/persona.ts index 91c82d8..990d176 100644 --- a/packages/core/src/admin/persona.ts +++ b/packages/core/src/admin/persona.ts @@ -124,6 +124,15 @@ export type PoolProfileEntry = PoolProfile["entries"][number]; export type AttributeProvenance = PersonaAttributePutPayload["provenance"]; /** What the value IS — the schema's own five. */ export type AttributeValueType = PersonaAttributePutPayload["valueType"]; +/** + * The holder's own answer on how carefully a value is shown to them, where they + * gave one. `undefined` on a record is not a third value — it says the holder + * decided nothing and the claim-type registry answers instead. + */ +export type AttributeSensitivity = NonNullable; +/** The holder's own answer on what it takes to let a value leave, where they + * gave one. Absence means the same as it does for {@link AttributeSensitivity}. */ +export type AttributeRelease = NonNullable; /** One place the holder's identities link, and what can be done about it. */ export type CorrelationFinding = PersonaCorrelationAnalyzeResponsePayload["findings"][number]; /** One record of something that left, and to whom. */ @@ -248,6 +257,33 @@ export interface AttributePutParams extends PersonaHolderParams { /** The holder's own name for it — "work mobile", "the flat". */ label?: string; provenance: AttributeProvenance; + /** + * How carefully this value is shown to the holder — **their** decision, not + * the registry's. + * + * **Absence is the meaningful state and must be preserved.** Omitted records + * that the holder decided nothing, so every consumer resolves it from the + * claim-type registry; sending back a value that was merely *resolved* pins + * the attribute to today's table, and a later tightening of the registry + * would then protect every new attribute and leave this one exposed. The + * specification says so in as many words. Send this only where a holder + * chose, and omit it to return the attribute to the registry's answer. + * + * `high` also governs the read path: a listing that did not set + * `includeSensitive` is answered without this value. + */ + sensitivity?: AttributeSensitivity; + /** + * What it takes to let this value LEAVE — again the holder's decision, with + * the same meaning for absence. + * + * Distinct from `sensitivity`, which governs showing it to the holder. + * `consent` is the ordinary gate: a preview renders what would leave and the + * present releases it, so a human sees it once. `stepUp` additionally + * requires a fresh authentication bound to THAT preview — not to the session, + * because "each time" bound to a session degrades into "once per login". + */ + release?: AttributeRelease; /** Optimistic concurrency: the attribute must be at exactly this version. * The agent's conflict rejection carries its own view of the record, so a * caller does not have to re-read to find out what it lost to. */ @@ -257,6 +293,14 @@ export interface AttributePutParams extends PersonaHolderParams { /** * Create or replace one attribute. * + * **A put replaces the whole record**, so every member a caller omits is a + * member the attribute loses. That is the intended way to clear `sensitivity` + * or `release` back to the registry's answer, and it is also the way an editor + * that simply never mentioned them wiped a holder's decision on every save — + * silently, because the response says nothing about what was dropped. An editor + * must read them off the record it loaded and send them back unless the person + * changed them. + * * The response's `correlation` is **advisory and computed after the write**. * The agent does not refuse on correlation grounds — the holder decides whether * two of their identities may share a value, and a maintainer that vetoed it @@ -274,6 +318,13 @@ export async function personaAttributePut( provenance: params.provenance, ...(params.attributeId !== undefined ? { attributeId: params.attributeId } : {}), ...(params.label !== undefined ? { label: params.label } : {}), + // Both spread conditionally, which is the whole of "absent means the holder + // decided nothing". A `sensitivity: undefined` member present in the object + // would serialise away to the same wire document, but the shape of this + // code is what a reader checks, and a put that always names them is one + // edit away from freezing a resolved default into the record. + ...(params.sensitivity !== undefined ? { sensitivity: params.sensitivity } : {}), + ...(params.release !== undefined ? { release: params.release } : {}), ...(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 c7b39fe..82c4afe 100644 --- a/packages/core/tests/admin.persona.mjs +++ b/packages/core/tests/admin.persona.mjs @@ -359,3 +359,55 @@ test("an empty list is a real answer and is not null", () => { // able to notice and say rather than have flattened into "we don't know". assert.deepEqual(personasBlockingDelete({ personaDids: [] }), []); }); + +// ── The holder's own decisions travel, and absence is one of them ─────────── +// +// `sensitivity` and `release` are OPTIONAL on the wire and their absence is +// load-bearing: it records that the holder decided nothing, so every consumer +// resolves from the claim-type registry. Sending a resolved value back would +// freeze the attribute to today's table — a later tightening would protect +// every new attribute and leave this one exposed — which is why these are +// spread conditionally rather than always named. + +test("a decision the holder made is carried on the put", async () => { + const r = recorder({ attributeId: "01J", version: 2, created: false, updatedAt: "x" }); + await personaAttributePut(r, { + ...PARTIES, + type: "profile.github", + valueType: "string", + value: "octocat", + provenance: { kind: "selfAsserted" }, + sensitivity: "normal", + release: "stepUp", + }); + const { payload } = r.sent[0].envelope; + assert.equal(payload.sensitivity, "normal"); + assert.equal(payload.release, "stepUp"); +}); + +test("a decision the holder did not make is absent, not resolved", async () => { + const r = recorder({ attributeId: "01J", version: 1, created: true, updatedAt: "x" }); + await personaAttributePut(r, { + ...PARTIES, + type: "phone.mobile", + valueType: "string", + value: "+65 8262 2325", + provenance: { kind: "selfAsserted" }, + }); + const { payload } = r.sent[0].envelope; + assert.ok(!("sensitivity" in payload), "omitted means the registry answers"); + assert.ok(!("release" in payload), "omitted means the registry answers"); +}); + +test("a values listing can ask for the sensitive ones, and does not by default", async () => { + // The half of sensitivity that is not cosmetic: without this member the agent + // returns the metadata of every `sensitivity: high` attribute and the + // plaintext of none. + const r = recorder({ attributes: [] }); + await personaAttributeList(r, { ...PARTIES, includeValues: true, includeSensitive: true }); + assert.equal(r.sent[0].envelope.payload.includeSensitive, true); + + const plain = recorder({ attributes: [] }); + await personaAttributeList(plain, { ...PARTIES, includeValues: true }); + assert.ok(!("includeSensitive" in plain.sent[0].envelope.payload)); +}); diff --git a/packages/extension/src/manager/claim-sensitivity.ts b/packages/extension/src/manager/claim-sensitivity.ts index 2a18195..3cce416 100644 --- a/packages/extension/src/manager/claim-sensitivity.ts +++ b/packages/extension/src/manager/claim-sensitivity.ts @@ -142,14 +142,14 @@ export const REGISTERED_ROOTS: ReadonlySet = new Set( ); /** - * How this type's values are treated — `CLAIM-TYPES.md` §4, minus the rule - * this console cannot take part in. + * How this **type's** values are treated — `CLAIM-TYPES.md` §4, minus rule 1, + * which is about one attribute rather than a type. * * §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 attributes and - * a UI that wants to explain the difference needs both. + * wins over the registry. It stays *above* this call, in `treatmentFor` below — + * "the holder decided" and "the registry says" are two different claims about + * one value, and a UI that wants to explain the difference needs both. This + * function is only ever the second of them. * * **The prefix walk is rule 3, and it only ever tightens.** An unregistered * token takes the *more protective* of its longest registered prefix and the @@ -293,16 +293,97 @@ function emailLocal(text: string): string { * * `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 - * attribute they hold is an attribute they do not. + * greys a mask the way it greys a value the agent never sent has told the + * operator that an attribute they hold is an attribute they do not. + * + * **The mask style decides, not the sensitivity.** §3.3 made the two axes + * independent — `high` means *withheld from a listing that did not ask*, a mask + * style means *not shown in the clear* — and this function used to gate on + * `high` anyway. `email.*` is the case that showed it: `normal`/`emailLocal`, + * so `isSensitive` called it hidden and the strip promised it was "hidden until + * you press Show", while the address sat on screen in full with no button to + * press. Two functions, one question, two answers. + * + * `override` is the holder's own `sensitivity`, where they set one — see + * `treatmentFor`, which is where that decision is applied and where the reason + * an unregistered token's mask follows it is written down. */ -export function maskedFact(type: string, text: string): { text: string; masked: boolean } { - const treatment = treatmentOf(type); - if (treatment.sensitivity !== "high") return { text, masked: false }; +export function maskedFact( + type: string, + text: string, + override?: Sensitivity | undefined, +): { text: string; masked: boolean } { + const { treatment } = treatmentFor(type, override); + if (treatment.mask === "none") return { text, masked: false }; const masked = maskText(text, treatment.mask); - // A style of `none` on a `high` type would mask nothing while claiming to. - // No such entry exists; if one is added, the honest answer is to draw the - // value plainly and offer no control, rather than a *Show* button that - // changes nothing. + // A mask that changed nothing would claim to hide while hiding nothing — the + // honest answer is to draw the value plainly and offer no control, rather + // than a *Show* button that does not change what is on screen. return { text: masked, masked: masked !== text }; } + +/** + * How this attribute's value is treated, with the holder's own decision applied + * over the registry's — `CLAIM-TYPES.md` §4 rule 1. + * + * `sensitivity` on an attribute record is present **only** where the holder + * chose; absent means they chose nothing and the registry answers, which is why + * this takes the override rather than a resolved value. The two are kept apart + * all the way to the screen: `source` says which is speaking, so a pane can say + * "you decided" instead of presenting the registry's answer as the holder's. + * + * **Only the axis the holder decided moves — with one exception, and it is the + * one worth reading.** For a token the registry *declares*, the mask is a + * separate statement it has made (§3.3: the axes are independent — an email is + * worth hiding from the person behind you without being worth withholding from + * every listing), so deciding sensitivity leaves it alone. A holder who marks + * `phone.mobile` unsensitive gets the value delivered and still sees `•• 25` + * until they press Show. + * + * For an **unregistered** token there is no such statement. `UNREGISTERED` is + * one conservative answer standing in for a decision nobody made — §4 rule 3's + * own reasoning, "a vocabulary the registry has never seen is exactly the one + * nobody has reasoned about" — so when the holder decides, the thing it stood + * in for has arrived and the mask follows their answer instead of the floor. + * Without this, someone who marked their own `profile.github` as not sensitive + * would still be shown four bullets and told to press a button, by a rule whose + * only justification was that nobody had looked at it yet. + * + * The narrowness is the point: a *declared* token's mask never moves, because + * there the registry has an opinion and this console does not overrule it. + */ +export function treatmentFor( + type: string, + override?: Sensitivity | undefined, +): { treatment: ClaimTreatment; source: "holder" | "registry" } { + const registry = treatmentOf(type); + if (override === undefined) return { treatment: registry, source: "registry" }; + return { + treatment: { + sensitivity: override, + mask: isRegistered(type) ? registry.mask : override === "high" ? "full" : "none", + }, + source: "holder", + }; +} + +/** Whether the registry declares this token, or a family it belongs to — the + * same walk `treatmentOf` performs, asked as a question. An `x:` token is + * never registered, per §4's last rule. */ +function isRegistered(type: string): boolean { + if (type.startsWith("x:")) return false; + if (REGISTERED[type]) return true; + const segments = type.split("."); + for (let i = segments.length - 1; i > 0; i--) { + if (REGISTERED[segments.slice(0, i).join(".")]) return true; + } + return false; +} + +/** Whether this attribute's value is hidden until asked for, the holder's own + * decision included. The `type`-only {@link isSensitive} is the registry's + * answer alone and stays that way — a call site holding a whole attribute + * should use this one. */ +export function isSensitiveFor(type: string, override?: Sensitivity | undefined): boolean { + return treatmentFor(type, override).treatment.mask !== "none"; +} diff --git a/packages/extension/src/manager/identity-graph.ts b/packages/extension/src/manager/identity-graph.ts index 6fd8943..1345cdd 100644 --- a/packages/extension/src/manager/identity-graph.ts +++ b/packages/extension/src/manager/identity-graph.ts @@ -20,6 +20,15 @@ export interface AttributeNode { type: string; label?: string | undefined; value: unknown; + /** + * The holder's own decisions about this value, carried only where they made + * one. Absent is not a third value: it says the claim-type registry answers, + * and a map that filled it in with the resolved default would be presenting + * the registry's answer as the holder's. `treatmentFor` is where the two are + * combined, and it needs to be able to tell them apart. + */ + sensitivity?: PoolAttribute["sensitivity"]; + release?: PoolAttribute["release"]; provenance: PoolAttribute["provenance"]; stale: boolean; staleReason?: string | undefined; @@ -116,6 +125,8 @@ export function buildGraph( type: a.type, label: a.label, value: a.value, + sensitivity: a.sensitivity, + release: a.release, provenance: a.provenance, stale: a.stale === true, staleReason: a.staleReason, diff --git a/packages/extension/src/manager/panes/persona-editors.tsx b/packages/extension/src/manager/panes/persona-editors.tsx index 01ad3cf..d16590b 100644 --- a/packages/extension/src/manager/panes/persona-editors.tsx +++ b/packages/extension/src/manager/panes/persona-editors.tsx @@ -42,7 +42,7 @@ import { useAsync } from "../use-async.js"; import { contextHeading, formatInstant } from "../format.js"; import { type Authority, type Parties } from "../use-vta.js"; import { holderGate } from "../holder-gate.js"; -import { maskedFact } from "../claim-sensitivity.js"; +import { maskedFact, treatmentFor, type Sensitivity } from "../claim-sensitivity.js"; import { composeEntries, lockedRefs, preservedEntries, tickedFrom } from "../profile-entries.js"; import { personaCandidates } from "../persona-candidates.js"; @@ -117,12 +117,22 @@ function formatValue(value: unknown): { text: string; withheld: boolean } { export function FactValue({ type, value, + sensitivity, style, textStyle, reveal, }: { type: string; value: unknown; + /** + * The holder's own decision on this attribute, where they made one. Absent + * means they did not, and the claim-type registry answers — the two are + * different claims and `treatmentFor` keeps them apart. A surface that has a + * whole attribute in hand should pass this; one that has only a type (a claim + * inside a face, read from a binding) has nothing to pass and gets the + * registry's answer, which is correct for it. + */ + sensitivity?: Sensitivity | undefined; /** Typography for the row — applied to the wrapper, so the control inherits it. */ style?: React.CSSProperties; /** Wrapping or truncation for the value itself, which differs per surface. */ @@ -143,7 +153,7 @@ export function FactValue({ const [refused, setRefused] = useState(null); const { text, withheld } = formatValue(revealed ? revealed.value : value); - const { text: hidden, masked } = maskedFact(type, text); + const { text: hidden, masked } = maskedFact(type, text, sensitivity); // **A withheld value is never masked.** The mask is a statement that a value // is here and is being kept off the screen; drawing it over "not on this @@ -312,6 +322,41 @@ export function rawValue(value: unknown): string { } +/** + * The holder's own answer, or the registry's. + * + * A ` onChange(e.target.value as Value | "")}> + + {options.map((o) => ( + + ))} + + ); +} + export function AttributeEditor({ parties, authority, @@ -336,6 +381,12 @@ export function AttributeEditor({ const [label, setLabel] = useState(existing?.label ?? ""); const [valueType, setValueType] = useState(existing?.valueType ?? "string"); const [raw, setRaw] = useState(rawValue(existing?.value)); + // Both start from the record, so an edit that touches neither sends back what + // was there. A put REPLACES the attribute, so an editor that simply never + // mentioned these cleared the holder's decision on every save — silently, + // because nothing in the response says a member was dropped. + const [sensitivity, setSensitivity] = useState(existing?.sensitivity ?? ""); + const [release, setRelease] = useState<"consent" | "stepUp" | "">(existing?.release ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [pending, setPending] = useState(null); @@ -382,6 +433,11 @@ export function AttributeEditor({ value: parsed.value, provenance, ...(label.trim() ? { label: label.trim() } : {}), + // Omitted where the person left it to the agent, which is how a + // decision is *cleared* as well as how one is never made. Never the + // resolved default — see `Decision` above. + ...(sensitivity ? { sensitivity } : {}), + ...(release ? { release } : {}), ...(existing ? { attributeId: existing.attributeId, expectedVersion: existing.version } : {}), }); // Advisory, and it arrives with the write rather than before it: the @@ -400,7 +456,7 @@ export function AttributeEditor({ ); setBusy(false); if (ok && linked === null) onDone(); - }, [parties, type, label, valueType, raw, provenance, existing, onDone]); + }, [parties, type, label, valueType, raw, provenance, sensitivity, release, existing, onDone]); return ( + {/* ── What happens to this value, decided here ── + Two questions, not one, because they are answered by different + things at different moments: the first is about your own screen and + about what your agent hands this page, the second is about a value + leaving for somebody else. The claim-type registry answers both + unless you say otherwise, and leaving them alone is a real answer — + it means "keep following the registry", so a later tightening + reaches this attribute too. */} + + + + {derived && ( This attribute is backed by a credential, and that is kept as it stands — diff --git a/packages/extension/src/manager/panes/persona-map.tsx b/packages/extension/src/manager/panes/persona-map.tsx index 4fb22c6..e901e2f 100644 --- a/packages/extension/src/manager/panes/persona-map.tsx +++ b/packages/extension/src/manager/panes/persona-map.tsx @@ -60,7 +60,7 @@ import { ResolvedProfile, } from "./persona-editors.js"; import { holderGate } from "../holder-gate.js"; -import { isSensitive } from "../claim-sensitivity.js"; +import { isSensitiveFor } from "../claim-sensitivity.js"; import type { RevealTarget } from "../reveal-value.js"; // ── Words for what the agent knows ────────────────────────────────────────── @@ -612,6 +612,7 @@ export function IdentityMap({ onReveal({ attributeId: f.id, type: f.type })} style={{ minWidth: 0, overflow: "hidden" }} textStyle={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} @@ -975,6 +976,7 @@ function DetailStrip({ onReveal({ attributeId: attribute.id, type: attribute.type })} style={{ fontSize: t.md, fontWeight: 640 }} textStyle={{ wordBreak: "break-word" }} @@ -989,13 +991,35 @@ function DetailStrip({ withheld is not in this page at all, and *Show* is the request for it. Saying the first about the second is what the strip did before, and it was the one claim it must never make wrongly. */} - {isSensitive(attribute.type) && ( + {isSensitiveFor(attribute.type, attribute.sensitivity) && ( {attribute.value === undefined ? "Your agent has not sent this value to this page. Show asks it for this one." : "Hidden until you press Show — that is about who can see your screen. Your agent has already sent this value here."} )} + {/* Whose answer this is. Absent means the registry's, and saying + "you decided" over the registry's answer would be the console + putting words in the holder's mouth about their own data. */} + {(attribute.sensitivity || attribute.release) && ( + + You decided:{" "} + {[ + attribute.sensitivity === "high" + ? "kept back until you ask" + : attribute.sensitivity === "normal" + ? "shown" + : null, + attribute.release === "stepUp" + ? "approved again every time it leaves" + : attribute.release === "consent" + ? "approved once before it leaves" + : null, + ] + .filter(Boolean) + .join(" · ")} + + )} {attribute.stale && Can no longer be proven ({attribute.staleReason ?? "stale"}).} {col("Reach", reach.faces.length === 0 ? ( diff --git a/packages/extension/tests/manager-claim-sensitivity.test.mts b/packages/extension/tests/manager-claim-sensitivity.test.mts index 75ccc30..4f98287 100644 --- a/packages/extension/tests/manager-claim-sensitivity.test.mts +++ b/packages/extension/tests/manager-claim-sensitivity.test.mts @@ -20,6 +20,8 @@ import { maskText, maskedFact, treatmentOf, + treatmentFor, + isSensitiveFor, } from "../src/manager/claim-sensitivity.ts"; // ── The registry's own answers ────────────────────────────────────────────── @@ -197,3 +199,77 @@ test("a value with no mask style is not hidden", () => { assert.ok(!isSensitive("account.handle")); assert.ok(!isSensitive("name.display")); }); + +// ── The holder's own decision (§4 rule 1) ─────────────────────────────────── +// +// `sensitivity` on an attribute record is present only where the holder chose. +// Absent is not a third value — it says the registry answers — and the two must +// stay distinguishable all the way to the screen, because "you decided" and +// "your agent's table says" are different claims to put in front of someone +// about their own data. + +test("no decision leaves the registry answering, and says so", () => { + const { treatment, source } = treatmentFor("phone.mobile"); + assert.deepEqual(treatment, treatmentOf("phone.mobile")); + assert.equal(source, "registry"); +}); + +test("a decision wins over the registry, and says whose it is", () => { + const { treatment, source } = treatmentFor("phone.mobile", "normal"); + assert.equal(treatment.sensitivity, "normal", "the holder outranks the table"); + assert.equal(source, "holder"); +}); + +test("a declared token keeps the registry's mask, whatever the holder decided", () => { + // §3.3: the axes are independent. The registry has an opinion about how a + // phone number is drawn, and this console does not overrule it — deciding it + // is not worth withholding is not the same as deciding it should be legible + // over a shoulder. + assert.equal(treatmentFor("phone.mobile", "normal").treatment.mask, "last2"); + assert.equal(treatmentFor("name.legal", "high").treatment.mask, "none"); +}); + +test("an unregistered token's mask follows the holder, because the floor was standing in for them", () => { + // `UNREGISTERED` is one conservative answer covering both axes, chosen + // because nobody had reasoned about the token (§4 rule 3's own words). The + // holder deciding is the decision it stood in for. Without this, someone who + // marked their own `profile.github` as not sensitive would still be shown + // four bullets by a rule justified only by nobody having looked. + assert.equal(treatmentFor("profile.github").treatment.mask, "full"); + assert.equal(treatmentFor("profile.github", "normal").treatment.mask, "none"); + assert.equal(treatmentFor("profile.github", "high").treatment.mask, "full"); + assert.equal(maskedFact("profile.github", "octocat", "normal").text, "octocat"); + assert.equal(maskedFact("profile.github", "octocat", "normal").masked, false); + assert.equal(maskedFact("profile.github", "octocat").masked, true); +}); + +test("an x: token is unregistered here too, so the same rule reaches it", () => { + assert.equal(treatmentFor("x:payment.card", "normal").treatment.mask, "none"); + // …but it does not become a registered token: no override, no change. + assert.equal(treatmentFor("x:payment.card").treatment.mask, "full"); +}); + +test("a family member invented under a gated family keeps the family's mask", () => { + // `payment.giftCard` walks up to `payment`, which the registry declares. A + // holder deciding it is not worth withholding does not make a gated family's + // mask disappear. + assert.equal(treatmentFor("payment.giftCard", "normal").treatment.mask, "full"); +}); + +// ── The mask style decides, not the sensitivity ───────────────────────────── + +test("an email is masked, which is what the registry asked for all along", () => { + // `email.*` is `normal`/`emailLocal`: worth hiding from the person behind + // you, not worth withholding from every listing. `maskedFact` used to gate on + // `high` and drew it in full, while `isSensitive` called it hidden — so the + // strip promised a Show button that was never rendered. + const { text, masked } = maskedFact("email.personal", "glenn.gore@example.com"); + assert.equal(text, "g•••@example.com"); + assert.equal(masked, true); + assert.equal(isSensitiveFor("email.personal"), true, "the two answers agree now"); +}); + +test("a type with no mask style is drawn plainly and offers no control", () => { + assert.equal(maskedFact("name.legal", "Glenn Gore").masked, false); + assert.equal(isSensitiveFor("name.legal"), false); +}); diff --git a/packages/extension/tests/persona-pane.render.test.mts b/packages/extension/tests/persona-pane.render.test.mts index 3f2651a..512aea2 100644 --- a/packages/extension/tests/persona-pane.render.test.mts +++ b/packages/extension/tests/persona-pane.render.test.mts @@ -16,7 +16,7 @@ import assert from "node:assert/strict"; import { agent, h, render, PARTIES } from "./harness/dom.mjs"; import { GuidedSetup } from "../src/manager/panes/persona-setup.js"; import { IdentityMap } from "../src/manager/panes/persona-map.js"; -import { BindingForm } from "../src/manager/panes/persona-editors.js"; +import { AttributeEditor, BindingForm } from "../src/manager/panes/persona-editors.js"; import { buildGraph } from "../src/manager/identity-graph.js"; const HOLDER = { session: { id: "s" }, roles: ["admin"], scopes: [] }; @@ -641,3 +641,136 @@ test("a value the agent did send is still covered locally, with no second questi assert.equal(asked, 0, "it was already here — asking again would be a second disclosure for nothing"); await ui.unmount(); }); + +// ── Deciding what happens to a value ──────────────────────────────────────── +// +// `sensitivity` and `release` are the holder's own answers, and absent means +// they gave none — the claim-type registry answers instead. Three states, not +// two, and the third is the one every naive implementation loses: a put +// REPLACES the attribute, so an editor that never mentions these clears them on +// every save, and the response says nothing about what was dropped. + +const PUT_OK = { "persona/attribute/put/1.0": { attributeId: "a1", version: 2, created: false, updatedAt: "x" } }; + +const editor = (existing?: Record) => + h(AttributeEditor, { + parties: PARTIES, + authority: HOLDER, + ...(existing ? { existing } : {}), + onDone: () => {}, + onCancel: () => {}, + }); + +const putPayload = (a: ReturnType) => a.of("attribute/put")[0]!.payload as Record; + +/** The `