Skip to content

feat(persona): the holder decides what happens to a value, and "not decided" is an answer - #195

Merged
stormer78 merged 1 commit into
mainfrom
feat/attribute-visibility
Sep 8, 2026
Merged

feat(persona): the holder decides what happens to a value, and "not decided" is an answer#195
stormer78 merged 1 commit into
mainfrom
feat/attribute-visibility

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Follow-up to #194, which fixed the reading of sensitive values and closed with "there is still no way to change an attribute's visibility from the console". This is that.

The two questions, asked

sensitivity and release are per-attribute members on the wire — how carefully a value is shown to the holder, and what it takes to let it leave. The console read neither and wrote neither, so the only rule in force was the vendored claim-type table, and an attribute typed profile.github was masked and withheld with no way to say otherwise.

The editor now asks both, each with three options:

SHOWING IT TO YOU Let your agent decide — today that means kept back until you ask · Show it — no need to hide this one · Keep it back until I ask for it
LETTING IT LEAVE Let your agent decide — today that means you approve it once, before it goes · I approve it once, before it goes · Ask me again, on my device, every single time

The default option names what the registry answers today, so the person can see what they would be changing away from.

"Let your agent decide" is a state, not a synonym

Choosing it writes the member absent. persona/attribute/put is explicit about why: 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.

The 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. A holder who gated their passport and then fixed its label lost the gate. The editor now reads both off the record it loaded and sends them back untouched; manager-claim-sensitivity and the render tests pin all three directions (carried, set, cleared).

Why marking something showable now actually shows it

treatmentFor applies the holder's decision over the registry's and reports which spoke. One narrow exception makes the feature do anything visible:

  • A declared token keeps the registry's mask. §3.3 has the axes independent — an email is worth hiding from the person behind you without being worth withholding from every listing — so phone.mobile stays •• 25 however it is marked. The registry has an opinion there and this console does not overrule it.
  • An unregistered token's mask follows the holder. UNREGISTERED is one conservative answer covering both axes precisely because nobody had reasoned about the token (§4 rule 3's own words). The holder deciding is the decision it was standing in for. Without this, marking your own profile.github as showable still drew four bullets, by a rule justified only by nobody having looked at it.

A masking bug found on the way

maskedFact gated on sensitivity === "high"; isSensitive gated on the mask style. §3.3 made those independent, and email.* is normal/emailLocal — so the strip promised "hidden until you press Show" while the address sat on screen in full with no button to press. Two functions, one question, two answers. The mask style decides now.

Words

Three rows added to design-docs/persona-vocabulary.md (that table's own rule: add copy in those words, or change the document first) — showing it to you, letting it leave, and let your agent decide for a member the holder left absent. "Sensitivity" stays off the screen: it reads as a judgement about the value rather than a setting about a screen. That file lives outside this repo, so it is not in this diff.

Checks

  • 13 new tests. Extension 352 pass, core 552 pass.
  • tsc -b clean across workspaces; npm run build clean, manager.js a single chunk.
  • Pinned: a decision outranks the registry and says whose it is; a declared token's mask never moves; an unregistered one's follows the holder; x: stays unregistered; a decision is carried through an unrelated edit; clearing writes absence rather than a resolved default; and an attribute the holder marked showable is drawn in the clear on the map.

…ecided" is an answer

`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 <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 57a7447 into main Sep 8, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/attribute-visibility branch September 8, 2026 11:07
stormer78 added a commit that referenced this pull request Sep 8, 2026
The payoff for `persona/claim-types/list`: the console stops shipping its own
copy of the table. The copy was correct — that was never the problem. A copy of
a table two repositories do not own costs a re-sync PR against each on every
change, and can only describe the tokens its own build knew about.

Resolution moves to `@openvtc/pnm-core/persona` beside the served table, and the
strictness orderings come from the agent, so "more protective" means the same
thing on both sides. An axis value this build does not recognise is treated as
most protective, never least.

#195's work is preserved as-is: `treatmentFor` still applies the holder's own
decision over the registry's answer, only the axis they decided moves, and a
declared token's mask never does. What changed underneath it is where the
registry's answer comes from — `isRegisteredType` is now the served table's
walk rather than a local one over a compiled record.

`null` is a real state and fails closed: masked, `unregistered`, attributed to
the registry rather than the holder, with no compiled fallback — a stale copy
resolving a token the agent has since tightened is the failure the registry
exists to end. A render test pins it, found by leaving the fixture out and
watching the reveal-control count go 3 → 4.

Drawing stays here, because §3.3 gives the style and says the width is the
client's. `listClaimTypes` takes no contextId: the payload is empty and the task
is reachable by any authenticated caller, so the holder's own tooling can read
it.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant