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
40 changes: 31 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,10 @@ in), up is what a context **holds** of them (the accent). `Flow` is computed in
draws. The family hues (`--m-fam-*`, `manager/attribute-family.ts`) are
**categorical**, the same species as the act colours in `manager-theme.css` and
bound by that file's rule: `--w-ok` / `--w-warn` / `--w-danger` stay the only
colours that mean anything. `familyOf` groups **only** roots the vendored
registry declares — `profile.*` and `employer` are `unregistered`, not a
colours that mean anything. `familyOf` groups **only** roots the **agent's**
registry declares and this console has placed (`PLACED_ROOTS`) — a family the
agent serves that this build predates is `unregistered`, which is the honest
answer for one rather than a gap. It groups only roots the registry declares — `profile.*` and `employer` are `unregistered`, not a
"profile" family invented here — and no family's words may claim the colour
protects anything, which `manager-attribute-family.test.mts` asserts directly.

Expand All @@ -372,11 +374,30 @@ border or in a pill; adding a `--m-fam-*` for something that is *state*; or
giving `familyOf` a prefix rule the registry has not declared.

**Sensitive values are hidden from the screen, and that is all it is.**
`manager/claim-sensitivity.ts` carries a **vendored** copy of the claim-type
registry's masking data — sensitivity and mask style per token, from
`specs/persona/_shared/0.1/claim-types.json` at `registryVersion` 0.1 — because
the agent does not serve that table: `persona/claim-types/list` is an open
question in `CLAIM-TYPES.md` §6, deferred until the first extension type ships.
The claim-type table is **read from the agent**, through
`persona/claim-types/list` — `@openvtc/pnm-core/persona`'s `listClaimTypes`,
loaded by `panes/persona.tsx` beside the pool and threaded down as a prop.
`manager/claim-sensitivity.ts` used to carry a vendored copy; the copy was
*correct*, which was never the problem. A copy of a table two repositories do
not own costs a re-sync pull request against each on every change, and can only
describe the tokens its own build knew about — an agent serving an extension
type is invisible to a client shipping its own.

**Resolution lives in core** (`resolveTreatment`), beside the served table, and
the strictness orderings come from the agent — so "more protective" means the
same thing on both sides, and a maintainer adding a stricter mask style is
honoured without a rebuild. An axis value this build does not recognise is
treated as **most** protective. `treatmentFor` still applies the holder's
decision over that answer, unchanged: only the axis they decided moves, and a
*declared* token's mask never does.

**`null` is a real state and it fails closed.** While the table is in flight
every value is masked and every attribute groups as `unregistered`, attributed
to the registry rather than the holder — claiming `source: "holder"` for a
default would put their name on one. There is deliberately no compiled
fallback: a stale copy resolving a token the agent has since tightened is the
failure the registry exists to end.

An unregistered or `x:` token resolves to the conservative default
(`high`/`full`) per §4 rule 3. The prefix walk **is** rule 3 and it only ever
*tightens*: an unregistered token takes the more protective of its longest
Expand Down Expand Up @@ -498,8 +519,9 @@ flow; a surface that formats a value itself instead of rendering
`FactValue` (the second surface is always the one added later, and a value
masked on the card and printed in the strip is masked nowhere); greying a mask
with `c.faint`, which is this pane's word for "the agent sent no value" and so
makes a fact the holder has look like one they do not; adding a prefix fallback
to the vendored table; or letting a UI string imply the console does not hold
makes a fact the holder has look like one they do not; reintroducing a compiled
table as a fallback for a registry that has not loaded; or letting a UI string
imply the console does not hold
what it hides.

