Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/admin/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersonaAttributePutPayload["sensitivity"]>;
/** 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<PersonaAttributePutPayload["release"]>;
/** 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. */
Expand Down Expand Up @@ -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. */
Expand All @@ -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
Expand All @@ -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<PersonaAttributePutPayload, PersonaAttributePutResponsePayload>(
Expand Down
52 changes: 52 additions & 0 deletions packages/core/tests/admin.persona.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
111 changes: 96 additions & 15 deletions packages/extension/src/manager/claim-sensitivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,14 @@ export const REGISTERED_ROOTS: ReadonlySet<string> = 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
Expand Down Expand Up @@ -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";
}
11 changes: 11 additions & 0 deletions packages/extension/src/manager/identity-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading