From 91bab9af12b8a01c6ed6daa12452740c33951564 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 27 May 2026 10:49:24 +0200 Subject: [PATCH 1/2] chore: scaffold draft PR for #4253 item 3 (rewire onto Select) Assisted-by: AI From 281d6f3b7588d710776aaca3cbbd4cfeeae9443b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 08:29:15 +0200 Subject: [PATCH 2/2] refactor(pe): deepen the matching primitive, convert credentials once matchConstraint splits into matchConstraintOnMap (evaluation on a pre-converted JSON object, returning per-field outcomes) and a thin converting wrapper that token introspection keeps using, so selection and introspection still share one definition of field resolution. The engine converts every candidate once per Select call instead of once per descriptor (the dominant step-1 cost at large wallets: D-fold JSON remarshaling removed) and caches the PD-level format gate per credential. matchField returns a fieldResult carrying the rejection detail, which lets the MatchReport render dismissals from the same evaluation the selection used: the explainFieldMismatch re-walk is gone, removing the last place where report reasons could drift from selection behavior. Assisted-by: AI --- vcr/pe/match_report.go | 74 +++++++++++++------------ vcr/pe/presentation_definition.go | 75 ++++++++++++++++++-------- vcr/pe/presentation_definition_test.go | 60 ++++++++++----------- vcr/pe/select.go | 72 ++++++++++++++++++------- 4 files changed, 178 insertions(+), 103 deletions(-) diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go index 6a333e415f..28d47f472f 100644 --- a/vcr/pe/match_report.go +++ b/vcr/pe/match_report.go @@ -147,10 +147,11 @@ type Dismissal struct { } // buildReport assembles the MatchReport after a Select run. It re-evaluates step 1 per descriptor -// and candidate to recover the dismissal reasons, so a non-traced run pays nothing. Binding -// conflicts are explained against the decisive assignment (the chosen one, or the best-effort -// diagnostic assignment on failure), not against every backtracking visit. -func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential, required []bool, +// and candidate (on the pre-converted credentials, so no repeated JSON conversion) to recover +// the dismissal reasons, so a non-traced run pays nothing. Binding conflicts are explained +// against the decisive assignment (the chosen one, or the best-effort diagnostic assignment on +// failure), not against every backtracking visit. +func buildReport(pd PresentationDefinition, candidates []evaluatedCredential, required []bool, assignment []*candidateGroup, ambiguous []string, result Result, err error, initialBindings map[string]string) *MatchReport { report := &MatchReport{ Outcome: OutcomeMatched, @@ -194,31 +195,36 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential var selectedIDValues map[string]string selectedKey := "" if selected != nil { - _, selectedIDValues, _ = evaluateCandidate(pd, *descriptor, *selected) + for _, candidate := range candidates { + if vcEqual(candidate.vc, *selected) { + _, selectedIDValues, _ = evaluateCandidate(*descriptor, candidate) + break + } + } selectedKey = tupleKey(selectedIDValues) } selectedSeen := false for _, candidate := range candidates { candidateReport := CandidateReport{} - if candidate.ID != nil { - candidateReport.CredentialID = candidate.ID.String() + if candidate.vc.ID != nil { + candidateReport.CredentialID = candidate.vc.ID.String() } - if selected != nil && !selectedSeen && vcEqual(candidate, *selected) { + if selected != nil && !selectedSeen && vcEqual(candidate.vc, *selected) { // the chosen credential carries no dismissal; its evaluation is already done selectedSeen = true candidateReport.Eligible = true descriptorReport.Considered = append(descriptorReport.Considered, candidateReport) continue } - eligible, idValues, _ := evaluateCandidate(pd, *descriptor, candidate) + eligible, idValues, evalErr := evaluateCandidate(*descriptor, candidate) candidateReport.Eligible = eligible if !eligible { - candidateReport.Dismissal = explainIneligible(pd, *descriptor, candidate) + candidateReport.Dismissal = explainIneligible(*descriptor, candidate, evalErr) } else { candidateReport.Dismissal = explainNotChosen(idValues, bindings, strictIDs) // An interchangeable alternative (same binding tuple as the chosen credential) // whose subject nevertheless differs is worth the operator's attention. - if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate, *selected) { + if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate.vc, *selected) { descriptorReport.DivergingAlternatives = true } } @@ -237,41 +243,41 @@ func subjectsEqual(a, b vc.VerifiableCredential) bool { return errA == nil && errB == nil && bytes.Equal(aJSON, bJSON) } -// explainIneligible pinpoints why a credential failed step 1: the first failing constraint field -// (filter rejection or missing value), or the format gate. -func explainIneligible(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) *Dismissal { +// explainIneligible derives the dismissal for a credential that failed step 1 from the same +// primitive the selection uses: the failing field's outcome (matchConstraintOnMap), or the +// format gate. +func explainIneligible(descriptor InputDescriptor, candidate evaluatedCredential, evalErr error) *Dismissal { + if evalErr != nil { + return &Dismissal{Reason: ReasonNoValue, Message: evalErr.Error()} + } if descriptor.Constraints != nil { - credentialJSON, err := credentialAsMap(credential) + isMatch, fields, err := matchConstraintOnMap(descriptor.Constraints, candidate.json) if err != nil { return &Dismissal{Reason: ReasonNoValue, Message: err.Error()} } - for _, field := range descriptor.Constraints.Fields { - if match, _, err := matchField(field, credentialJSON); err == nil && match { - continue - } - return explainFieldMismatch(field, credentialJSON) + if !isMatch { + // on a mismatch the last outcome is the failing field's + failed := len(fields) - 1 + return renderFieldDismissal(descriptor.Constraints.Fields[failed], fields[failed]) } } - dismissal := &Dismissal{Reason: ReasonFormat} - dismissal.Message = fmt.Sprintf("credential format does not satisfy the format requirements of the presentation definition or input descriptor '%s'", descriptor.Id) - return dismissal + return &Dismissal{ + Reason: ReasonFormat, + Message: fmt.Sprintf("credential format does not satisfy the format requirements of the presentation definition or input descriptor '%s'", descriptor.Id), + } } -// explainFieldMismatch distinguishes "a value was found but the filter rejected it" from "no path -// produced a value". -func explainFieldMismatch(field Field, credentialJSON map[string]interface{}) *Dismissal { +// renderFieldDismissal renders a failing field outcome: a value was found but the filter rejected +// it, or no path produced a value. +func renderFieldDismissal(field Field, outcome fieldResult) *Dismissal { dismissal := &Dismissal{} if field.Id != nil { dismissal.FieldID = *field.Id } - for _, path := range field.Path { - value, err := getValueAtPath(path, credentialJSON) - if err != nil || value == nil { - continue - } + if outcome.rejectedValue != nil { dismissal.Reason = ReasonFilter - dismissal.Path = path - dismissal.Found = fmt.Sprintf("%v", value) + dismissal.Path = outcome.rejectedPath + dismissal.Found = fmt.Sprintf("%v", outcome.rejectedValue) if field.Filter != nil { if field.Filter.Const != nil { dismissal.Expected = *field.Filter.Const @@ -279,7 +285,7 @@ func explainFieldMismatch(field Field, credentialJSON map[string]interface{}) *D dismissal.Expected = field.Filter.Type } } - dismissal.Message = fmt.Sprintf("value at %s does not satisfy the filter: expected %s, found %s", path, dismissal.Expected, dismissal.Found) + dismissal.Message = fmt.Sprintf("value at %s does not satisfy the filter: expected %s, found %s", outcome.rejectedPath, dismissal.Expected, dismissal.Found) return dismissal } dismissal.Reason = ReasonNoValue diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 26cdcb50b8..05e3aefb93 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -398,27 +398,55 @@ func matchCredential(descriptor InputDescriptor, credential vc.VerifiableCredent // LimitDisclosure is not supported for now. // If the constraint matches, it returns true and a map containing constraint field IDs and matched values. func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential) (bool, map[string]interface{}, error) { - credentialAsMap, err := credentialAsMap(credential) + credentialJSON, err := credentialAsMap(credential) if err != nil { return false, nil, err } - - // for each field in constraint.fields: - // a vc must match the field + isMatch, fields, err := matchConstraintOnMap(constraint, credentialJSON) + if err != nil || !isMatch { + return false, nil, err + } values := make(map[string]interface{}) + for i, field := range constraint.Fields { + if field.Id != nil { + values[*field.Id] = fields[i].value + } + } + return true, values, nil +} + +// matchConstraintOnMap matches the constraint against a credential already converted to its JSON +// object form (credentialAsMap), returning the per-field outcomes. All fields need to match; the +// evaluation stops at the first field that does not, so on a mismatch the last element of the +// returned slice is the failing field's outcome. Conversion is the caller's job, so that one +// conversion can serve many constraint evaluations: the selection engine evaluates every input +// descriptor against every candidate credential. +func matchConstraintOnMap(constraint *Constraints, credentialJSON map[string]interface{}) (bool, []fieldResult, error) { + results := make([]fieldResult, 0, len(constraint.Fields)) for _, field := range constraint.Fields { - match, value, err := matchField(field, credentialAsMap) + result, err := matchField(field, credentialJSON) if err != nil { return false, nil, err } - if !match { - return false, nil, nil - } - if field.Id != nil { - values[*field.Id] = value + results = append(results, result) + if !result.match { + return false, results, nil } } - return true, values, nil + return true, results, nil +} + +// fieldResult is the outcome of evaluating one constraint field against a credential. It carries +// enough detail for diagnostics, so no second evaluation pass is needed to explain a mismatch. +type fieldResult struct { + // match is whether the field accepts the credential. + match bool + // value is the resolved value; nil for an unresolved optional field. + value interface{} + // rejectedPath and rejectedValue describe the first path where a value was found but the + // filter rejected it. rejectedValue is nil when no path produced a value at all. + rejectedPath string + rejectedValue interface{} } // credentialAsMap converts a VC to a plain JSON object, the form the JSONPath evaluation works on. @@ -439,44 +467,49 @@ func credentialAsMap(credential vc.VerifiableCredential) (map[string]interface{} } } -// matchField matches the field against the VC. -// If the field matches, it returns true and the matched value. The matched value can be nil if the field is optional. +// matchField matches the field against the VC (in its JSON object form). +// The matched value can be nil if the field is optional. // All fields need to match unless optional is set to true and no values are found for all the paths. -func matchField(field Field, credential map[string]interface{}) (bool, interface{}, error) { +func matchField(field Field, credential map[string]interface{}) (fieldResult, error) { // for each path in field.paths: // a vc must match one of the path + var result fieldResult var optionalInvalid int for _, path := range field.Path { // if path is not found continue value, err := getValueAtPath(path, credential) if err != nil { - return false, nil, fmt.Errorf("path %q: %w", path, err) + return fieldResult{}, fmt.Errorf("path %q: %w", path, err) } if value == nil { continue } if field.Filter == nil { - return true, value, nil + return fieldResult{match: true, value: value}, nil } // if filter at path matches return true match, matchedValue, err := matchFilter(*field.Filter, value) if err != nil { - return false, nil, fmt.Errorf("path %q: %w", path, err) + return fieldResult{}, fmt.Errorf("path %q: %w", path, err) } if match { - return true, matchedValue, nil + return fieldResult{match: true, value: matchedValue}, nil + } + // if filter at path does not match continue and set optionalInvalid; + // remember the first rejection for diagnostics + if optionalInvalid == 0 { + result.rejectedPath, result.rejectedValue = path, value } - // if filter at path does not match continue and set optionalInvalid optionalInvalid++ } // no matches, check optional. Optional is only valid if all paths returned no results // not if a filter did not match if field.Optional != nil && *field.Optional && optionalInvalid == 0 { - return true, nil, nil + return fieldResult{match: true}, nil } - return false, nil, nil + return result, nil } // getValueAtPath uses the JSON path expression to get the value from the VC diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 0d8e19068d..3f88b51ea5 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -674,58 +674,58 @@ func Test_matchField(t *testing.T) { testCredentialMap, _ := remarshalToMap(testCredential) t.Run("single path match", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, "value", value) - assert.True(t, match) + assert.Equal(t, "value", result.value) + assert.True(t, result.match) }) t.Run("multi path match", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, "value", value) - assert.True(t, match) + assert.Equal(t, "value", result.value) + assert.True(t, result.match) }) t.Run("no match", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap) require.NoError(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) t.Run("no match, but optional", func(t *testing.T) { trueVal := true - match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap) require.NoError(t, err) - assert.Nil(t, value) - assert.True(t, match) + assert.Nil(t, result.value) + assert.True(t, result.match) }) t.Run("invalid match and optional", func(t *testing.T) { trueVal := true stringVal := "bar" - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) t.Run("valid match with Filter", func(t *testing.T) { stringVal := "value" - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, stringVal, value) - assert.True(t, match) + assert.Equal(t, stringVal, result.value) + assert.True(t, result.match) }) t.Run("match on type", func(t *testing.T) { stringVal := "VerifiableCredential" - match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, stringVal, value) - assert.True(t, match) + assert.Equal(t, stringVal, result.value) + assert.True(t, result.match) }) t.Run("match on type array", func(t *testing.T) { testCredentialString = ` @@ -737,28 +737,28 @@ func Test_matchField(t *testing.T) { }` _ = json.Unmarshal([]byte(testCredentialString), &testCredentialMap) stringVal := "VerifiableCredential" - match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, []interface{}{"VerifiableCredential"}, value) - assert.True(t, match) + assert.Equal(t, []interface{}{"VerifiableCredential"}, result.value) + assert.True(t, result.match) }) t.Run("errors", func(t *testing.T) { t.Run("invalid path", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap) require.Error(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) t.Run("invalid pattern", func(t *testing.T) { pattern := "[" - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap) require.Error(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) }) } diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 1a7b8a270d..c9900826c8 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -136,16 +136,23 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt required := requiredDescriptors(pd) assignment := make([]*candidateGroup, len(pd.InputDescriptors)) var ambiguous []string + var converted []evaluatedCredential if options.trace { defer func() { - result.Report = buildReport(pd, candidates, required, assignment, ambiguous, result, err, options.initialBindings) + result.Report = buildReport(pd, converted, required, assignment, ambiguous, result, err, options.initialBindings) }() } - // Step 1: per-descriptor eligibility, indexed for the search. + // Step 1: convert every candidate once (JSON form and PD-level format gate are the same for + // every descriptor), then index per-descriptor eligibility for the search. + var convErr error + converted, convErr = convertCandidates(pd, candidates) + if convErr != nil { + return result, convErr + } pools := make([]descriptorPool, len(pd.InputDescriptors)) for i, descriptor := range pd.InputDescriptors { - pool, poolErr := buildPool(pd, *descriptor, candidates) + pool, poolErr := buildPool(*descriptor, converted) if poolErr != nil { return result, poolErr } @@ -452,8 +459,8 @@ func strictBoundIDs(descriptor InputDescriptor, initialBindings map[string]strin // buildPool evaluates every candidate against a single input descriptor (its constraints and both // format gates) and indexes the eligible ones. This is step 1, independent of any cross-descriptor // binding. -func buildPool(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) (descriptorPool, error) { - eligible, err := eligibleCandidates(pd, descriptor, candidates) +func buildPool(descriptor InputDescriptor, candidates []evaluatedCredential) (descriptorPool, error) { + eligible, err := eligibleCandidates(descriptor, candidates) if err != nil { return descriptorPool{}, err } @@ -703,45 +710,74 @@ type eligibleCandidate struct { idValues map[string]string } +// evaluatedCredential is a candidate prepared for evaluation: the JSON object form the JSONPath +// evaluation works on, and the PD-level format gate outcome. Both are the same for every input +// descriptor, so they are computed once per credential instead of once per descriptor. +type evaluatedCredential struct { + vc vc.VerifiableCredential + json map[string]interface{} + pdFormatOK bool +} + +// convertCandidates prepares every candidate once for evaluation against all descriptors. +func convertCandidates(pd PresentationDefinition, candidates []vc.VerifiableCredential) ([]evaluatedCredential, error) { + converted := make([]evaluatedCredential, len(candidates)) + for i, candidate := range candidates { + credentialJSON, err := credentialAsMap(candidate) + if err != nil { + return nil, err + } + converted[i] = evaluatedCredential{ + vc: candidate, + json: credentialJSON, + pdFormatOK: matchFormat(pd.Format, candidate), + } + } + return converted, nil +} + // eligibleCandidates returns the credentials that satisfy a single input descriptor on its own, // each paired with its binding tuple. -func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]eligibleCandidate, error) { +func eligibleCandidates(descriptor InputDescriptor, candidates []evaluatedCredential) ([]eligibleCandidate, error) { var eligible []eligibleCandidate for _, candidate := range candidates { - ok, idValues, err := evaluateCandidate(pd, descriptor, candidate) + ok, idValues, err := evaluateCandidate(descriptor, candidate) if err != nil { return nil, err } if ok { - eligible = append(eligible, eligibleCandidate{vc: candidate, idValues: idValues}) + eligible = append(eligible, eligibleCandidate{vc: candidate.vc, idValues: idValues}) } } return eligible, nil } // evaluateCandidate is the step-1 eligibility primitive for one credential and one descriptor: -// the descriptor's constraints (matchConstraint) and both the PD-level and descriptor-level -// format gates. The resolved field-id values are returned (stringified) as the candidate's -// binding tuple. Both the selection and the MatchReport use this single primitive, so a traced -// report cannot drift from what the selection actually did. -func evaluateCandidate(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) (bool, map[string]string, error) { +// the descriptor's constraints (matchConstraintOnMap, on the pre-converted JSON) and both format +// gates. The resolved field-id values are returned (stringified) as the candidate's binding +// tuple. Both the selection and the MatchReport use this single primitive, so a traced report +// cannot drift from what the selection actually did. +func evaluateCandidate(descriptor InputDescriptor, candidate evaluatedCredential) (bool, map[string]string, error) { idValues := make(map[string]string) if descriptor.Constraints != nil { - isMatch, values, err := matchConstraint(descriptor.Constraints, credential) + isMatch, fields, err := matchConstraintOnMap(descriptor.Constraints, candidate.json) if err != nil { return false, nil, err } if !isMatch { return false, nil, nil } - for id, value := range values { - if s, ok := stringifyBindingValue(value); ok { - idValues[id] = s + for i, field := range descriptor.Constraints.Fields { + if field.Id == nil { + continue + } + if s, ok := stringifyBindingValue(fields[i].value); ok { + idValues[*field.Id] = s } } } // InputDescriptor formats must be a subset of the PresentationDefinition formats, so satisfy both. - if !matchFormat(pd.Format, credential) || !matchFormat(descriptor.Format, credential) { + if !candidate.pdFormatOK || !matchFormat(descriptor.Format, candidate.vc) { return false, nil, nil } return true, idValues, nil