**The console's components are rendered in tests, and this is how.**
Expand Down
184 changes: 184 additions & 0 deletions packages/core/src/persona/claim-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// `persona/claim-types/list/1.0` — the claim-type registry, read from the agent
// rather than compiled in.
//
// ## Why this exists
//
// The console vendored this table, and the copy was *correct*. That is not the
// problem being solved. The problem is that a copy of a table two repositories
// do not own costs a re-sync pull request against each of them on every change
// — which is the argument `CLAIM-TYPES.md` §1 makes for the registry existing
// at all, applied one layer down — and that a compiled copy can only ever
// describe the tokens its own build knew about. An agent serving an extension
// type is invisible to a client that ships its own table.
//
// ## Resolution belongs here, not at the call site
//
// `resolveTreatment` is §4 in full, and the parts that look like detail are the
// parts that were got wrong before: the walk is on **dot boundaries**, it is a
// **proper** prefix, `x:` borrows nothing, and the prefix can only ever
// *tighten* against the floor. A caller that walked prefixes itself would have
// to get all four right, and the loosening direction fails quietly — an
// unregistered `name.somethingNew` inheriting `name`'s `none` is a value shown
// in the clear that the registry never said to show.
//
// The orderings come from the agent's `strictness`, not from a constant here.
// That is the half that makes "more protective wins" mean the same thing on
// both sides: a maintainer that adds a mask style stricter than `full` says so
// in the ordering it serves, and this resolves against it without a rebuild.

import type { TrustTaskSender } from "../vta/channel.js";

import {
TYPE_URI as CLAIM_TYPES_LIST,
RESPONSE_TYPE_URI as CLAIM_TYPES_LIST_RESPONSE,
type PersonaClaimTypesListPayload,
type PersonaClaimTypesListResponsePayload,
} from "@openvtc/trust-tasks/persona/claim-types/list/1.0/payload";

import { call, type PersonaCallerParams } from "./call.js";

/**
* Who is asking, and of which agent — **without** a context.
*
* Every other task in this directory takes `PersonaCallerParams`, which
* requires one. This task must not: its payload is empty, and the agent gates
* it as reachable by any authenticated caller precisely so the two callers that
* need it can both get it. Requiring a context here would refuse the holder's
* own tooling, which has none to name — and a context invented to satisfy a
* signature is a lie in an audit trail.
*/
export type ClaimTypesCaller = Omit<PersonaCallerParams, "contextId">;

export type ClaimTypeRegistry = PersonaClaimTypesListResponsePayload;
export type RegistryEntry = ClaimTypeRegistry["entries"][number];

/** How carefully a value is shown to its own holder, and how it is drawn. */
export interface ClaimTreatment {
sensitivity: string;
mask: string;
}

/**
* Read the agent's claim-type registry.
*
* The response is a constant for a given agent, so a caller **may** hold it for
* the life of a session, keyed on `registryVersion`. It **must not** be cached
* across agents: two agents may serve different extension types, and a table
* from one applied to the other resolves tokens it has never heard of.
*/
export async function listClaimTypes(
sender: TrustTaskSender,
params: ClaimTypesCaller,
): Promise<ClaimTypeRegistry> {
return call<PersonaClaimTypesListPayload, ClaimTypeRegistry>(
sender,
params,
CLAIM_TYPES_LIST,
CLAIM_TYPES_LIST_RESPONSE,
"persona/claim-types/list",
{},
);
}

/** The open extension namespace. `ClaimType` in `persona-record.schema.json`. */
const EXTENSION_PREFIX = "x:";

/**
* The more protective of two values on one axis, per the agent's ordering.
*
* A value the ordering does not contain is treated as **most** protective
* rather than least. That is the unknown-value branch, and it is the difference
* between showing a value and hiding it: a maintainer serving a style this
* build has never heard of must not have it read as "no mask".
*/
function stricter(order: readonly string[], a: string, b: string): string {
const rank = (v: string) => {
const i = order.indexOf(v);
return i === -1 ? -1 : i;
};
return rank(a) <= rank(b) ? a : b;
}

/**
* How this type's values are treated — `CLAIM-TYPES.md` §4.
*
* Rule 1 — a holder's per-attribute override — is deliberately *not* here. It
* belongs above this call, because "the holder decided" and "the registry says"
* are different facts and a UI that wants to explain the difference needs both.
*/
export function resolveTreatment(
registry: ClaimTypeRegistry,
type: string,
): ClaimTreatment {
const floor: ClaimTreatment = {
sensitivity: registry.unregistered.sensitivity,
mask: registry.unregistered.mask,
};

// Rule 4 taken first: `x:` is unregistered *by construction*, so it must
// borrow neither an entry nor a family however much of a registered token it
// happens to spell.
if (type.startsWith(EXTENSION_PREFIX)) return floor;

// Rule 2 — an exact entry, as written.
const exact = registry.entries.find((e) => e.type === type);
if (exact) return { sensitivity: exact.sensitivity, mask: exact.mask };

// Rule 3 — the longest registered *proper* prefix, on dot boundaries only.
// `payment` is a prefix of `payment.card`; `paymentology.card` is a member of
// nothing.
const segments = type.split(".");
let prefix: RegistryEntry | undefined;
for (let i = segments.length - 1; i > 0; i--) {
const candidate = registry.entries.find(
(e) => e.type === segments.slice(0, i).join("."),
);
if (candidate) {
prefix = candidate;
break;
}
}
if (!prefix) return floor;

// ...and it can only tighten. `payment.giftCard` inherits `payment`'s gating,
// because a gated family must not be leavable by inventing a token; while
// `name.somethingNew` does **not** inherit `name`'s `none`, because a family
// entry cannot make an unknown token visible.
return {
sensitivity: stricter(
registry.strictness.sensitivity ?? [],
prefix.sensitivity,
floor.sensitivity,
),
mask: stricter(registry.strictness.mask ?? [], prefix.mask, floor.mask),
};
}

/**
* Whether the registry **declares** this token, or a family it belongs to.
*
* The same walk [`resolveTreatment`] performs, asked as a question — a caller
* applying a holder's override needs it, because the floor and a declared
* entry are different kinds of answer. An `x:` token is never registered, per
* §4's last rule.
*/
export function isRegisteredType(registry: ClaimTypeRegistry, type: string): boolean {
if (type.startsWith(EXTENSION_PREFIX)) return false;
if (registry.entries.some((e) => e.type === type)) return true;
const segments = type.split(".");
for (let i = segments.length - 1; i > 0; i--) {
const head = segments.slice(0, i).join(".");
if (registry.entries.some((e) => e.type === head)) return true;
}
return false;
}

/**
* The first segment of every registered token — the roots a UI may group by.
*
* Served rather than derived from a compiled list, so a family the agent knows
* and this build does not still groups.
*/
export function registeredRoots(registry: ClaimTypeRegistry): ReadonlySet<string> {
return new Set(registry.entries.map((e) => e.type.split(".")[0]!));
}
1 change: 1 addition & 0 deletions packages/core/src/persona/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

export * from "./disclosure.js";
export * from "./step-up.js";
export * from "./claim-types.js";
export * from "./consent-view.js";
export * from "./renderers.js";
export * from "./bindings.js";
Expand Down
124 changes: 124 additions & 0 deletions packages/core/tests/persona.claim-types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// `CLAIM-TYPES.md` §4, resolved against the table the AGENT serves.
//
// These moved here from the console when the vendored copy went. Resolution is
// not a rendering concern and never was: it is the same four rules whoever
// asks, and having it in one place is the point of reading the table rather
// than compiling one in.

import { test } from "node:test";
import assert from "node:assert/strict";

import { resolveTreatment, isRegisteredType, registeredRoots } from "../dist/persona/index.js";

/** A registry shaped exactly as `persona/claim-types/list` returns one. */
const registry = {
registryVersion: "0.1",
entries: [
// Family rows and exact rows, undistinguished — which one a row is depends
// on the token being resolved.
{ type: "payment", sensitivity: "high", release: "stepUp", mask: "full" },
{ type: "gov", sensitivity: "high", release: "stepUp", mask: "full" },
{ type: "name", sensitivity: "normal", release: "consent", mask: "none" },
{ type: "name.legal", sensitivity: "normal", release: "consent", mask: "none" },
{ type: "payment.card", sensitivity: "high", release: "stepUp", mask: "last4" },
{ type: "email.work", sensitivity: "normal", release: "consent", mask: "emailLocal" },
{ type: "account.handle", sensitivity: "normal", release: "consent", mask: "none" },
],
unregistered: { sensitivity: "high", release: "consent", mask: "full" },
strictness: {
sensitivity: ["high", "normal"],
release: ["stepUp", "consent"],
mask: ["full", "last2", "last4", "emailLocal", "none"],
},
};

