@@ -72,6 +72,7 @@ import { Loading, LoadError, Table, Truncated, type Column } from "../table.js";
7272import { useAsync , type Async } from "../use-async.js" ;
7373import { contextHeading , formatInstant } from "../format.js" ;
7474import { isUnscopedHolder , type Authority , type Parties } from "../use-vta.js" ;
75+ import { scanForProfile } from "../profile-bindings.js" ;
7576import {
7677 composeEntries ,
7778 lockedRefs ,
@@ -975,14 +976,23 @@ function DeleteProfile({
975976 * what says the value lives only in this profile — so it is rendered as a
976977 * statement rather than as three empty cells.
977978 */
978- function ResolvedProfile ( { parties, profile } : { parties : Parties ; profile : PoolProfile } ) {
979+ function ResolvedProfile ( {
980+ parties,
981+ profileId,
982+ name,
983+ } : {
984+ parties : Parties ;
985+ profileId : string ;
986+ /** How to name it while loading and when it holds nothing. */
987+ name : string ;
988+ } ) {
979989 const resolved = useAsync (
980- async ( ) => personaProfileGet ( managerSender , { ...parties , profileId : profile . profileId , resolve : true } ) ,
981- [ parties . holder . did , parties . service . did , profile . profileId ] ,
990+ async ( ) => personaProfileGet ( managerSender , { ...parties , profileId, resolve : true } ) ,
991+ [ parties . holder . did , parties . service . did , profileId ] ,
982992 ) ;
983993
984- if ( resolved . error ) return < LoadError what = { `what ${ profile . name } presents` } error = { resolved . error } /> ;
985- if ( ! resolved . data ) return < Loading what = { `what ${ profile . name } presents` } /> ;
994+ if ( resolved . error ) return < LoadError what = { `what ${ name } presents` } error = { resolved . error } /> ;
995+ if ( ! resolved . data ) return < Loading what = { `what ${ name } presents` } /> ;
986996
987997 const claims = resolved . data . resolved ?? [ ] ;
988998 if ( claims . length === 0 ) {
@@ -1006,7 +1016,7 @@ function ResolvedProfile({ parties, profile }: { parties: Parties; profile: Pool
10061016 const inline = claim . attributeId === undefined ;
10071017 return (
10081018 < div
1009- key = { `${ profile . profileId } -claim-${ i } ` }
1019+ key = { `${ profileId } -claim-${ i } ` }
10101020 style = { { display : "flex" , gap : 10 , flexWrap : "wrap" , alignItems : "baseline" } }
10111021 >
10121022 < span style = { { fontFamily : font . mono , fontSize : t . xs , minWidth : 150 } } > { claim . type } </ span >
@@ -1020,29 +1030,127 @@ function ResolvedProfile({ parties, profile }: { parties: Parties; profile: Pool
10201030 ) ;
10211031}
10221032
1033+ /**
1034+ * Which personas present a profile, and where.
1035+ *
1036+ * The question a holder actually asks of a profile — "who knows me by this?" —
1037+ * and the console has to assemble it, because no single task answers it. The
1038+ * assembly, and the soundness argument that makes it exact, live in
1039+ * `profile-bindings.ts`; this renders the result.
1040+ *
1041+ * **A click, not a column.** Even bounded, it is a fan-out across every
1042+ * context, and the answer is the holder's linkage map — the artifact this
1043+ * family exists to keep from being assembled casually. A column would run it on
1044+ * every page load for every profile and leave the map on screen whether or not
1045+ * anyone asked.
1046+ */
1047+ function ProfileBindings ( {
1048+ parties,
1049+ profile,
1050+ records,
1051+ } : {
1052+ parties : Parties ;
1053+ profile : PoolProfile ;
1054+ records : ContextRecord [ ] ;
1055+ } ) {
1056+ const found = useAsync (
1057+ async ( ) =>
1058+ scanForProfile (
1059+ records . map ( ( r ) => r . id ) ,
1060+ { profileId : profile . profileId , name : profile . name } ,
1061+ {
1062+ list : ( contextId ) => listBindings ( managerSender , { ...parties , contextId } ) ,
1063+ get : ( contextId , personaDid ) =>
1064+ getBinding ( managerSender , { ...parties , contextId, personaDid } ) ,
1065+ } ,
1066+ ) ,
1067+ [ parties . holder . did , parties . service . did , profile . profileId , profile . name , records . length ] ,
1068+ ) ;
1069+
1070+ if ( found . error ) return < LoadError what = { `who presents ${ profile . name } ` } error = { found . error } /> ;
1071+ if ( ! found . data ) return < Loading what = { `who presents ${ profile . name } ` } /> ;
1072+
1073+ const { rows, unreadable } = found . data ;
1074+
1075+ return (
1076+ < div style = { { display : "grid" , gap : 6 , padding : "4px 0 6px" } } >
1077+ < span style = { { fontSize : t . xs , color : c . faint , textTransform : "uppercase" , letterSpacing : 0.4 } } >
1078+ Presented by
1079+ </ span >
1080+ { rows . length === 0 ? (
1081+ < span style = { { fontSize : t . sm , color : c . faint } } >
1082+ No persona presents this profile. Nothing discloses it, in any context.
1083+ </ span >
1084+ ) : (
1085+ rows . map ( ( row ) => (
1086+ < div
1087+ key = { `${ row . contextId } -${ row . personaDid } ` }
1088+ style = { { display : "flex" , gap : 10 , flexWrap : "wrap" , alignItems : "baseline" } }
1089+ >
1090+ < span style = { { fontFamily : font . mono , fontSize : t . xs , wordBreak : "break-all" } } >
1091+ { row . personaDid }
1092+ </ span >
1093+ < span style = { { color : c . muted , fontSize : t . sm } } >
1094+ in { contextHeading ( records . find ( ( r ) => r . id === row . contextId ) , row . contextId ) } ·{ " " }
1095+ { row . claimCount } claim(s)
1096+ </ span >
1097+ </ div >
1098+ ) )
1099+ ) }
1100+ { rows . length > 1 && (
1101+ // The whole reason a holder asks. Two personas presenting one profile
1102+ // present identical values, so anyone who sees both knows they are the
1103+ // same person — and no later narrowing undoes it for someone who
1104+ // already saw them.
1105+ < Note tone = "warn" >
1106+ { rows . length } personas present this profile. They disclose the same values, so anyone
1107+ who sees two of them knows they are the same person — permanently.
1108+ </ Note >
1109+ ) }
1110+ { unreadable . length > 0 && (
1111+ < Note tone = "warn" >
1112+ This answer is incomplete: your agent would not answer for { unreadable . join ( ", " ) } . A
1113+ persona there could be presenting this profile without appearing above.
1114+ </ Note >
1115+ ) }
1116+ </ div >
1117+ ) ;
1118+ }
1119+
10231120function ProfilesPanel ( {
10241121 parties,
10251122 authority,
10261123 attributes,
10271124 profiles,
1125+ records,
10281126 onChanged,
10291127} : {
10301128 parties : Parties ;
10311129 authority : Authority | null ;
10321130 attributes : PoolAttribute [ ] ;
10331131 profiles : Async < PoolProfile [ ] > ;
1132+ /** The contexts to look in when asked who presents a profile. */
1133+ records : ContextRecord [ ] ;
10341134 onChanged : ( ) => void ;
10351135} ) {
10361136 const [ editing , setEditing ] = useState < PoolProfile | null > ( null ) ;
10371137 const [ creating , setCreating ] = useState ( false ) ;
1038- const [ resolving , setResolving ] = useState < string | null > ( null ) ;
1138+ /** Which row is expanded, and which of its two questions it is answering.
1139+ * One state rather than two, so opening either closes the other — a row
1140+ * showing both at once reads as one list. */
1141+ const [ open , setOpen ] = useState < { profileId : string ; view : "claims" | "where" } | null > ( null ) ;
10391142 const denied = holderGate ( authority ) ;
10401143
10411144 const byId = useMemo (
10421145 ( ) => new Map ( attributes . map ( ( a ) => [ a . attributeId , a ] ) ) ,
10431146 [ attributes ] ,
10441147 ) ;
10451148
1149+ const toggle = ( profileId : string , view : "claims" | "where" ) =>
1150+ setOpen ( ( current ) =>
1151+ current ?. profileId === profileId && current . view === view ? null : { profileId, view } ,
1152+ ) ;
1153+
10461154 if ( editing ) {
10471155 return (
10481156 < ProfileEditor
@@ -1098,9 +1206,17 @@ function ProfilesPanel({
10981206 kind = "quiet"
10991207 disabled = { Boolean ( denied ) }
11001208 { ...( denied ? { title : denied } : { } ) }
1101- onClick = { ( ) => setResolving ( ( id ) => ( id === p . profileId ? null : p . profileId ) ) }
1209+ onClick = { ( ) => toggle ( p . profileId , "claims" ) }
1210+ >
1211+ { open ?. profileId === p . profileId && open . view === "claims" ? "Hide" : "What it presents" }
1212+ </ Button >
1213+ < Button
1214+ kind = "quiet"
1215+ disabled = { Boolean ( denied ) }
1216+ { ...( denied ? { title : denied } : { } ) }
1217+ onClick = { ( ) => toggle ( p . profileId , "where" ) }
11021218 >
1103- { resolving === p . profileId ? "Hide" : "What it presents" }
1219+ { open ?. profileId === p . profileId && open . view === "where" ? "Hide" : "Who presents it " }
11041220 </ Button >
11051221 < Button kind = "quiet" onClick = { ( ) => setEditing ( p ) } >
11061222 Edit
@@ -1130,9 +1246,14 @@ function ProfilesPanel({
11301246 columns = { columns }
11311247 rows = { profiles . data }
11321248 rowKey = { ( p ) => p . profileId }
1133- expanded = { ( p ) =>
1134- resolving === p . profileId ? < ResolvedProfile parties = { parties } profile = { p } /> : null
1135- }
1249+ expanded = { ( p ) => {
1250+ if ( open ?. profileId !== p . profileId ) return null ;
1251+ return open . view === "claims" ? (
1252+ < ResolvedProfile parties = { parties } profileId = { p . profileId } name = { p . name } />
1253+ ) : (
1254+ < ProfileBindings parties = { parties } profile = { p } records = { records } />
1255+ ) ;
1256+ } }
11361257 empty = "No profiles yet. Until there is one, no persona has anything to present."
11371258 />
11381259 ) }
@@ -1172,6 +1293,60 @@ function ProfilesPanel({
11721293 * whether bound, the profile's label, a claim count. Never the contents. */
11731294type BindingRow = Awaited < ReturnType < typeof listBindings > > [ "personas" ] [ number ] ;
11741295
1296+ /**
1297+ * What one persona actually presents in one context.
1298+ *
1299+ * Two calls, and the first is not avoidable: `binding/list` gives a profile
1300+ * *name* and a count, never a `profileId` and never contents — thin by
1301+ * construction, because a binding read that returned values would make the
1302+ * disclosure gate decorative. So `binding/get` resolves the id, and
1303+ * `profile/get?resolve=true` resolves what it projects.
1304+ *
1305+ * **This is a truthful answer only because a pool edit now pushes.** The
1306+ * resolved profile is what the agent last materialised into the context; until
1307+ * VTI#1281 nothing called `rematerialise`, so the two could disagree and this
1308+ * view would have shown a holder values their verifiers were never given.
1309+ * Reading the profile is the right source *because* the push exists — not a
1310+ * convenient stand-in for the copy.
1311+ */
1312+ function PersonaClaims ( {
1313+ parties,
1314+ contextId,
1315+ personaDid,
1316+ profileName,
1317+ } : {
1318+ parties : Parties ;
1319+ contextId : string ;
1320+ personaDid : string ;
1321+ profileName : string ;
1322+ } ) {
1323+ const bound = useAsync (
1324+ async ( ) => getBinding ( managerSender , { ...parties , contextId, personaDid } ) ,
1325+ [ parties . holder . did , parties . service . did , contextId , personaDid ] ,
1326+ ) ;
1327+
1328+ if ( bound . error ) return < LoadError what = { `what ${ profileName } presents` } error = { bound . error } /> ;
1329+ if ( ! bound . data ) return < Loading what = { `what ${ profileName } presents` } /> ;
1330+ if ( ! bound . data . profileId ) {
1331+ // `bound` was true a moment ago and the profile is gone now, or the agent
1332+ // withheld the id. Either way, say that rather than render an empty list
1333+ // that reads as "presents nothing".
1334+ return (
1335+ < div style = { { fontSize : t . sm , color : c . faint , padding : "6px 0" } } >
1336+ Your agent did not name the profile behind this binding, so there is nothing to resolve.
1337+ </ div >
1338+ ) ;
1339+ }
1340+
1341+ return (
1342+ < ResolvedProfile
1343+ parties = { parties }
1344+ profileId = { bound . data . profileId }
1345+ name = { bound . data . profileName ?? profileName }
1346+ />
1347+ ) ;
1348+ }
1349+
11751350function BindingsPanel ( {
11761351 parties,
11771352 authority,
@@ -1184,6 +1359,7 @@ function BindingsPanel({
11841359 records : ContextRecord [ ] ;
11851360} ) {
11861361 const [ contextId , setContextId ] = useState < string > ( "" ) ;
1362+ const [ showing , setShowing ] = useState < string | null > ( null ) ;
11871363 const [ personaDid , setPersonaDid ] = useState ( "" ) ;
11881364 const [ profileId , setProfileId ] = useState < string > ( "" ) ;
11891365 const [ busy , setBusy ] = useState ( false ) ;
@@ -1342,12 +1518,18 @@ function BindingsPanel({
13421518 key : "presents" ,
13431519 header : "Presents" ,
13441520 width : "220px" ,
1521+ // The profile name and a count are all `binding/list` returns — thin by
1522+ // construction at the agent, which never sends claim contents on this
1523+ // path. So the count is a link rather than an answer: it says how much
1524+ // there is, and clicking asks what it is.
13451525 render : ( b ) =>
13461526 b . bound ? (
1347- < span >
1348- { b . profileName ?? "a profile" }
1349- < span style = { { color : c . muted } } > · { b . claimCount ?? 0 } claim(s)</ span >
1350- </ span >
1527+ < Button
1528+ kind = "quiet"
1529+ onClick = { ( ) => setShowing ( ( did ) => ( did === b . personaDid ? null : b . personaDid ) ) }
1530+ >
1531+ { b . profileName ?? "a profile" } · { b . claimCount ?? 0 } claim(s)
1532+ </ Button >
13511533 ) : (
13521534 < Pill tone = "off" > nothing</ Pill >
13531535 ) ,
@@ -1407,6 +1589,16 @@ function BindingsPanel({
14071589 columns = { columns }
14081590 rows = { bindings . data . personas }
14091591 rowKey = { ( b ) => b . personaDid }
1592+ expanded = { ( b ) =>
1593+ showing === b . personaDid && b . bound ? (
1594+ < PersonaClaims
1595+ parties = { parties }
1596+ contextId = { contextId }
1597+ personaDid = { b . personaDid }
1598+ profileName = { b . profileName ?? "this profile" }
1599+ />
1600+ ) : null
1601+ }
14101602 empty = "No persona has ever been bound in this context. Name one below to start."
14111603 />
14121604 { bindings . data . nextCursor && < Truncated what = "the personas in this context" /> }
@@ -1795,6 +1987,7 @@ export function PersonaPane({
17951987 authority = { authority }
17961988 attributes = { attributes . data ?? [ ] }
17971989 profiles = { profiles }
1990+ records = { records }
17981991 onChanged = { reloadAll }
17991992 />
18001993
0 commit comments