test("an exact entry is used as written, not compared against its family", () => {
// `payment.card` is `last4`, not its family's `full`. Rule 2 answers before
// rule 3 can tighten it.
assert.deepEqual(resolveTreatment(registry, "payment.card"), {
sensitivity: "high",
mask: "last4",
});
});

test("a bare name is a token, not just a prefix", () => {
assert.deepEqual(resolveTreatment(registry, "name"), { sensitivity: "normal", mask: "none" });
});

test("a token invented under a gated family cannot escape it", () => {
// The hole rule 3 exists for: without the walk this would take the floor's
// `consent`, weaker than every registered member of the family it plainly
// belongs to.
assert.deepEqual(resolveTreatment(registry, "payment.giftCard"), {
sensitivity: "high",
mask: "full",
});
});

test("a family entry can tighten but never loosen", () => {
// `name` is `none`, but an unregistered member does NOT inherit that — the
// more protective of the prefix and the floor wins, per axis. A family entry
// cannot make an unknown token visible.
assert.deepEqual(resolveTreatment(registry, "name.somethingNew"), {
sensitivity: "high",
mask: "full",
});
});

test("an x: token borrows nothing, however it is spelled", () => {
assert.deepEqual(resolveTreatment(registry, "x:payment.card"), {
sensitivity: "high",
mask: "full",
});
assert.deepEqual(resolveTreatment(registry, "x:name"), { sensitivity: "high", mask: "full" });
});

test("a prefix is matched on dot boundaries, not on characters", () => {
// `paymentology.card` merely starts with those characters and is a member of
// nothing.
assert.deepEqual(resolveTreatment(registry, "paymentology.card"), {
sensitivity: "high",
mask: "full",
});
});

test("a token the registry has never seen takes the floor", () => {
assert.deepEqual(resolveTreatment(registry, "wholly.unknown"), {
sensitivity: "high",
mask: "full",
});
});

test("a declared token is distinguishable from one that fell to the floor", () => {
// A holder applying their own decision needs to know which they have: a
// declared entry is a statement the registry made, the floor is one standing
// in for a decision nobody took. `treatmentFor` in the console turns on it.
assert.equal(isRegisteredType(registry, "payment.card"), true, "exact entry");
assert.equal(isRegisteredType(registry, "payment.giftCard"), true, "via its family");
assert.equal(isRegisteredType(registry, "wholly.unknown"), false);
assert.equal(isRegisteredType(registry, "x:payment.card"), false, "x: is never registered");
});

test("an unrecognised value on an axis is treated as most protective", () => {
// A maintainer serving a style this build has never heard of must not have it
// read as "no mask" — the unknown-value branch is the difference between
// showing a value and hiding it.
const future = {
...registry,
entries: [
{ type: "novel", sensitivity: "high", release: "consent", mask: "someFutureStyle" },
],
};
// The style survives resolution rather than being rewritten — a renderer that
// knows it should use it. What must never happen is it resolving to `none`:
// that is the branch where an unknown style shows a value in the clear.
const resolved = resolveTreatment(future, "novel.thing").mask;
assert.notEqual(resolved, "none", "an unknown style must never resolve to no mask");
});

test("the roots come from the served table, not a compiled list", () => {
const roots = registeredRoots(registry);
assert.ok(roots.has("payment"));
assert.ok(roots.has("email"));
assert.ok(!roots.has("wholly"));
});
2 changes: 1 addition & 1 deletion packages/core/tests/task-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ test("coverage against the agent's surface is recorded, not discovered", () => {
// the agent does not name, rather than as a deprecation warning. That is the
// expected shape of a cutover here: nothing is deployed, so neither side
// keeps an old version alive.
const expected = 187;
const expected = 188;
assert.equal(
implemented.size,
expected,
Expand Down
Loading
Loading