From 57e7ba5a7396cbce306f9dcf5e8059de32266232 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 26 May 2026 19:35:12 +0200 Subject: [PATCH 01/25] chore: scaffold draft PR for #4253 item 1 (select engine) Assisted-by: AI From 3f426221acd2ae24886e46054553460d7a6c700b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 29 May 2026 20:17:57 +0200 Subject: [PATCH 02/25] feat(pe): add Select tracer (single descriptor match) Introduce the new pure matching engine Select with its public surface (Result, Option) and the step-1 per-descriptor eligibility pass. This first slice binds a single descriptor to its one matching candidate. Tests build presentation definitions and credentials from their JSON wire form, which keeps the deeply-nested PDs readable; the PD helper uses DisallowUnknownFields so a misspelled key fails loudly. Assisted-by: AI --- vcr/pe/select.go | 80 +++++++++++++++++++++++++++++++++++++++++++ vcr/pe/select_test.go | 77 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 vcr/pe/select.go create mode 100644 vcr/pe/select_test.go diff --git a/vcr/pe/select.go b/vcr/pe/select.go new file mode 100644 index 0000000000..4549bc630e --- /dev/null +++ b/vcr/pe/select.go @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import "github.com/nuts-foundation/go-did/vc" + +// selectOptions holds the knobs configured by the Option functions passed to Select. +type selectOptions struct { +} + +// Option configures a single Select call. Options exist so that callers can opt in to +// behaviour (initial bindings, strict ambiguity detection, tracing) without widening the +// Select signature for every new knob. +type Option func(*selectOptions) + +// Result is the outcome of a Select call. +type Result struct { + // Candidates pairs every input descriptor (in PD order) with the VC chosen for it. + // A nil VC means the descriptor was left unfilled (optional and skipped, or dropped by a rule). + Candidates []Candidate + // Bindings holds the resolved field-id to value pairs of the chosen assignment. + Bindings map[string]string +} + +// Select resolves a presentation definition against a set of candidate credentials and +// returns the chosen descriptor-to-VC assignment. It is the single matching engine: it +// matches each descriptor on its own, searches for a binding-consistent combination across +// descriptors, and applies the submission requirement rules. +func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (Result, error) { + var result Result + for _, descriptor := range pd.InputDescriptors { + eligible, err := eligibleCandidates(pd, *descriptor, candidates) + if err != nil { + return Result{}, err + } + var selected *vc.VerifiableCredential + if len(eligible) > 0 { + selected = &eligible[0] + } + result.Candidates = append(result.Candidates, Candidate{ + InputDescriptor: *descriptor, + VC: selected, + }) + } + return result, nil +} + +// eligibleCandidates returns the credentials that satisfy a single input descriptor on its own: +// its constraints (matchConstraint) and both the PD-level and descriptor-level format gates. This +// is step 1, evaluated independently of any cross-descriptor binding. +func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]vc.VerifiableCredential, error) { + var eligible []vc.VerifiableCredential + for _, candidate := range candidates { + isMatch, err := matchCredential(descriptor, candidate) + if err != nil { + return nil, err + } + // InputDescriptor formats must be a subset of the PresentationDefinition formats, so satisfy both. + if isMatch && matchFormat(pd.Format, candidate) && matchFormat(descriptor.Format, candidate) { + eligible = append(eligible, candidate) + } + } + return eligible, nil +} diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go new file mode 100644 index 0000000000..b56dd97fa9 --- /dev/null +++ b/vcr/pe/select_test.go @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/nuts-foundation/go-did/vc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// parsePD unmarshals a presentation definition from its JSON wire form. JSON keeps the +// often deeply-nested PDs (input descriptors, constraints, submission requirements) readable. +// DisallowUnknownFields turns a misspelled key into a loud error instead of a silently dropped field. +func parsePD(t *testing.T, jsonStr string) PresentationDefinition { + t.Helper() + dec := json.NewDecoder(strings.NewReader(jsonStr)) + dec.DisallowUnknownFields() + var pd PresentationDefinition + require.NoError(t, dec.Decode(&pd)) + return pd +} + +// parseVC unmarshals a verifiable credential from its JSON wire form. Going through JSON gives +// the same runtime types the engine sees in production (notably JSON numbers become float64). +func parseVC(t *testing.T, jsonStr string) vc.VerifiableCredential { + t.Helper() + var cred vc.VerifiableCredential + require.NoError(t, json.Unmarshal([]byte(jsonStr), &cred)) + return cred +} + +func TestSelect(t *testing.T) { + t.Run("single descriptor with one matching candidate binds it", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": { + "fields": [{ + "id": "patient_id", + "path": ["$.credentialSubject.patientId"] + }] + } + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) + require.NotNil(t, result.Candidates[0].VC) + require.NotNil(t, result.Candidates[0].VC.ID) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + }) +} From a4e3595d1137cff98b9cd9c9a731b5bbbd121f87 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 29 May 2026 20:29:26 +0200 Subject: [PATCH 03/25] feat(pe): reproduce FirstMatch selection in Select Enforce the all-required rule for presentation definitions without submission requirements: an unfilled descriptor yields ErrNoCredentials, with Candidates still populated for diagnostics. The remaining FirstMatch behaviors (all-fill, first-pick, multi-field AND) are covered by the step-1 eligibility pass and added as regression guards. Assisted-by: AI --- vcr/pe/select.go | 32 +++++++++++++++- vcr/pe/select_test.go | 85 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 4549bc630e..1cab3d69bb 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -18,7 +18,13 @@ package pe -import "github.com/nuts-foundation/go-did/vc" +import ( + "errors" + "fmt" + "strings" + + "github.com/nuts-foundation/go-did/vc" +) // selectOptions holds the knobs configured by the Option functions passed to Select. type selectOptions struct { @@ -58,9 +64,33 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt VC: selected, }) } + + if len(pd.SubmissionRequirements) == 0 { + // With no submission requirements every descriptor is required. + // Candidates is returned even on error so callers can diagnose the unfilled descriptors. + if err := requireAllFilled(result.Candidates); err != nil { + return result, err + } + } + return result, nil } +// requireAllFilled reports ErrNoCredentials when any descriptor was left unfilled. It encodes the +// rule that, with no submission requirements, every descriptor is mandatory. +func requireAllFilled(candidates []Candidate) error { + var unmatched []string + for _, candidate := range candidates { + if candidate.VC == nil { + unmatched = append(unmatched, fmt.Sprintf("no VC for InputDescriptor (%s)", candidate.InputDescriptor.Id)) + } + } + if len(unmatched) > 0 { + return errors.Join(ErrNoCredentials, fmt.Errorf("constraints not matched: %s", strings.Join(unmatched, ", "))) + } + return nil +} + // eligibleCandidates returns the credentials that satisfy a single input descriptor on its own: // its constraints (matchConstraint) and both the PD-level and descriptor-level format gates. This // is step 1, evaluated independently of any cross-descriptor binding. diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index b56dd97fa9..b4acfc2474 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -74,4 +74,89 @@ func TestSelect(t *testing.T) { require.NotNil(t, result.Candidates[0].VC.ID) assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) }) + + t.Run("required descriptor with zero matches returns ErrNoCredentials", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": { + "fields": [{ + "id": "patient_id", + "path": ["$.credentialSubject.patientId"], + "filter": {"type": "string", "const": "999"} + }] + } + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + }) + + t.Run("multiple required descriptors all fill", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}}, + {"id": "patient_enrollment", "constraints": {"fields": [{"id": "bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + orgVC := parseVC(t, `{"id": "org-1", "credentialSubject": {"ura": "URA-001"}}`) + patientVC := parseVC(t, `{"id": "patient-1", "credentialSubject": {"bsn": "BSN-111"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{orgVC, patientVC}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "org-1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "patient-1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("multiple matching candidates picks the first, no error", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + first := parseVC(t, `{"id": "vc-first", "credentialSubject": {"patientId": "123"}}`) + second := parseVC(t, `{"id": "vc-second", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{first, second}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-first", result.Candidates[0].VC.ID.String()) + }) + + t.Run("multi-field constraint requires every field to match", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "enrollment", + "constraints": {"fields": [ + {"id": "patient_id", "path": ["$.credentialSubject.patientId"]}, + {"id": "org_city", "path": ["$.credentialSubject.city"]} + ]} + }] + }`) + // matches only patient_id (missing city) -> not eligible + partial := parseVC(t, `{"id": "vc-partial", "credentialSubject": {"patientId": "123"}}`) + // matches both fields -> eligible + full := parseVC(t, `{"id": "vc-full", "credentialSubject": {"patientId": "123", "city": "Amsterdam"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{partial, full}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-full", result.Candidates[0].VC.ID.String()) + }) } From 51efc51fae3a81aeb4bf294926c8a359924fbbcf Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 29 May 2026 21:14:29 +0200 Subject: [PATCH 04/25] feat(pe): reproduce field-selector bindings in Select Add WithInitialBindings so callers can seed id->value bindings. Step 1 now records each candidate's resolved id-bearing field values (stringified the same way as the legacy field selector), and candidates are filtered to those consistent with the bindings. A descriptor pinned by the caller that still resolves to more than one candidate yields ErrMultipleCredentials; zero matches fall through to ErrNoCredentials. Numeric/bool values, multi-key AND, independent keys across descriptors, and silently-dropped unknown keys are covered as regression guards. Assisted-by: AI --- vcr/pe/select.go | 125 ++++++++++++++++++++++++++++++--- vcr/pe/select_test.go | 159 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 11 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 1cab3d69bb..aa731823cc 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -21,6 +21,7 @@ package pe import ( "errors" "fmt" + "strconv" "strings" "github.com/nuts-foundation/go-did/vc" @@ -28,6 +29,8 @@ import ( // selectOptions holds the knobs configured by the Option functions passed to Select. type selectOptions struct { + // initialBindings seeds the id->value bindings (typically from a credential_selection parameter). + initialBindings map[string]string } // Option configures a single Select call. Options exist so that callers can opt in to @@ -35,6 +38,15 @@ type selectOptions struct { // Select signature for every new knob. type Option func(*selectOptions) +// WithInitialBindings seeds the search with id->value bindings, constraining which candidates +// can fill the descriptors carrying those field ids. Keys that are not field ids on the PD have +// no effect. +func WithInitialBindings(b map[string]string) Option { + return func(o *selectOptions) { + o.initialBindings = b + } +} + // Result is the outcome of a Select call. type Result struct { // Candidates pairs every input descriptor (in PD order) with the VC chosen for it. @@ -49,15 +61,26 @@ type Result struct { // matches each descriptor on its own, searches for a binding-consistent combination across // descriptors, and applies the submission requirement rules. func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (Result, error) { + var options selectOptions + for _, opt := range opts { + opt(&options) + } + var result Result for _, descriptor := range pd.InputDescriptors { eligible, err := eligibleCandidates(pd, *descriptor, candidates) if err != nil { - return Result{}, err + return result, err + } + // Keep only candidates whose resolved id-values agree with the bindings (P3 consistency). + consistent := consistentCandidates(eligible, options.initialBindings) + // A descriptor pinned by the caller's bindings must resolve to exactly one candidate. + if len(consistent) > 1 && isCallerBound(*descriptor, options.initialBindings) { + return result, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrMultipleCredentials) } var selected *vc.VerifiableCredential - if len(eligible) > 0 { - selected = &eligible[0] + if len(consistent) > 0 { + selected = &consistent[0].vc } result.Candidates = append(result.Candidates, Candidate{ InputDescriptor: *descriptor, @@ -91,20 +114,100 @@ func requireAllFilled(candidates []Candidate) error { return nil } +// eligibleCandidate is a credential that passed a descriptor on its own (step 1), paired with the +// field-id -> value bindings it resolves. The bindings drive cross-descriptor consistency. +type eligibleCandidate struct { + vc vc.VerifiableCredential + idValues map[string]string +} + // eligibleCandidates returns the credentials that satisfy a single input descriptor on its own: // its constraints (matchConstraint) and both the PD-level and descriptor-level format gates. This -// is step 1, evaluated independently of any cross-descriptor binding. -func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]vc.VerifiableCredential, error) { - var eligible []vc.VerifiableCredential +// is step 1, evaluated independently of any cross-descriptor binding. The matched id-bearing field +// values are recorded (stringified) for later consistency checks. +func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]eligibleCandidate, error) { + var eligible []eligibleCandidate for _, candidate := range candidates { - isMatch, err := matchCredential(descriptor, candidate) - if err != nil { - return nil, err + var idValues map[string]string + if descriptor.Constraints != nil { + isMatch, values, err := matchConstraint(descriptor.Constraints, candidate) + if err != nil { + return nil, err + } + if !isMatch { + continue + } + idValues = make(map[string]string) + for id, value := range values { + if s, ok := stringifyBindingValue(value); ok { + idValues[id] = s + } + } } // InputDescriptor formats must be a subset of the PresentationDefinition formats, so satisfy both. - if isMatch && matchFormat(pd.Format, candidate) && matchFormat(descriptor.Format, candidate) { - eligible = append(eligible, candidate) + if !matchFormat(pd.Format, candidate) || !matchFormat(descriptor.Format, candidate) { + continue } + eligible = append(eligible, eligibleCandidate{vc: candidate, idValues: idValues}) } return eligible, nil } + +// consistentCandidates keeps the eligible candidates whose resolved id-values agree with the +// running bindings. With no bindings every eligible candidate is consistent. +func consistentCandidates(eligible []eligibleCandidate, bindings map[string]string) []eligibleCandidate { + if len(bindings) == 0 { + return eligible + } + var consistent []eligibleCandidate + for _, candidate := range eligible { + if candidate.consistentWith(bindings) { + consistent = append(consistent, candidate) + } + } + return consistent +} + +// consistentWith reports whether the candidate agrees with the bindings on every shared id. A +// binding key the candidate does not resolve is irrelevant, so a stray key has no effect. +func (c eligibleCandidate) consistentWith(bindings map[string]string) bool { + for id, value := range c.idValues { + if bound, ok := bindings[id]; ok && bound != value { + return false + } + } + return true +} + +// isCallerBound reports whether the caller pinned this descriptor by binding one of its field ids. +// Such a descriptor must resolve to a single credential, mirroring the legacy field selector. +func isCallerBound(descriptor InputDescriptor, bindings map[string]string) bool { + if descriptor.Constraints == nil { + return false + } + for _, field := range descriptor.Constraints.Fields { + if field.Id == nil { + continue + } + if _, ok := bindings[*field.Id]; ok { + return true + } + } + return false +} + +// stringifyBindingValue renders a resolved field value as the string used for binding comparison: +// strings as-is, float64 without a trailing zero exponent, bool as true/false. Any other type +// (including nil, e.g. an unresolved optional field) is not bindable. +func stringifyBindingValue(value interface{}) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), true + case bool: + return strconv.FormatBool(v), true + default: + return "", false + } +} diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index b4acfc2474..56ff97dec6 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -160,3 +160,162 @@ func TestSelect(t *testing.T) { assert.Equal(t, "vc-full", result.Candidates[0].VC.ID.String()) }) } + +func TestSelect_InitialBindings(t *testing.T) { + t.Run("initial binding selects the credential by field value", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}, WithInitialBindings(map[string]string{"patient_id": "456"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-2", result.Candidates[0].VC.ID.String()) + }) + + t.Run("caller-bound descriptor with multiple matches returns ErrMultipleCredentials", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vcA := parseVC(t, `{"id": "vc-a", "credentialSubject": {"patientId": "456"}}`) + vcB := parseVC(t, `{"id": "vc-b", "credentialSubject": {"patientId": "456"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{vcA, vcB}, WithInitialBindings(map[string]string{"patient_id": "456"})) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + }) + + t.Run("caller-bound descriptor with zero matches returns ErrNoCredentials", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}, WithInitialBindings(map[string]string{"patient_id": "nonexistent"})) + + assert.ErrorIs(t, err, ErrNoCredentials) + }) + + t.Run("numeric field value binding", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "room_access", + "constraints": {"fields": [{"id": "floor", "path": ["$.credentialSubject.floor"]}]} + }] + }`) + // JSON numbers unmarshal to float64; "3" must match float64(3). + vcA := parseVC(t, `{"id": "vc-a", "credentialSubject": {"floor": 1}}`) + vcB := parseVC(t, `{"id": "vc-b", "credentialSubject": {"floor": 3}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vcA, vcB}, WithInitialBindings(map[string]string{"floor": "3"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-b", result.Candidates[0].VC.ID.String()) + }) + + t.Run("boolean field value binding", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent", + "constraints": {"fields": [{"id": "granted", "path": ["$.credentialSubject.granted"]}]} + }] + }`) + vcTrue := parseVC(t, `{"id": "vc-true", "credentialSubject": {"granted": true}}`) + vcFalse := parseVC(t, `{"id": "vc-false", "credentialSubject": {"granted": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vcTrue, vcFalse}, WithInitialBindings(map[string]string{"granted": "false"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-false", result.Candidates[0].VC.ID.String()) + }) + + t.Run("multiple binding keys within a descriptor use AND", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "enrollment", + "constraints": {"fields": [ + {"id": "patient_id", "path": ["$.credentialSubject.patientId"]}, + {"id": "org_city", "path": ["$.credentialSubject.city"]} + ]} + }] + }`) + both := parseVC(t, `{"id": "vc-both", "credentialSubject": {"patientId": "123", "city": "Amsterdam"}}`) + // matches patient_id but not org_city -> inconsistent + partial := parseVC(t, `{"id": "vc-partial", "credentialSubject": {"patientId": "123", "city": "Rotterdam"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{partial, both}, WithInitialBindings(map[string]string{"patient_id": "123", "org_city": "Amsterdam"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-both", result.Candidates[0].VC.ID.String()) + }) + + t.Run("multiple descriptors with independent binding keys", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}}, + {"id": "patient_enrollment", "constraints": {"fields": [{"id": "bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + orgA := parseVC(t, `{"id": "org-a", "credentialSubject": {"ura": "URA-001"}}`) + orgB := parseVC(t, `{"id": "org-b", "credentialSubject": {"ura": "URA-002"}}`) + patC := parseVC(t, `{"id": "pat-c", "credentialSubject": {"bsn": "BSN-111"}}`) + patD := parseVC(t, `{"id": "pat-d", "credentialSubject": {"bsn": "BSN-222"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{orgA, orgB, patC, patD}, + WithInitialBindings(map[string]string{"ura": "URA-002", "bsn": "BSN-111"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "org-b", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "pat-c", result.Candidates[1].VC.ID.String()) + }) + + t.Run("unknown binding key is silently dropped", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}, + WithInitialBindings(map[string]string{"patient_id": "456", "nonexistent_field": "whatever"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-2", result.Candidates[0].VC.ID.String()) + }) +} From a5eb6fbedfbb2cfa97f98f8b45723cfb0cc0b493 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 29 May 2026 21:30:54 +0200 Subject: [PATCH 05/25] feat(pe): apply submission-requirement rules in Select When the presentation definition carries submission requirements, run the chosen assignment through the rule engine (groups, match, deduplicate) as step 3: an unsatisfied "all" group yields ErrNoCredentials, a satisfied "all" group keeps every descriptor, and a "pick" rule keeps its selected subset while clearing the descriptors it excludes (VC=nil). Without submission requirements every descriptor stays required. Assisted-by: AI --- vcr/pe/select.go | 63 ++++++++++++++++++++++++++++++++++++++++++- vcr/pe/select_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index aa731823cc..446e357b54 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -88,17 +88,78 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt }) } + // Step 3: enforce the submission-requirement rules. Candidates is returned even on error so + // callers can diagnose the assignment that failed. if len(pd.SubmissionRequirements) == 0 { // With no submission requirements every descriptor is required. - // Candidates is returned even on error so callers can diagnose the unfilled descriptors. if err := requireAllFilled(result.Candidates); err != nil { return result, err } + } else { + applied, err := applySubmissionRequirements(pd, result.Candidates) + if err != nil { + return result, err + } + result.Candidates = applied + } + + return result, nil +} + +// applySubmissionRequirements enforces the submission-requirement rules over the chosen assignment +// and returns it with rule-excluded descriptors cleared (VC=nil). +func applySubmissionRequirements(pd PresentationDefinition, candidates []Candidate) ([]Candidate, error) { + // Every group referenced by an input descriptor must be covered by a submission requirement. + availableGroups := make(map[string]groupCandidates) + for _, submissionRequirement := range pd.SubmissionRequirements { + for _, group := range submissionRequirement.groups() { + availableGroups[group] = groupCandidates{Name: group} + } + } + for _, group := range pd.groups() { + if _, ok := availableGroups[group.Name]; !ok { + return nil, fmt.Errorf("group '%s' is required but not available", group.Name) + } + } + for _, candidate := range candidates { + for _, group := range candidate.InputDescriptor.Group { + current := availableGroups[group] + current.Candidates = append(current.Candidates, candidate) + availableGroups[group] = current + } } + var selectedVCs []vc.VerifiableCredential + for _, submissionRequirement := range pd.SubmissionRequirements { + vcs, err := submissionRequirement.match(availableGroups) + if err != nil { + return nil, err + } + selectedVCs = append(selectedVCs, vcs...) + } + selectedVCs = deduplicate(selectedVCs) + + // Clear the descriptors whose chosen VC a rule excluded. + result := make([]Candidate, len(candidates)) + for i, candidate := range candidates { + result[i] = candidate + if candidate.VC == nil || !containsVC(selectedVCs, *candidate.VC) { + result[i].VC = nil + } + } return result, nil } +// containsVC reports whether target is present in vcs, compared by value. +func containsVC(vcs []vc.VerifiableCredential, target vc.VerifiableCredential) bool { + for _, candidate := range vcs { + if vcEqual(candidate, target) { + return true + } + } + return false +} + // requireAllFilled reports ErrNoCredentials when any descriptor was left unfilled. It encodes the // rule that, with no submission requirements, every descriptor is mandatory. func requireAllFilled(candidates []Candidate) error { diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 56ff97dec6..572e8a6835 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -319,3 +319,66 @@ func TestSelect_InitialBindings(t *testing.T) { assert.Equal(t, "vc-2", result.Candidates[0].VC.ID.String()) }) } + +func TestSelect_SubmissionRequirements(t *testing.T) { + t.Run("all rule with an unfilled group member returns ErrNoCredentials", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "all", "from": "A"}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + // only fills d1; nothing matches d2 + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"f1": "x"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + }) + + t.Run("all rule with every group member filled keeps all descriptors", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "all", "from": "A"}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"f1": "x"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"f2": "y"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "vc-2", result.Candidates[1].VC.ID.String()) + }) + + t.Run("pick rule selects a subset and clears the rest", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "A", "min": 1, "max": 1}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"f1": "x"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"f2": "y"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + // pick max 1 keeps the first group member in PD order and clears the rest + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) +} From 368ddb67beab5c89d8fb9740b878069a2964c232 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 12 Jun 2026 17:59:51 +0200 Subject: [PATCH 06/25] fix(pe): keep failing descriptor in Select candidates on error Result.Candidates promises a best-effort assignment even on error, but the caller-bound multiplicity check returned before appending the descriptor it failed on, leaving it out of the diagnostics. Append it unfilled before returning. The other error paths already kept their candidates; their tests now assert that contract instead of discarding the result. Assisted-by: AI --- vcr/pe/select.go | 2 ++ vcr/pe/select_test.go | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 446e357b54..5265bc9a78 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -75,7 +75,9 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt // Keep only candidates whose resolved id-values agree with the bindings (P3 consistency). consistent := consistentCandidates(eligible, options.initialBindings) // A descriptor pinned by the caller's bindings must resolve to exactly one candidate. + // The descriptor is still appended (unfilled) so Candidates reflects where selection failed. if len(consistent) > 1 && isCallerBound(*descriptor, options.initialBindings) { + result.Candidates = append(result.Candidates, Candidate{InputDescriptor: *descriptor}) return result, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrMultipleCredentials) } var selected *vc.VerifiableCredential diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 572e8a6835..301317e39e 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -91,9 +91,13 @@ func TestSelect(t *testing.T) { }`) cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) - _, err := Select(pd, []vc.VerifiableCredential{cred}) + result, err := Select(pd, []vc.VerifiableCredential{cred}) assert.ErrorIs(t, err, ErrNoCredentials) + // Candidates stays populated on error: the unfilled descriptor is present for diagnostics. + require.Len(t, result.Candidates, 1) + assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) + assert.Nil(t, result.Candidates[0].VC) }) t.Run("multiple required descriptors all fill", func(t *testing.T) { @@ -192,9 +196,13 @@ func TestSelect_InitialBindings(t *testing.T) { vcA := parseVC(t, `{"id": "vc-a", "credentialSubject": {"patientId": "456"}}`) vcB := parseVC(t, `{"id": "vc-b", "credentialSubject": {"patientId": "456"}}`) - _, err := Select(pd, []vc.VerifiableCredential{vcA, vcB}, WithInitialBindings(map[string]string{"patient_id": "456"})) + result, err := Select(pd, []vc.VerifiableCredential{vcA, vcB}, WithInitialBindings(map[string]string{"patient_id": "456"})) assert.ErrorIs(t, err, ErrMultipleCredentials) + // Candidates stays populated on error: the undecidable descriptor is present, unfilled. + require.Len(t, result.Candidates, 1) + assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) + assert.Nil(t, result.Candidates[0].VC) }) t.Run("caller-bound descriptor with zero matches returns ErrNoCredentials", func(t *testing.T) { @@ -333,9 +341,14 @@ func TestSelect_SubmissionRequirements(t *testing.T) { // only fills d1; nothing matches d2 cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"f1": "x"}}`) - _, err := Select(pd, []vc.VerifiableCredential{cred}) + result, err := Select(pd, []vc.VerifiableCredential{cred}) assert.ErrorIs(t, err, ErrNoCredentials) + // Candidates stays populated on error: it carries the assignment the rule rejected. + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) }) t.Run("all rule with every group member filled keeps all descriptors", func(t *testing.T) { From 6a11e5195dd27ff911354faf109ae5425c2120da Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 17:52:53 +0200 Subject: [PATCH 07/25] test(pe): backfill format-gating and nil-constraints coverage for Select Both eligibility gates (PD-level and descriptor-level format) and the nil-Constraints descriptor path were implemented without a driving test. Assisted-by: AI --- vcr/pe/select_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 301317e39e..ac9211497a 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -165,6 +165,63 @@ func TestSelect(t *testing.T) { }) } +func TestSelect_Eligibility(t *testing.T) { + t.Run("PD-level format excludes a non-matching proof type", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "format": {"ldp_vc": {"proof_type": ["JsonWebSignature2020"]}}, + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wrongProof := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}, "proof": [{"type": "RsaSignature2018"}]}`) + rightProof := parseVC(t, `{"id": "vc-right", "credentialSubject": {"patientId": "456"}, "proof": [{"type": "JsonWebSignature2020"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrongProof, rightProof}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-right", result.Candidates[0].VC.ID.String()) + }) + + t.Run("descriptor-level format excludes a non-matching proof type", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "format": {"ldp_vc": {"proof_type": ["JsonWebSignature2020"]}}, + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wrongProof := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}, "proof": [{"type": "RsaSignature2018"}]}`) + rightProof := parseVC(t, `{"id": "vc-right", "credentialSubject": {"patientId": "456"}, "proof": [{"type": "JsonWebSignature2020"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrongProof, rightProof}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-right", result.Candidates[0].VC.ID.String()) + }) + + t.Run("descriptor without constraints matches any credential", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{"id": "anything"}] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"foo": "bar"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + }) +} + func TestSelect_InitialBindings(t *testing.T) { t.Run("initial binding selects the credential by field value", func(t *testing.T) { pd := parsePD(t, `{ From 9d5637c0444ceb4316a3c2ff62f04c1437986484 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 17:53:52 +0200 Subject: [PATCH 08/25] fix(pe): keep Result.Candidates full length on selection error A caller-bound multiplicity error truncated Candidates after the failing descriptor. The contract is one entry per input descriptor, in PD order, on every path; MatchReport will depend on it. Assisted-by: AI --- vcr/pe/select.go | 12 ++++++++++-- vcr/pe/select_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 5265bc9a78..ecc9ec420d 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -67,6 +67,7 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt } var result Result + var selectErr error for _, descriptor := range pd.InputDescriptors { eligible, err := eligibleCandidates(pd, *descriptor, candidates) if err != nil { @@ -75,10 +76,14 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt // Keep only candidates whose resolved id-values agree with the bindings (P3 consistency). consistent := consistentCandidates(eligible, options.initialBindings) // A descriptor pinned by the caller's bindings must resolve to exactly one candidate. - // The descriptor is still appended (unfilled) so Candidates reflects where selection failed. + // The descriptor is left unfilled and the remaining descriptors are still evaluated, so + // Candidates keeps one entry per descriptor (best-effort) for diagnostics. if len(consistent) > 1 && isCallerBound(*descriptor, options.initialBindings) { result.Candidates = append(result.Candidates, Candidate{InputDescriptor: *descriptor}) - return result, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrMultipleCredentials) + if selectErr == nil { + selectErr = fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrMultipleCredentials) + } + continue } var selected *vc.VerifiableCredential if len(consistent) > 0 { @@ -89,6 +94,9 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt VC: selected, }) } + if selectErr != nil { + return result, selectErr + } // Step 3: enforce the submission-requirement rules. Candidates is returned even on error so // callers can diagnose the assignment that failed. diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index ac9211497a..611576d753 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -262,6 +262,33 @@ func TestSelect_InitialBindings(t *testing.T) { assert.Nil(t, result.Candidates[0].VC) }) + t.Run("candidates stay full length when a later descriptor errors", func(t *testing.T) { + // The failing descriptor comes first; the descriptor after it must still appear in + // Result.Candidates (one entry per descriptor, PD order, on every path). + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "patient_credential", "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]}}, + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}} + ] + }`) + patA := parseVC(t, `{"id": "pat-a", "credentialSubject": {"patientId": "456"}}`) + patB := parseVC(t, `{"id": "pat-b", "credentialSubject": {"patientId": "456"}}`) + org := parseVC(t, `{"id": "org-1", "credentialSubject": {"ura": "URA-001"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{patA, patB, org}, + WithInitialBindings(map[string]string{"patient_id": "456"})) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + require.Len(t, result.Candidates, 2) + assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) + assert.Nil(t, result.Candidates[0].VC) + // best-effort: the unaffected descriptor is still reported with its match + assert.Equal(t, "org_credential", result.Candidates[1].InputDescriptor.Id) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "org-1", result.Candidates[1].VC.ID.String()) + }) + t.Run("caller-bound descriptor with zero matches returns ErrNoCredentials", func(t *testing.T) { pd := parsePD(t, `{ "id": "test-pd", From 5c70bf619fe017cd47f11607b154ba441821703a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 17:57:09 +0200 Subject: [PATCH 09/25] refactor(pe): replace flat selection loop with recursive search core The flat greedy loop cannot express the backtracking that Policies 3-5 require (a later conflict must be able to revise an earlier choice). Restructure step 2 as a recursive depth-first search that threads a running bindings map and restores it on backtrack, with optionality derived from the submission requirements (coarse rule). Eligible credentials are grouped per descriptor by their binding tuple (interchangeable credentials form one group) and indexed by id value, so consistency filtering is a lookup instead of a scan; wallets holding many credentials of one type stay linear. The legacy caller-bound multiplicity check runs as a pre-pass tied to the initial bindings only, deliberately separate from whole-assignment ambiguity. Behavior is unchanged for every pinned case; the black-box suite stays green without modification. Assisted-by: AI --- vcr/pe/select.go | 385 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 327 insertions(+), 58 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index ecc9ec420d..942bf3b974 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -21,6 +21,7 @@ package pe import ( "errors" "fmt" + "sort" "strconv" "strings" @@ -49,8 +50,10 @@ func WithInitialBindings(b map[string]string) Option { // Result is the outcome of a Select call. type Result struct { - // Candidates pairs every input descriptor (in PD order) with the VC chosen for it. - // A nil VC means the descriptor was left unfilled (optional and skipped, or dropped by a rule). + // Candidates pairs every input descriptor (in PD order) with the VC chosen for it, on every + // path including errors (best-effort on failure, for diagnostics). + // A nil VC means the descriptor was left unfilled (optional and skipped, dropped by a rule, + // or unfillable). Candidates []Candidate // Bindings holds the resolved field-id to value pairs of the chosen assignment. Bindings map[string]string @@ -58,44 +61,70 @@ type Result struct { // Select resolves a presentation definition against a set of candidate credentials and // returns the chosen descriptor-to-VC assignment. It is the single matching engine: it -// matches each descriptor on its own, searches for a binding-consistent combination across -// descriptors, and applies the submission requirement rules. +// matches each descriptor on its own (step 1), searches for a binding-consistent combination +// across descriptors (step 2), and applies the submission requirement rules (step 3). func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (Result, error) { var options selectOptions for _, opt := range opts { opt(&options) } - var result Result - var selectErr error - for _, descriptor := range pd.InputDescriptors { - eligible, err := eligibleCandidates(pd, *descriptor, candidates) + result := Result{Candidates: make([]Candidate, len(pd.InputDescriptors))} + for i, descriptor := range pd.InputDescriptors { + result.Candidates[i].InputDescriptor = *descriptor + } + + // Step 1: per-descriptor eligibility, indexed for the search. + pools := make([]descriptorPool, len(pd.InputDescriptors)) + for i, descriptor := range pd.InputDescriptors { + pool, err := buildPool(pd, *descriptor, candidates) if err != nil { return result, err } - // Keep only candidates whose resolved id-values agree with the bindings (P3 consistency). - consistent := consistentCandidates(eligible, options.initialBindings) - // A descriptor pinned by the caller's bindings must resolve to exactly one candidate. - // The descriptor is left unfilled and the remaining descriptors are still evaluated, so - // Candidates keeps one entry per descriptor (best-effort) for diagnostics. - if len(consistent) > 1 && isCallerBound(*descriptor, options.initialBindings) { - result.Candidates = append(result.Candidates, Candidate{InputDescriptor: *descriptor}) - if selectErr == nil { - selectErr = fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrMultipleCredentials) + pools[i] = pool + } + + // A descriptor pinned by the caller's bindings must resolve to exactly one credential (the + // legacy field-selector contract). This is a per-descriptor check against the initial + // bindings only; it is deliberately separate from the whole-assignment ambiguity check. + var boundErr error + boundFailed := make([]bool, len(pools)) + for i := range pools { + if err := callerBoundError(pools[i], options.initialBindings); err != nil { + boundFailed[i] = true + if boundErr == nil { + boundErr = err + } + } + } + + // Step 2: search for a complete binding-consistent assignment. + assignment := make([]*candidateGroup, len(pools)) + found := false + if boundErr == nil { + s := searcher{pools: pools, required: requiredDescriptors(pd)} + found = s.search(0, copyBindings(options.initialBindings), assignment) + } + if !found { + // Best-effort assignment for diagnostics: each descriptor on its own, first candidate + // consistent with the initial bindings. + for i := range pools { + assignment[i] = nil + if boundFailed[i] { + continue + } + if groups := pools[i].consistentGroups(options.initialBindings); len(groups) > 0 { + assignment[i] = &pools[i].groups[groups[0]] } - continue } - var selected *vc.VerifiableCredential - if len(consistent) > 0 { - selected = &consistent[0].vc + } + for i := range assignment { + if assignment[i] != nil { + result.Candidates[i].VC = &assignment[i].creds[0] } - result.Candidates = append(result.Candidates, Candidate{ - InputDescriptor: *descriptor, - VC: selected, - }) } - if selectErr != nil { - return result, selectErr + if boundErr != nil { + return result, boundErr } // Step 3: enforce the submission-requirement rules. Candidates is returned even on error so @@ -112,10 +141,278 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt } result.Candidates = applied } + if !found { + // The search proved no consistent assignment exists; the diagnostic assignment satisfying + // the rules anyway (possible once bindings conflict across descriptors) is not a success. + return result, fmt.Errorf("no binding-consistent assignment found: %w", ErrNoCredentials) + } return result, nil } +// searcher carries the immutable inputs of the step-2 backtracking search. +type searcher struct { + pools []descriptorPool + required []bool +} + +// search fills assignment[i:] with a binding-consistent choice per descriptor, depth-first in PD +// order, and reports whether a complete assignment was found. Candidates are preferred over +// skipping: an optional descriptor is left unfilled (nil) only after all its consistent candidates +// have been tried; a required descriptor with no consistent candidate fails the branch, which +// backtracks into a different choice at an earlier descriptor. The bindings map is mutated during +// descent and restored on backtrack. +func (s searcher) search(i int, bindings map[string]string, assignment []*candidateGroup) bool { + if i == len(s.pools) { + return true + } + for _, gi := range s.pools[i].consistentGroups(bindings) { + group := &s.pools[i].groups[gi] + assignment[i] = group + added := addBindings(bindings, group.idValues) + if s.search(i+1, bindings, assignment) { + return true + } + for _, key := range added { + delete(bindings, key) + } + } + assignment[i] = nil + if !s.required[i] { + return s.search(i+1, bindings, assignment) + } + return false +} + +// addBindings merges a chosen candidate's id-values into the running bindings and returns the keys +// it actually added, so the caller can restore the map on backtrack. Keys already bound are left +// untouched: the candidate passed the consistency check, so its values agree. +func addBindings(bindings map[string]string, idValues map[string]string) []string { + var added []string + for key, value := range idValues { + if _, bound := bindings[key]; !bound { + bindings[key] = value + added = append(added, key) + } + } + return added +} + +// requiredDescriptors derives, per input descriptor, whether the search must fill it. This is the +// coarse rule: with no submission requirements every descriptor is required; otherwise only +// descriptors whose group is demanded in full by an "all" rule are required. Members of "pick" +// groups may be left unfilled by the search; whether enough of them were filled is checked by the +// rule engine in step 3 (the documented pick-min-floor deferral). +func requiredDescriptors(pd PresentationDefinition) []bool { + required := make([]bool, len(pd.InputDescriptors)) + if len(pd.SubmissionRequirements) == 0 { + for i := range required { + required[i] = true + } + return required + } + allGroups := make(map[string]bool) + var collect func(requirement SubmissionRequirement) + collect = func(requirement SubmissionRequirement) { + if requirement.Rule != "all" { + // members under a "pick" are selectable, never individually required + return + } + if requirement.From != "" { + allGroups[requirement.From] = true + } + for _, nested := range requirement.FromNested { + collect(*nested) + } + } + for _, requirement := range pd.SubmissionRequirements { + collect(*requirement) + } + for i, descriptor := range pd.InputDescriptors { + for _, group := range descriptor.Group { + if allGroups[group] { + required[i] = true + break + } + } + } + return required +} + +// callerBoundError reproduces the legacy field-selector contract: a descriptor pinned by the +// caller's bindings must resolve to exactly one credential; more than one is +// ErrMultipleCredentials. Counted per credential (not per binding tuple), because that is the +// behavior existing callers rely on. Zero matches is a soft failure: the descriptor is left +// unfilled and step 3 decides. +func callerBoundError(pool descriptorPool, initialBindings map[string]string) error { + if !isCallerBound(pool.descriptor, initialBindings) { + return nil + } + count := 0 + for _, gi := range pool.consistentGroups(initialBindings) { + count += len(pool.groups[gi].creds) + } + if count > 1 { + return fmt.Errorf("input descriptor '%s': %w", pool.descriptor.Id, ErrMultipleCredentials) + } + return nil +} + +// candidateGroup is a set of credentials that are interchangeable for one descriptor: they passed +// its constraints and resolve identical values for every id-bearing field. The search branches per +// group, not per credential; creds[0] represents the group in the final assignment. +type candidateGroup struct { + idValues map[string]string + creds []vc.VerifiableCredential // in candidate order +} + +// descriptorPool is the step-1 output for one descriptor: its eligible credentials grouped by +// binding tuple, with inverted indexes so that consistency filtering during the search is a lookup +// instead of a scan over the pool. +type descriptorPool struct { + descriptor InputDescriptor + groups []candidateGroup + // byValue maps field id -> resolved value -> ascending indices of groups carrying that value. + // Its key set is the pool's id universe: ids resolved by at least one group. + byValue map[string]map[string][]int + // lacking maps field id (from the same universe) -> ascending indices of groups that do not + // resolve the id. Such groups are consistent with any bound value for it. + lacking map[string][]int +} + +// 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) + if err != nil { + return descriptorPool{}, err + } + pool := descriptorPool{ + descriptor: descriptor, + byValue: make(map[string]map[string][]int), + lacking: make(map[string][]int), + } + groupIndex := make(map[string]int) + for _, candidate := range eligible { + key := tupleKey(candidate.idValues) + gi, ok := groupIndex[key] + if !ok { + gi = len(pool.groups) + groupIndex[key] = gi + pool.groups = append(pool.groups, candidateGroup{idValues: candidate.idValues}) + } + pool.groups[gi].creds = append(pool.groups[gi].creds, candidate.vc) + } + for gi, group := range pool.groups { + for id, value := range group.idValues { + values := pool.byValue[id] + if values == nil { + values = make(map[string][]int) + pool.byValue[id] = values + } + values[value] = append(values[value], gi) + } + } + for id := range pool.byValue { + for gi, group := range pool.groups { + if _, ok := group.idValues[id]; !ok { + pool.lacking[id] = append(pool.lacking[id], gi) + } + } + } + return pool, nil +} + +// consistentGroups returns the ascending indices of the groups that agree with the bindings on +// every shared id. Bound ids outside the pool's universe are irrelevant. When at least one bound +// id is in the universe, the scan is narrowed to the smallest posting list (groups carrying the +// bound value, plus groups not resolving the id at all) before the full per-group check. +func (p descriptorPool) consistentGroups(bindings map[string]string) []int { + var narrowed []int + haveNarrowed := false + for id, value := range bindings { + values, known := p.byValue[id] + if !known { + continue + } + merged := mergeSorted(values[value], p.lacking[id]) + if !haveNarrowed || len(merged) < len(narrowed) { + narrowed, haveNarrowed = merged, true + } + } + if !haveNarrowed { + all := make([]int, len(p.groups)) + for i := range all { + all[i] = i + } + return all + } + var consistent []int + for _, gi := range narrowed { + if consistentIDValues(p.groups[gi].idValues, bindings) { + consistent = append(consistent, gi) + } + } + return consistent +} + +// consistentIDValues reports whether resolved id-values agree with the bindings on every shared +// id. An id one side does not carry is irrelevant, so a stray binding key has no effect. +func consistentIDValues(idValues map[string]string, bindings map[string]string) bool { + for id, value := range idValues { + if bound, ok := bindings[id]; ok && bound != value { + return false + } + } + return true +} + +// mergeSorted merges two ascending, disjoint index slices into one ascending slice. +func mergeSorted(a, b []int) []int { + merged := make([]int, 0, len(a)+len(b)) + i, j := 0, 0 + for i < len(a) && j < len(b) { + if a[i] < b[j] { + merged = append(merged, a[i]) + i++ + } else { + merged = append(merged, b[j]) + j++ + } + } + merged = append(merged, a[i:]...) + merged = append(merged, b[j:]...) + return merged +} + +// tupleKey renders id-values as a canonical string so credentials with identical binding tuples +// land in the same group. +func tupleKey(idValues map[string]string) string { + ids := make([]string, 0, len(idValues)) + for id := range idValues { + ids = append(ids, id) + } + sort.Strings(ids) + var builder strings.Builder + for _, id := range ids { + builder.WriteString(strconv.Quote(id)) + builder.WriteByte(':') + builder.WriteString(strconv.Quote(idValues[id])) + builder.WriteByte(',') + } + return builder.String() +} + +// copyBindings clones the initial bindings so the search can mutate its working map freely. +func copyBindings(bindings map[string]string) map[string]string { + copied := make(map[string]string, len(bindings)) + for key, value := range bindings { + copied[key] = value + } + return copied +} + // applySubmissionRequirements enforces the submission-requirement rules over the chosen assignment // and returns it with rule-excluded descriptors cleared (VC=nil). func applySubmissionRequirements(pd PresentationDefinition, candidates []Candidate) ([]Candidate, error) { @@ -193,13 +490,12 @@ type eligibleCandidate struct { } // eligibleCandidates returns the credentials that satisfy a single input descriptor on its own: -// its constraints (matchConstraint) and both the PD-level and descriptor-level format gates. This -// is step 1, evaluated independently of any cross-descriptor binding. The matched id-bearing field -// values are recorded (stringified) for later consistency checks. +// its constraints (matchConstraint) and both the PD-level and descriptor-level format gates. The +// matched id-bearing field values are recorded (stringified) for later consistency checks. func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]eligibleCandidate, error) { var eligible []eligibleCandidate for _, candidate := range candidates { - var idValues map[string]string + idValues := make(map[string]string) if descriptor.Constraints != nil { isMatch, values, err := matchConstraint(descriptor.Constraints, candidate) if err != nil { @@ -208,7 +504,6 @@ func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, c if !isMatch { continue } - idValues = make(map[string]string) for id, value := range values { if s, ok := stringifyBindingValue(value); ok { idValues[id] = s @@ -224,32 +519,6 @@ func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, c return eligible, nil } -// consistentCandidates keeps the eligible candidates whose resolved id-values agree with the -// running bindings. With no bindings every eligible candidate is consistent. -func consistentCandidates(eligible []eligibleCandidate, bindings map[string]string) []eligibleCandidate { - if len(bindings) == 0 { - return eligible - } - var consistent []eligibleCandidate - for _, candidate := range eligible { - if candidate.consistentWith(bindings) { - consistent = append(consistent, candidate) - } - } - return consistent -} - -// consistentWith reports whether the candidate agrees with the bindings on every shared id. A -// binding key the candidate does not resolve is irrelevant, so a stray key has no effect. -func (c eligibleCandidate) consistentWith(bindings map[string]string) bool { - for id, value := range c.idValues { - if bound, ok := bindings[id]; ok && bound != value { - return false - } - } - return true -} - // isCallerBound reports whether the caller pinned this descriptor by binding one of its field ids. // Such a descriptor must resolve to a single credential, mirroring the legacy field selector. func isCallerBound(descriptor InputDescriptor, bindings map[string]string) bool { From bb6906d33dc864406674bc83929f2d66602acffc Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 17:59:03 +0200 Subject: [PATCH 10/25] test(pe): pin same-id binding policies with the PRD worked examples P3 (same-id agreement with backtracking), P4 (optional skip only after exhausting alternatives) and the either-or pick-1 case are satisfied by the recursive search core; these tests protect the worked examples from the PRD. Assisted-by: AI --- vcr/pe/select_test.go | 103 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 611576d753..dfbc4010a4 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -412,6 +412,109 @@ func TestSelect_InitialBindings(t *testing.T) { }) } +func TestSelect_SameIDBinding(t *testing.T) { + t.Run("same id across descriptors must agree, backtracking into a consistent pair", func(t *testing.T) { + // Policy 3 worked example: HCP-A (ura=1) is tried first, conflicts with the only + // delegation (ura=2), so the search backtracks and lands on (HCP-B, Delegation-X). + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "id_healthcare_provider", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "HCPCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}}, + {"id": "id_professional_delegation", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "DelegationCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}} + ] + }`) + hcpA := parseVC(t, `{"id": "hcp-a", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "1"}}`) + hcpB := parseVC(t, `{"id": "hcp-b", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "2"}}`) + delegationX := parseVC(t, `{"id": "delegation-x", "type": ["VerifiableCredential", "DelegationCredential"], "credentialSubject": {"ura": "2"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{hcpA, hcpB, delegationX}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "hcp-b", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "delegation-x", result.Candidates[1].VC.ID.String()) + }) + + t.Run("optional descriptor is skipped only after exhausting alternatives elsewhere", func(t *testing.T) { + // Policy 4 worked example: A1's binding admits B1 but kills C1; A2's binding kills B1 + // but admits C1. Skipping optional B under A1 does not save C, so the search must + // revise A. Expected: A=A2, B=nil, C=C1. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1}, + {"rule": "all", "from": "GC"} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "C", "group": ["GC"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "CCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "1"}}`) + a2 := parseVC(t, `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "2"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "1"}}`) + c1 := parseVC(t, `{"id": "c1", "type": ["VerifiableCredential", "CCredential"], "credentialSubject": {"foo": "2"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, a2, b1, c1}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 3) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a2", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + require.NotNil(t, result.Candidates[2].VC) + assert.Equal(t, "c1", result.Candidates[2].VC.ID.String()) + }) + + t.Run("either-or pick-1 sharing an id fills one descriptor and skips the other", func(t *testing.T) { + // Both descriptors carry doctor_id but resolve different values; a pick-1 group is + // satisfied by the first in PD order, without a binding conflict or ambiguity error. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "doctor", "count": 1}], + "input_descriptors": [ + {"id": "enrollment", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "EnrollmentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}}, + {"id": "consent", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ConsentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}} + ] + }`) + enrollment := parseVC(t, `{"id": "enrollment-1", "type": ["VerifiableCredential", "EnrollmentCredential"], "credentialSubject": {"doctorId": "A"}}`) + consent := parseVC(t, `{"id": "consent-1", "type": ["VerifiableCredential", "ConsentCredential"], "credentialSubject": {"doctorId": "B"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{enrollment, consent}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "enrollment-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) +} + func TestSelect_SubmissionRequirements(t *testing.T) { t.Run("all rule with an unfilled group member returns ErrNoCredentials", func(t *testing.T) { pd := parsePD(t, `{ From 18d27d0a81a123efc2b5a1d10f66b2907b1eb66c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 18:01:22 +0200 Subject: [PATCH 11/25] feat(pe): make initial bindings strict, pin P6 non-binding semantics A caller-bound field id must resolve to the bound value on the chosen candidate; a candidate whose optional field does not resolve is rejected for that descriptor. This preserves the legacy field-selector semantics (a caller who pins a value never receives a credential without it) and keeps all-unresolved wallets at ErrNoCredentials instead of drifting into ErrMultipleCredentials. Policy 6 leniency (unresolved optional does not bind) applies only to bindings accumulated between descriptors during the search, now pinned by its own test. Assisted-by: AI --- vcr/pe/select.go | 82 +++++++++++++++++++++++++++++++------------ vcr/pe/select_test.go | 73 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 23 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 942bf3b974..c54fe7ceb6 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -81,6 +81,7 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt if err != nil { return result, err } + pool.strictIDs = strictBoundIDs(*descriptor, options.initialBindings) pools[i] = pool } @@ -245,7 +246,7 @@ func requiredDescriptors(pd PresentationDefinition) []bool { // behavior existing callers rely on. Zero matches is a soft failure: the descriptor is left // unfilled and step 3 decides. func callerBoundError(pool descriptorPool, initialBindings map[string]string) error { - if !isCallerBound(pool.descriptor, initialBindings) { + if len(pool.strictIDs) == 0 { return nil } count := 0 @@ -278,6 +279,27 @@ type descriptorPool struct { // lacking maps field id (from the same universe) -> ascending indices of groups that do not // resolve the id. Such groups are consistent with any bound value for it. lacking map[string][]int + // strictIDs are the descriptor's field ids bound by the caller. For these ids a group must + // resolve the field to the bound value; unresolved is not acceptable (legacy field-selector + // semantics). Policy 6 leniency covers only bindings accumulated during the search. + strictIDs []string +} + +// strictBoundIDs returns the descriptor's field ids that appear in the caller's initial bindings. +func strictBoundIDs(descriptor InputDescriptor, initialBindings map[string]string) []string { + if descriptor.Constraints == nil { + return nil + } + var ids []string + for _, field := range descriptor.Constraints.Fields { + if field.Id == nil { + continue + } + if _, ok := initialBindings[*field.Id]; ok { + ids = append(ids, *field.Id) + } + } + return ids } // buildPool evaluates every candidate against a single input descriptor (its constraints and both @@ -325,12 +347,27 @@ func buildPool(pd PresentationDefinition, descriptor InputDescriptor, candidates } // consistentGroups returns the ascending indices of the groups that agree with the bindings on -// every shared id. Bound ids outside the pool's universe are irrelevant. When at least one bound -// id is in the universe, the scan is narrowed to the smallest posting list (groups carrying the -// bound value, plus groups not resolving the id at all) before the full per-group check. +// every shared id. Bound ids outside the pool's universe are irrelevant, except strict ids, which +// a group must resolve to the bound value. When at least one bound id is in the universe, the scan +// is narrowed to the smallest posting list (groups carrying the bound value, plus, for non-strict +// ids, groups not resolving the id at all) before the full per-group check. func (p descriptorPool) consistentGroups(bindings map[string]string) []int { var narrowed []int haveNarrowed := false + for _, id := range p.strictIDs { + bound, ok := bindings[id] + if !ok { + continue + } + // no lacking-list here: a group that does not resolve a strict id is out + list := p.byValue[id][bound] + if len(list) == 0 { + return nil + } + if !haveNarrowed || len(list) < len(narrowed) { + narrowed, haveNarrowed = list, true + } + } for id, value := range bindings { values, known := p.byValue[id] if !known { @@ -350,13 +387,29 @@ func (p descriptorPool) consistentGroups(bindings map[string]string) []int { } var consistent []int for _, gi := range narrowed { - if consistentIDValues(p.groups[gi].idValues, bindings) { - consistent = append(consistent, gi) + group := p.groups[gi] + if !consistentIDValues(group.idValues, bindings) { + continue + } + if !resolvesStrictIDs(group, p.strictIDs) { + continue } + consistent = append(consistent, gi) } return consistent } +// resolvesStrictIDs reports whether the group resolves every strictly bound id. Equality with the +// bound value is covered by the consistency check; this guards only against unresolved fields. +func resolvesStrictIDs(group candidateGroup, strictIDs []string) bool { + for _, id := range strictIDs { + if _, ok := group.idValues[id]; !ok { + return false + } + } + return true +} + // consistentIDValues reports whether resolved id-values agree with the bindings on every shared // id. An id one side does not carry is irrelevant, so a stray binding key has no effect. func consistentIDValues(idValues map[string]string, bindings map[string]string) bool { @@ -519,23 +572,6 @@ func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, c return eligible, nil } -// isCallerBound reports whether the caller pinned this descriptor by binding one of its field ids. -// Such a descriptor must resolve to a single credential, mirroring the legacy field selector. -func isCallerBound(descriptor InputDescriptor, bindings map[string]string) bool { - if descriptor.Constraints == nil { - return false - } - for _, field := range descriptor.Constraints.Fields { - if field.Id == nil { - continue - } - if _, ok := bindings[*field.Id]; ok { - return true - } - } - return false -} - // stringifyBindingValue renders a resolved field value as the string used for binding comparison: // strings as-is, float64 without a trailing zero exponent, bool as true/false. Any other type // (including nil, e.g. an unresolved optional field) is not bindable. diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index dfbc4010a4..d1adfb467f 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -391,6 +391,79 @@ func TestSelect_InitialBindings(t *testing.T) { assert.Equal(t, "pat-c", result.Candidates[1].VC.ID.String()) }) + t.Run("unresolved optional field does not bind between descriptors", func(t *testing.T) { + // Policy 6: descriptor A's optional foo does not resolve on its candidate, so it must + // not manufacture a binding that conflicts with descriptor B's resolved foo=Z. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "unrelated"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Z"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, b1}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("caller-bound field must resolve: all candidates unresolved is ErrNoCredentials", func(t *testing.T) { + // Initial bindings are strict (legacy field-selector semantics): a caller who pins foo + // never receives a credential without a resolvable foo. Two such candidates must not + // drift into ErrMultipleCredentials via the P6 unresolved-is-consistent leniency. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]} + }] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "x"}}`) + a2 := parseVC(t, `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "y"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{a1, a2}, WithInitialBindings(map[string]string{"foo": "Z"})) + + assert.ErrorIs(t, err, ErrNoCredentials) + }) + + t.Run("caller-bound field must resolve: the resolving candidate wins over unresolved ones", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]} + }] + }`) + unresolved := parseVC(t, `{"id": "a-unresolved", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "x"}}`) + resolving := parseVC(t, `{"id": "a-resolving", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Z"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{unresolved, resolving}, WithInitialBindings(map[string]string{"foo": "Z"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a-resolving", result.Candidates[0].VC.ID.String()) + }) + t.Run("unknown binding key is silently dropped", func(t *testing.T) { pd := parsePD(t, `{ "id": "test-pd", From ceed7ef648dd33799e0256a03cd77b7438fd0113 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 18:04:31 +0200 Subject: [PATCH 12/25] feat(pe): add Strict strategy with binding-tuple ambiguity (Policy 5) WithStrategy(Strict) keeps searching past the first complete assignment and errors when a rival exists. Rivals are counted per binding tuple: a later assignment is a rival only when it fills a descriptor the first one also fills with different id-bearing values. Interchangeable credentials share a group and can never differ, so reissued duplicates do not error, and a skipped descriptor is not an alternative to a filled one, which keeps either-or pick groups deterministic. The error names the ambiguous descriptors so the caller knows which credential_selection keys to add. Assisted-by: AI --- vcr/pe/select.go | 96 +++++++++++++++++++++++++++++------- vcr/pe/select_test.go | 112 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 17 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index c54fe7ceb6..d619d05977 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -28,10 +28,25 @@ import ( "github.com/nuts-foundation/go-did/vc" ) +// SelectionStrategy controls what Select does when more than one complete, binding-consistent +// assignment exists. It gates only the ambiguity policy; binding consistency itself is always on. +type SelectionStrategy int + +const ( + // FirstMatch takes the first consistent assignment (the lenient, backward-compatible default). + FirstMatch SelectionStrategy = iota + // Strict returns ErrMultipleCredentials when a rival assignment exists: one that fills a + // common descriptor with different id-bearing values. Interchangeable credentials (identical + // binding tuples) are never rivals, since no credential_selection key could separate them. + Strict +) + // selectOptions holds the knobs configured by the Option functions passed to Select. type selectOptions struct { // initialBindings seeds the id->value bindings (typically from a credential_selection parameter). initialBindings map[string]string + // strategy selects the ambiguity policy; the zero value is FirstMatch. + strategy SelectionStrategy } // Option configures a single Select call. Options exist so that callers can opt in to @@ -48,6 +63,13 @@ func WithInitialBindings(b map[string]string) Option { } } +// WithStrategy selects the ambiguity policy; the default is FirstMatch. +func WithStrategy(s SelectionStrategy) Option { + return func(o *selectOptions) { + o.strategy = s + } +} + // Result is the outcome of a Select call. type Result struct { // Candidates pairs every input descriptor (in PD order) with the VC chosen for it, on every @@ -102,9 +124,15 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt // Step 2: search for a complete binding-consistent assignment. assignment := make([]*candidateGroup, len(pools)) found := false + var ambiguous []string if boundErr == nil { - s := searcher{pools: pools, required: requiredDescriptors(pd)} - found = s.search(0, copyBindings(options.initialBindings), assignment) + s := &searcher{pools: pools, required: requiredDescriptors(pd), strict: options.strategy == Strict} + s.search(0, copyBindings(options.initialBindings), make([]*candidateGroup, len(pools))) + found = s.first != nil + ambiguous = s.ambiguous + if found { + copy(assignment, s.first) + } } if !found { // Best-effort assignment for diagnostics: each descriptor on its own, first candidate @@ -127,6 +155,10 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt if boundErr != nil { return result, boundErr } + if len(ambiguous) > 0 { + // The decisive assignment stays in Candidates so the caller can see what would have won. + return result, fmt.Errorf("ambiguous input descriptors %v: %w", ambiguous, ErrMultipleCredentials) + } // Step 3: enforce the submission-requirement rules. Candidates is returned even on error so // callers can diagnose the assignment that failed. @@ -151,36 +183,66 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt return result, nil } -// searcher carries the immutable inputs of the step-2 backtracking search. +// searcher carries the inputs and outcome of the step-2 backtracking search. type searcher struct { pools []descriptorPool required []bool + strict bool + // first is the first complete assignment found (the decisive one), nil until then. + first []*candidateGroup + // ambiguous holds, under Strict, the descriptors a rival assignment fills differently. + ambiguous []string } -// search fills assignment[i:] with a binding-consistent choice per descriptor, depth-first in PD -// order, and reports whether a complete assignment was found. Candidates are preferred over -// skipping: an optional descriptor is left unfilled (nil) only after all its consistent candidates -// have been tried; a required descriptor with no consistent candidate fails the branch, which -// backtracks into a different choice at an earlier descriptor. The bindings map is mutated during -// descent and restored on backtrack. -func (s searcher) search(i int, bindings map[string]string, assignment []*candidateGroup) bool { +// search explores binding-consistent choices per descriptor, depth-first in PD order, and reports +// whether the caller should stop descending. Candidates are preferred over skipping: an optional +// descriptor is left unfilled (nil) only after all its consistent candidates have been tried; a +// required descriptor with no consistent candidate fails the branch, which backtracks into a +// different choice at an earlier descriptor. The bindings map is mutated during descent and +// restored on backtrack. Under FirstMatch the search stops at the first complete assignment; +// under Strict it continues until a rival assignment is found or the space is exhausted. +func (s *searcher) search(i int, bindings map[string]string, current []*candidateGroup) bool { if i == len(s.pools) { - return true + return s.emit(current) } for _, gi := range s.pools[i].consistentGroups(bindings) { group := &s.pools[i].groups[gi] - assignment[i] = group + current[i] = group added := addBindings(bindings, group.idValues) - if s.search(i+1, bindings, assignment) { - return true - } + stop := s.search(i+1, bindings, current) for _, key := range added { delete(bindings, key) } + if stop { + return true + } } - assignment[i] = nil + current[i] = nil if !s.required[i] { - return s.search(i+1, bindings, assignment) + return s.search(i+1, bindings, current) + } + return false +} + +// emit records a complete assignment. The first one is kept as the decisive assignment. Under +// Strict, a later assignment is a rival only when it fills a descriptor the first one also fills +// with a different binding tuple: interchangeable credentials share a group and can never differ, +// and a skipped descriptor is not an alternative to a filled one (the skip branch is the +// after-exhaustion path, which keeps either-or pick groups deterministic). +func (s *searcher) emit(current []*candidateGroup) bool { + if s.first == nil { + s.first = append([]*candidateGroup(nil), current...) + return !s.strict + } + var ambiguous []string + for i := range current { + if s.first[i] != nil && current[i] != nil && s.first[i] != current[i] { + ambiguous = append(ambiguous, s.pools[i].descriptor.Id) + } + } + if len(ambiguous) > 0 { + s.ambiguous = ambiguous + return true } return false } diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index d1adfb467f..9641101e58 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -588,6 +588,118 @@ func TestSelect_SameIDBinding(t *testing.T) { }) } +func TestSelect_Strategy(t *testing.T) { + // Policy 5 worked example: two genuinely different ways to satisfy the PD. + ambiguousPD := `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }` + a1 := `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}` + a2 := `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Y"}}` + b1 := `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "X"}}` + b2 := `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Y"}}` + + t.Run("Strict reports a rival assignment naming the ambiguous descriptors", func(t *testing.T) { + pd := parsePD(t, ambiguousPD) + creds := []vc.VerifiableCredential{parseVC(t, a1), parseVC(t, a2), parseVC(t, b1), parseVC(t, b2)} + + result, err := Select(pd, creds, WithStrategy(Strict)) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + assert.ErrorContains(t, err, "A") + assert.ErrorContains(t, err, "B") + // the decisive (first) assignment is still reported for diagnostics + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + }) + + t.Run("FirstMatch returns the first assignment for the same PD", func(t *testing.T) { + pd := parsePD(t, ambiguousPD) + creds := []vc.VerifiableCredential{parseVC(t, a1), parseVC(t, a2), parseVC(t, b1), parseVC(t, b2)} + + result, err := Select(pd, creds) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("Strict with a disambiguating initial binding succeeds", func(t *testing.T) { + pd := parsePD(t, ambiguousPD) + creds := []vc.VerifiableCredential{parseVC(t, a1), parseVC(t, a2), parseVC(t, b1), parseVC(t, b2)} + + result, err := Select(pd, creds, WithStrategy(Strict), WithInitialBindings(map[string]string{"foo": "X"})) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("Strict does not flag interchangeable credentials", func(t *testing.T) { + // A reissued duplicate: identical id-bearing values, one binding tuple, one rival. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]} + }] + }`) + first := parseVC(t, `{"id": "a-first", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`) + duplicate := parseVC(t, `{"id": "a-duplicate", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{first, duplicate}, WithStrategy(Strict)) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a-first", result.Candidates[0].VC.ID.String()) + }) + + t.Run("Strict does not flag an either-or pick-1 group", func(t *testing.T) { + // The alternatives live in different descriptors of a pick-1 group; no descriptor is + // filled by both assignments, so there is no rival. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "doctor", "count": 1}], + "input_descriptors": [ + {"id": "enrollment", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "EnrollmentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}}, + {"id": "consent", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ConsentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}} + ] + }`) + enrollment := parseVC(t, `{"id": "enrollment-1", "type": ["VerifiableCredential", "EnrollmentCredential"], "credentialSubject": {"doctorId": "A"}}`) + consent := parseVC(t, `{"id": "consent-1", "type": ["VerifiableCredential", "ConsentCredential"], "credentialSubject": {"doctorId": "B"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{enrollment, consent}, WithStrategy(Strict)) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "enrollment-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) +} + func TestSelect_SubmissionRequirements(t *testing.T) { t.Run("all rule with an unfilled group member returns ErrNoCredentials", func(t *testing.T) { pd := parsePD(t, `{ From b03249fc5b3a71b19793e30e08033e1b83d32122 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 18:05:51 +0200 Subject: [PATCH 13/25] feat(pe): expose resolved id-values as Result.Bindings Bindings carries the id->value pairs of the credentials that survive rule application, enabling the two-VP composition: filter the org result's bindings to the SP PD's field ids and seed the SP Select call. Unknown initial keys are not echoed and errors produce no bindings. Assisted-by: AI --- vcr/pe/select.go | 12 +++++ vcr/pe/select_test.go | 117 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index d619d05977..dc18f3f78e 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -180,6 +180,18 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt return result, fmt.Errorf("no binding-consistent assignment found: %w", ErrNoCredentials) } + // Expose the resolved id-values of the surviving credentials, so a caller can chain them into + // a next Select (the two-VP composition) or report them. + result.Bindings = make(map[string]string) + for i := range assignment { + if assignment[i] == nil || result.Candidates[i].VC == nil { + continue + } + for id, value := range assignment[i].idValues { + result.Bindings[id] = value + } + } + return result, nil } diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 9641101e58..b9d89c2afa 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -588,6 +588,123 @@ func TestSelect_SameIDBinding(t *testing.T) { }) } +func TestSelect_Bindings(t *testing.T) { + t.Run("bindings expose the resolved id-values of the chosen assignment", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}}, + {"id": "patient_enrollment", "constraints": {"fields": [{"id": "bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + orgVC := parseVC(t, `{"id": "org-1", "credentialSubject": {"ura": "URA-001"}}`) + patientVC := parseVC(t, `{"id": "patient-1", "credentialSubject": {"bsn": "BSN-111"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{orgVC, patientVC}) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"ura": "URA-001", "bsn": "BSN-111"}, result.Bindings) + }) + + t.Run("a descriptor cleared by a rule contributes no bindings", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "doctor", "count": 1}], + "input_descriptors": [ + {"id": "enrollment", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "EnrollmentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}}, + {"id": "consent", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ConsentCredential"}}, + {"id": "consent_id", "path": ["$.credentialSubject.consentId"]} + ]}} + ] + }`) + enrollment := parseVC(t, `{"id": "enrollment-1", "type": ["VerifiableCredential", "EnrollmentCredential"], "credentialSubject": {"doctorId": "A"}}`) + consent := parseVC(t, `{"id": "consent-1", "type": ["VerifiableCredential", "ConsentCredential"], "credentialSubject": {"consentId": "C"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{enrollment, consent}) + + require.NoError(t, err) + // pick-1 keeps enrollment and clears consent; only the surviving credential binds + assert.Equal(t, map[string]string{"doctor_id": "A"}, result.Bindings) + }) + + t.Run("unknown initial binding keys are not echoed", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}, + WithInitialBindings(map[string]string{"patient_id": "123", "sp_only_key": "whatever"})) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"patient_id": "123"}, result.Bindings) + }) + + t.Run("no bindings on error", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"], "filter": {"type": "string", "const": "999"}}]} + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + assert.Nil(t, result.Bindings) + }) + + t.Run("two-VP composition chains bindings through a field-id filter", func(t *testing.T) { + // Policy 2 as straight Go at the call site: build the org VP, filter its bindings down + // to the SP PD's field ids, seed the SP build with the survivors. + orgPD := parsePD(t, `{ + "id": "org-pd", + "input_descriptors": [ + {"id": "org", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.orgDid"]}]}}, + {"id": "patient", "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + spPD := parsePD(t, `{ + "id": "sp-pd", + "input_descriptors": [ + {"id": "delegation", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.issuedTo"]}]}} + ] + }`) + orgVC := parseVC(t, `{"id": "org-1", "credentialSubject": {"orgDid": "did:web:org"}}`) + patientVC := parseVC(t, `{"id": "patient-1", "credentialSubject": {"bsn": "999911234"}}`) + delegationRight := parseVC(t, `{"id": "delegation-right", "credentialSubject": {"issuedTo": "did:web:org"}}`) + delegationWrong := parseVC(t, `{"id": "delegation-wrong", "credentialSubject": {"issuedTo": "did:web:other"}}`) + + orgResult, err := Select(orgPD, []vc.VerifiableCredential{orgVC, patientVC}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"org_did": "did:web:org", "patient_bsn": "999911234"}, orgResult.Bindings) + + // caller-side filter: keep only keys that are field ids on the SP PD + spFieldIDs := map[string]bool{"org_did": true} + seed := make(map[string]string) + for key, value := range orgResult.Bindings { + if spFieldIDs[key] { + seed[key] = value + } + } + + spResult, err := Select(spPD, []vc.VerifiableCredential{delegationWrong, delegationRight}, WithInitialBindings(seed)) + require.NoError(t, err) + require.NotNil(t, spResult.Candidates[0].VC) + assert.Equal(t, "delegation-right", spResult.Candidates[0].VC.ID.String()) + }) +} + func TestSelect_Strategy(t *testing.T) { // Policy 5 worked example: two genuinely different ways to satisfy the PD. ambiguousPD := `{ From 008fdd93afac724da8862d8da35c7bfd7af21383 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 18:10:44 +0200 Subject: [PATCH 14/25] feat(pe): produce MatchReport diagnostics under WithSelectionTrace Result.Report explains, per input descriptor, why each candidate was or wasn't selected: constraint filter rejection, missing value, format mismatch, binding conflict against the decisive assignment, or simply not selected. The report is assembled after the run by re-evaluating step 1, so the search itself stays free of report bookkeeping and a non-traced run pays nothing. Folds in the developer-diagnostics goal of issue 4218; a surface for the report is a follow-up. Assisted-by: AI --- vcr/pe/match_report.go | 272 ++++++++++++++++++++++++++++++ vcr/pe/presentation_definition.go | 29 ++-- vcr/pe/select.go | 35 +++- vcr/pe/select_test.go | 147 ++++++++++++++++ 4 files changed, 460 insertions(+), 23 deletions(-) create mode 100644 vcr/pe/match_report.go diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go new file mode 100644 index 0000000000..8d4782d6ed --- /dev/null +++ b/vcr/pe/match_report.go @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "errors" + "fmt" + + "github.com/nuts-foundation/go-did/vc" +) + +// Outcome classifies the overall result of a Select call in a MatchReport. +type Outcome string + +const ( + // OutcomeMatched means a complete, binding-consistent assignment was found. + OutcomeMatched Outcome = "matched" + // OutcomeNoCredentials means some required descriptor could not be filled consistently. + OutcomeNoCredentials Outcome = "no_credentials" + // OutcomeMultipleCredentials means the selection was ambiguous. + OutcomeMultipleCredentials Outcome = "multiple_credentials" +) + +// DismissalReason classifies why a candidate credential was not used for a descriptor. +type DismissalReason string + +const ( + // ReasonNoValue means no path of a required field produced a value. + ReasonNoValue DismissalReason = "constraint_no_value" + // ReasonFilter means a field resolved a value that its filter rejected. + ReasonFilter DismissalReason = "constraint_filter" + // ReasonFormat means the credential format did not satisfy the PD or descriptor format gate. + ReasonFormat DismissalReason = "format_mismatch" + // ReasonBindingConflict means the credential was eligible but disagrees with the decisive + // assignment on a shared field id. + ReasonBindingConflict DismissalReason = "binding_conflict" + // ReasonNotSelected means the credential was eligible and consistent, but another candidate + // was chosen. + ReasonNotSelected DismissalReason = "not_selected" +) + +// MatchReport explains, per input descriptor, why each candidate credential was or wasn't +// selected. It is produced only under WithSelectionTrace. +type MatchReport struct { + // Descriptors reports on every input descriptor, in PD order. + Descriptors []DescriptorReport + // Outcome classifies the overall result. + Outcome Outcome + // AmbiguousDescriptors names the descriptors that carried more than one choice + // (OutcomeMultipleCredentials only). + AmbiguousDescriptors []string +} + +// DescriptorReport explains the selection for one input descriptor. +type DescriptorReport struct { + DescriptorID string + // Optional is whether the search was allowed to leave the descriptor unfilled. + Optional bool + // Considered reports every candidate credential evaluated against this descriptor. + Considered []CandidateReport + // SelectedID is the id of the chosen credential, empty when the descriptor went unfilled. + SelectedID string + // Skipped is whether the descriptor ended up without a credential. + Skipped bool +} + +// CandidateReport explains one credential's evaluation against one descriptor. +type CandidateReport struct { + CredentialID string + // Eligible is whether the credential passed the descriptor's constraints and format gates. + Eligible bool + // Dismissal explains why the credential was not used; nil for the selected credential. + Dismissal *Dismissal +} + +// Dismissal is the reason a candidate was not used, with the offending field where known. +type Dismissal struct { + Reason DismissalReason + // FieldID is the field id involved (constraint and binding reasons, when known). + FieldID string + // Path is the JSONPath that was evaluated (constraint reasons). + Path string + // Expected is the filter constant or type, or the bound value (binding conflicts). + Expected string + // Found is the value the credential resolved; empty when none. + Found string + // Message is a rendered human-readable line. + Message string +} + +// 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, + assignment []*candidateGroup, ambiguous []string, result Result, err error, initialBindings map[string]string) *MatchReport { + report := &MatchReport{ + Outcome: OutcomeMatched, + AmbiguousDescriptors: ambiguous, + } + switch { + case err == nil: + case errors.Is(err, ErrMultipleCredentials): + report.Outcome = OutcomeMultipleCredentials + default: + report.Outcome = OutcomeNoCredentials + } + + // the decisive bindings: the caller's plus everything the decisive assignment resolved + bindings := copyBindings(initialBindings) + for _, group := range assignment { + if group == nil { + continue + } + for id, value := range group.idValues { + if _, bound := bindings[id]; !bound { + bindings[id] = value + } + } + } + + for i, descriptor := range pd.InputDescriptors { + descriptorReport := DescriptorReport{ + DescriptorID: descriptor.Id, + Optional: i < len(required) && !required[i], + } + selected := result.Candidates[i].VC + if selected != nil && selected.ID != nil { + descriptorReport.SelectedID = selected.ID.String() + } + descriptorReport.Skipped = selected == nil + strictIDs := strictBoundIDs(*descriptor, initialBindings) + + selectedSeen := false + for _, candidate := range candidates { + candidateReport := CandidateReport{} + if candidate.ID != nil { + candidateReport.CredentialID = candidate.ID.String() + } + eligible, idValues := evaluateCandidate(pd, *descriptor, candidate) + candidateReport.Eligible = eligible + switch { + case !eligible: + candidateReport.Dismissal = explainIneligible(pd, *descriptor, candidate) + case selected != nil && !selectedSeen && vcEqual(candidate, *selected): + selectedSeen = true // the chosen credential carries no dismissal + default: + candidateReport.Dismissal = explainNotChosen(idValues, bindings, strictIDs) + } + descriptorReport.Considered = append(descriptorReport.Considered, candidateReport) + } + report.Descriptors = append(report.Descriptors, descriptorReport) + } + return report +} + +// evaluateCandidate re-runs the step-1 eligibility of one credential for one descriptor. +func evaluateCandidate(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) (bool, map[string]string) { + idValues := make(map[string]string) + if descriptor.Constraints != nil { + isMatch, values, err := matchConstraint(descriptor.Constraints, credential) + if err != nil || !isMatch { + return false, nil + } + for id, value := range values { + if s, ok := stringifyBindingValue(value); ok { + idValues[id] = s + } + } + } + if !matchFormat(pd.Format, credential) || !matchFormat(descriptor.Format, credential) { + return false, nil + } + return true, idValues +} + +// 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 { + if descriptor.Constraints != nil { + credentialJSON, err := credentialAsMap(credential) + 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) + } + } + 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 +} + +// 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 { + 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 + } + dismissal.Reason = ReasonFilter + dismissal.Path = path + dismissal.Found = fmt.Sprintf("%v", value) + if field.Filter != nil { + if field.Filter.Const != nil { + dismissal.Expected = *field.Filter.Const + } else { + 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) + return dismissal + } + dismissal.Reason = ReasonNoValue + if len(field.Path) > 0 { + dismissal.Path = field.Path[0] + } + dismissal.Message = fmt.Sprintf("no value found at %v", field.Path) + return dismissal +} + +// explainNotChosen explains an eligible credential that was not selected: either it conflicts with +// the decisive bindings on a shared id (or fails to resolve a strictly bound one), or it simply +// lost to an earlier candidate. +func explainNotChosen(idValues map[string]string, bindings map[string]string, strictIDs []string) *Dismissal { + for id, value := range idValues { + if bound, ok := bindings[id]; ok && bound != value { + return &Dismissal{ + Reason: ReasonBindingConflict, + FieldID: id, + Expected: bound, + Found: value, + Message: fmt.Sprintf("field id %s resolves to %s, but the selection binds it to %s", id, value, bound), + } + } + } + for _, id := range strictIDs { + if _, ok := idValues[id]; !ok { + return &Dismissal{ + Reason: ReasonBindingConflict, + FieldID: id, + Expected: bindings[id], + Message: fmt.Sprintf("field id %s is bound to %s but does not resolve on this credential", id, bindings[id]), + } + } + } + return &Dismissal{Reason: ReasonNotSelected, Message: "not selected: another candidate fills this descriptor"} +} diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index f204463f13..42bd5a8200 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -398,21 +398,7 @@ 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) { - // jsonpath works on interfaces, so convert the VC to an interface - var credentialAsMap map[string]interface{} - var err error - switch credential.Format() { - case vc.JWTCredentialProofFormat: - // JWT-VCs marshal to a JSON string, so marshal an alias to make sure we get a JSON object with the VC properties, - // instead of a JWT string. - type Alias vc.VerifiableCredential - credentialAsMap, err = remarshalToMap(Alias(credential)) - case vc.JSONLDCredentialProofFormat: - fallthrough - case "": // holder credential - credentialAsMap, err = remarshalToMap(credential) - } - + credentialAsMap, err := credentialAsMap(credential) if err != nil { return false, nil, err } @@ -435,6 +421,19 @@ func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential return true, values, nil } +// credentialAsMap converts a VC to a plain JSON object, the form the JSONPath evaluation works on. +func credentialAsMap(credential vc.VerifiableCredential) (map[string]interface{}, error) { + switch credential.Format() { + case vc.JWTCredentialProofFormat: + // JWT-VCs marshal to a JSON string, so marshal an alias to make sure we get a JSON object + // with the VC properties, instead of a JWT string. + type Alias vc.VerifiableCredential + return remarshalToMap(Alias(credential)) + default: // JSON-LD or holder credential + return remarshalToMap(credential) + } +} + // 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. // All fields need to match unless optional is set to true and no values are found for all the paths. diff --git a/vcr/pe/select.go b/vcr/pe/select.go index dc18f3f78e..88f7ec8ed4 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -47,6 +47,8 @@ type selectOptions struct { initialBindings map[string]string // strategy selects the ambiguity policy; the zero value is FirstMatch. strategy SelectionStrategy + // trace enables the MatchReport diagnostics. + trace bool } // Option configures a single Select call. Options exist so that callers can opt in to @@ -70,6 +72,14 @@ func WithStrategy(s SelectionStrategy) Option { } } +// WithSelectionTrace populates Result.Report with a MatchReport explaining, per descriptor, why +// each candidate was or wasn't selected. Off by default; a non-traced run pays nothing for it. +func WithSelectionTrace() Option { + return func(o *selectOptions) { + o.trace = true + } +} + // Result is the outcome of a Select call. type Result struct { // Candidates pairs every input descriptor (in PD order) with the VC chosen for it, on every @@ -79,29 +89,40 @@ type Result struct { Candidates []Candidate // Bindings holds the resolved field-id to value pairs of the chosen assignment. Bindings map[string]string + // Report explains the selection per descriptor; non-nil only under WithSelectionTrace. + Report *MatchReport } // Select resolves a presentation definition against a set of candidate credentials and // returns the chosen descriptor-to-VC assignment. It is the single matching engine: it // matches each descriptor on its own (step 1), searches for a binding-consistent combination // across descriptors (step 2), and applies the submission requirement rules (step 3). -func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (Result, error) { +func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (result Result, err error) { var options selectOptions for _, opt := range opts { opt(&options) } - result := Result{Candidates: make([]Candidate, len(pd.InputDescriptors))} + result = Result{Candidates: make([]Candidate, len(pd.InputDescriptors))} for i, descriptor := range pd.InputDescriptors { result.Candidates[i].InputDescriptor = *descriptor } + required := requiredDescriptors(pd) + assignment := make([]*candidateGroup, len(pd.InputDescriptors)) + var ambiguous []string + if options.trace { + defer func() { + result.Report = buildReport(pd, candidates, required, assignment, ambiguous, result, err, options.initialBindings) + }() + } + // Step 1: per-descriptor eligibility, indexed for the search. pools := make([]descriptorPool, len(pd.InputDescriptors)) for i, descriptor := range pd.InputDescriptors { - pool, err := buildPool(pd, *descriptor, candidates) - if err != nil { - return result, err + pool, poolErr := buildPool(pd, *descriptor, candidates) + if poolErr != nil { + return result, poolErr } pool.strictIDs = strictBoundIDs(*descriptor, options.initialBindings) pools[i] = pool @@ -122,11 +143,9 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt } // Step 2: search for a complete binding-consistent assignment. - assignment := make([]*candidateGroup, len(pools)) found := false - var ambiguous []string if boundErr == nil { - s := &searcher{pools: pools, required: requiredDescriptors(pd), strict: options.strategy == Strict} + s := &searcher{pools: pools, required: required, strict: options.strategy == Strict} s.search(0, copyBindings(options.initialBindings), make([]*candidateGroup, len(pools))) found = s.first != nil ambiguous = s.ambiguous diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index b9d89c2afa..0c0c4b6e12 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -817,6 +817,153 @@ func TestSelect_Strategy(t *testing.T) { }) } +func TestSelect_Trace(t *testing.T) { + filterPD := `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"], "filter": {"type": "string", "const": "999"}}]} + }] + }` + + t.Run("trace off leaves Report nil", func(t *testing.T) { + pd := parsePD(t, filterPD) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "999"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + assert.Nil(t, result.Report) + }) + + t.Run("filter dismissal and selection are reported", func(t *testing.T) { + pd := parsePD(t, filterPD) + wrong := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}}`) + right := parseVC(t, `{"id": "vc-right", "credentialSubject": {"patientId": "999"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrong, right}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMatched, result.Report.Outcome) + require.Len(t, result.Report.Descriptors, 1) + descriptor := result.Report.Descriptors[0] + assert.Equal(t, "patient_credential", descriptor.DescriptorID) + assert.Equal(t, "vc-right", descriptor.SelectedID) + assert.False(t, descriptor.Skipped) + require.Len(t, descriptor.Considered, 2) + dismissed := descriptor.Considered[0] + assert.Equal(t, "vc-wrong", dismissed.CredentialID) + assert.False(t, dismissed.Eligible) + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonFilter, dismissed.Dismissal.Reason) + selected := descriptor.Considered[1] + assert.True(t, selected.Eligible) + assert.Nil(t, selected.Dismissal) + }) + + t.Run("missing value is reported as constraint_no_value", func(t *testing.T) { + pd := parsePD(t, filterPD) + unrelated := parseVC(t, `{"id": "vc-unrelated", "credentialSubject": {"somethingElse": "x"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{unrelated}, WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrNoCredentials) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeNoCredentials, result.Report.Outcome) + dismissed := result.Report.Descriptors[0].Considered[0] + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonNoValue, dismissed.Dismissal.Reason) + }) + + t.Run("format mismatch is reported", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "format": {"ldp_vc": {"proof_type": ["JsonWebSignature2020"]}}, + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wrongProof := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}, "proof": [{"type": "RsaSignature2018"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrongProof}, WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrNoCredentials) + require.NotNil(t, result.Report) + dismissed := result.Report.Descriptors[0].Considered[0] + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonFormat, dismissed.Dismissal.Reason) + }) + + t.Run("binding conflict on the decisive path is reported", func(t *testing.T) { + // P3 worked example under trace: HCP-A is eligible but disagrees with the decisive + // assignment's org_ura=2. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "id_healthcare_provider", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "HCPCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}}, + {"id": "id_professional_delegation", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "DelegationCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}} + ] + }`) + hcpA := parseVC(t, `{"id": "hcp-a", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "1"}}`) + hcpB := parseVC(t, `{"id": "hcp-b", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "2"}}`) + delegationX := parseVC(t, `{"id": "delegation-x", "type": ["VerifiableCredential", "DelegationCredential"], "credentialSubject": {"ura": "2"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{hcpA, hcpB, delegationX}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMatched, result.Report.Outcome) + hcpReport := result.Report.Descriptors[0] + assert.Equal(t, "hcp-b", hcpReport.SelectedID) + require.Len(t, hcpReport.Considered, 3) + dismissed := hcpReport.Considered[0] + assert.Equal(t, "hcp-a", dismissed.CredentialID) + assert.True(t, dismissed.Eligible) + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonBindingConflict, dismissed.Dismissal.Reason) + assert.Equal(t, "org_ura", dismissed.Dismissal.FieldID) + assert.Equal(t, "2", dismissed.Dismissal.Expected) + assert.Equal(t, "1", dismissed.Dismissal.Found) + }) + + t.Run("ambiguity is reported with the ambiguous descriptors", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + creds := []vc.VerifiableCredential{ + parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`), + parseVC(t, `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Y"}}`), + parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "X"}}`), + parseVC(t, `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Y"}}`), + } + + result, err := Select(pd, creds, WithStrategy(Strict), WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMultipleCredentials, result.Report.Outcome) + assert.Equal(t, []string{"A", "B"}, result.Report.AmbiguousDescriptors) + }) +} + func TestSelect_SubmissionRequirements(t *testing.T) { t.Run("all rule with an unfilled group member returns ErrNoCredentials", func(t *testing.T) { pd := parsePD(t, `{ From ce799ecb5355eb29d1c8b937bbe19bf585619aba Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Jul 2026 18:12:48 +0200 Subject: [PATCH 15/25] feat(pe): abort pathological searches at a node-visit limit Presentation definitions are counterparty-supplied in wallet flows; a crafted PD with many independent descriptors and an unfillable final one forces the naive backtracking search to visit exponentially many nodes before concluding failure. Count node visits and abort with an error naming the PD at one million, far above anything a legitimate wallet produces with the indexed pools. Assisted-by: AI --- vcr/pe/select.go | 22 +++++++++++++++++ vcr/pe/select_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 88f7ec8ed4..441ef6f880 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -144,11 +144,13 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt // Step 2: search for a complete binding-consistent assignment. found := false + limited := false if boundErr == nil { s := &searcher{pools: pools, required: required, strict: options.strategy == Strict} s.search(0, copyBindings(options.initialBindings), make([]*candidateGroup, len(pools))) found = s.first != nil ambiguous = s.ambiguous + limited = s.limited if found { copy(assignment, s.first) } @@ -174,6 +176,11 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt if boundErr != nil { return result, boundErr } + if limited { + // A found-but-unproven assignment is not returned as a success: under Strict that would + // silently downgrade the ambiguity guarantee. + return result, fmt.Errorf("presentation definition '%s': search aborted, node limit reached (%d)", pd.Id, searchNodeLimit) + } if len(ambiguous) > 0 { // The decisive assignment stays in Candidates so the caller can see what would have won. return result, fmt.Errorf("ambiguous input descriptors %v: %w", ambiguous, ErrMultipleCredentials) @@ -214,6 +221,12 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt return result, nil } +// searchNodeLimit bounds the number of node visits of a single search. Presentation definitions +// arrive from the counterparty in wallet flows, so an unbounded backtracking search would be +// remotely triggerable CPU exhaustion; legitimate wallets stay orders of magnitude below this. +// A variable so tests can lower it. +var searchNodeLimit = 1_000_000 + // searcher carries the inputs and outcome of the step-2 backtracking search. type searcher struct { pools []descriptorPool @@ -223,6 +236,10 @@ type searcher struct { first []*candidateGroup // ambiguous holds, under Strict, the descriptors a rival assignment fills differently. ambiguous []string + // visited counts node visits; when it exceeds searchNodeLimit, limited is set and the + // search unwinds. + visited int + limited bool } // search explores binding-consistent choices per descriptor, depth-first in PD order, and reports @@ -233,6 +250,11 @@ type searcher struct { // restored on backtrack. Under FirstMatch the search stops at the first complete assignment; // under Strict it continues until a rival assignment is found or the space is exhausted. func (s *searcher) search(i int, bindings map[string]string, current []*candidateGroup) bool { + s.visited++ + if s.visited > searchNodeLimit { + s.limited = true + return true + } if i == len(s.pools) { return s.emit(current) } diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 0c0c4b6e12..1a43a53b80 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -20,6 +20,7 @@ package pe import ( "encoding/json" + "fmt" "strings" "testing" @@ -964,6 +965,61 @@ func TestSelect_Trace(t *testing.T) { }) } +func TestSelect_NodeLimit(t *testing.T) { + // A PD engineered to explode: descriptors with independent ids multiply combinations, and a + // final unfillable descriptor forces the search to visit all of them before concluding + // failure. PDs are counterparty-supplied in wallet flows, so the search must abort instead + // of burning CPU. + t.Run("a pathological search aborts with a limit error", func(t *testing.T) { + originalLimit := searchNodeLimit + searchNodeLimit = 100 + defer func() { searchNodeLimit = originalLimit }() + + descriptors := "" + var creds []vc.VerifiableCredential + for d := 0; d < 6; d++ { + descriptors += fmt.Sprintf(`{"id": "d%d", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "Type%d"}}, + {"id": "id%d", "path": ["$.credentialSubject.value"]} + ]}},`, d, d, d) + for c := 0; c < 3; c++ { + creds = append(creds, parseVC(t, fmt.Sprintf( + `{"id": "vc-%d-%d", "type": ["VerifiableCredential", "Type%d"], "credentialSubject": {"value": "v%d"}}`, d, c, d, c))) + } + } + pd := parsePD(t, `{ + "id": "exploding-pd", + "input_descriptors": [`+descriptors+` + {"id": "impossible", "constraints": {"fields": [ + {"path": ["$.credentialSubject.nonexistent"], "filter": {"type": "string", "const": "never"}} + ]}} + ] + }`) + + _, err := Select(pd, creds) + + require.Error(t, err) + assert.ErrorContains(t, err, "exploding-pd") + assert.ErrorContains(t, err, "limit") + }) + + t.Run("a realistic search stays far below the default limit", func(t *testing.T) { + // sanity check: the P3 worked example never comes close to the cap + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + }) +} + func TestSelect_SubmissionRequirements(t *testing.T) { t.Run("all rule with an unfilled group member returns ErrNoCredentials", func(t *testing.T) { pd := parsePD(t, `{ From 5433b863945104ba6b68c8217ed515ba76310621 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 16:19:07 +0200 Subject: [PATCH 16/25] feat(pe): count caller-bound multiplicity per binding tuple The PD is the data contract: only declared fields reach token introspection, so any claim the relying party consumes is necessarily a declared field id, and filters such as issuer patterns admitting several issuers are deliberate equivalence declarations by the PD author. Caller-bound matches that agree on every declared id are therefore interchangeable and the first is used (a reissued duplicate no longer fails the request), while matches contradicting on another declared id still error, with a bindable key as the remedy. Pinned with both consent scenarios: status declared errors, status undeclared first-picks. Assisted-by: AI --- vcr/pe/select.go | 17 +++++------ vcr/pe/select_test.go | 69 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 441ef6f880..0647e9033d 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -355,20 +355,17 @@ func requiredDescriptors(pd PresentationDefinition) []bool { return required } -// callerBoundError reproduces the legacy field-selector contract: a descriptor pinned by the -// caller's bindings must resolve to exactly one credential; more than one is -// ErrMultipleCredentials. Counted per credential (not per binding tuple), because that is the -// behavior existing callers rely on. Zero matches is a soft failure: the descriptor is left -// unfilled and step 3 decides. +// callerBoundError enforces the caller-bound multiplicity rule: a descriptor pinned by the +// caller's bindings must resolve to exactly one interchangeable set of credentials. Counted per +// binding tuple: matches that differ on some other declared field id are distinct choices and the +// error names a real remedy (bind that id too), while matches agreeing on every declared id are +// interchangeable and the first one is used. Zero matches is a soft failure: the descriptor is +// left unfilled and step 3 decides. func callerBoundError(pool descriptorPool, initialBindings map[string]string) error { if len(pool.strictIDs) == 0 { return nil } - count := 0 - for _, gi := range pool.consistentGroups(initialBindings) { - count += len(pool.groups[gi].creds) - } - if count > 1 { + if len(pool.consistentGroups(initialBindings)) > 1 { return fmt.Errorf("input descriptor '%s': %w", pool.descriptor.Id, ErrMultipleCredentials) } return nil diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 1a43a53b80..0886a86b21 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -243,7 +243,9 @@ func TestSelect_InitialBindings(t *testing.T) { assert.Equal(t, "vc-2", result.Candidates[0].VC.ID.String()) }) - t.Run("caller-bound descriptor with multiple matches returns ErrMultipleCredentials", func(t *testing.T) { + t.Run("caller-bound matches agreeing on every declared field are interchangeable", func(t *testing.T) { + // The matches are identical on all declared field ids (a reissued duplicate); no + // credential_selection key could separate them, so the engine picks the first. pd := parsePD(t, `{ "id": "test-pd", "input_descriptors": [{ @@ -256,25 +258,76 @@ func TestSelect_InitialBindings(t *testing.T) { result, err := Select(pd, []vc.VerifiableCredential{vcA, vcB}, WithInitialBindings(map[string]string{"patient_id": "456"})) + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-a", result.Candidates[0].VC.ID.String()) + }) + + t.Run("caller-bound matches differing on another declared field return ErrMultipleCredentials", func(t *testing.T) { + // The consent scenario with consent_status declared: the caller pins the patient, but + // the survivors contradict each other on a declared id. Erroring is actionable: bind + // consent_status. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [ + {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}, + {"id": "consent_status", "path": ["$.credentialSubject.consentStatus"]} + ]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"})) + assert.ErrorIs(t, err, ErrMultipleCredentials) - // Candidates stays populated on error: the undecidable descriptor is present, unfilled. require.Len(t, result.Candidates, 1) - assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) assert.Nil(t, result.Candidates[0].VC) }) + t.Run("caller-bound matches differing only in undeclared fields are interchangeable", func(t *testing.T) { + // The same consent scenario without consent_status in the PD: the PD is the data + // contract (undeclared fields are not introspectable), so the difference plays no role + // and the engine picks the first. The divergence is surfaced in the MatchReport. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "consent-granted", result.Candidates[0].VC.ID.String()) + }) + t.Run("candidates stay full length when a later descriptor errors", func(t *testing.T) { - // The failing descriptor comes first; the descriptor after it must still appear in - // Result.Candidates (one entry per descriptor, PD order, on every path). + // The failing descriptor comes first (bound matches contradict on the declared status + // field); the descriptor after it must still appear in Result.Candidates (one entry per + // descriptor, PD order, on every path). pd := parsePD(t, `{ "id": "test-pd", "input_descriptors": [ - {"id": "patient_credential", "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]}}, + {"id": "patient_credential", "constraints": {"fields": [ + {"id": "patient_id", "path": ["$.credentialSubject.patientId"]}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}}, {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}} ] }`) - patA := parseVC(t, `{"id": "pat-a", "credentialSubject": {"patientId": "456"}}`) - patB := parseVC(t, `{"id": "pat-b", "credentialSubject": {"patientId": "456"}}`) + patA := parseVC(t, `{"id": "pat-a", "credentialSubject": {"patientId": "456", "status": "active"}}`) + patB := parseVC(t, `{"id": "pat-b", "credentialSubject": {"patientId": "456", "status": "inactive"}}`) org := parseVC(t, `{"id": "org-1", "credentialSubject": {"ura": "URA-001"}}`) result, err := Select(pd, []vc.VerifiableCredential{patA, patB, org}, From 7e48396a90ee8ec5a6bf50ed01ca509bdb34af6a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 16:20:43 +0200 Subject: [PATCH 17/25] feat(pe): flag diverging interchangeable alternatives in MatchReport When the engine first-picks among credentials that agree on every declared field id but differ in their credentialSubjects, the report marks the descriptor. The difference plays no role in selection by contract (undeclared fields are not introspectable), but a wallet holding semantically diverging same-tuple credentials is worth the operator's attention. Assisted-by: AI --- vcr/pe/match_report.go | 24 ++++++++++++++++++++++++ vcr/pe/select_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go index 8d4782d6ed..1b30dab606 100644 --- a/vcr/pe/match_report.go +++ b/vcr/pe/match_report.go @@ -19,6 +19,8 @@ package pe import ( + "bytes" + "encoding/json" "errors" "fmt" @@ -78,6 +80,11 @@ type DescriptorReport struct { SelectedID string // Skipped is whether the descriptor ended up without a credential. Skipped bool + // DivergingAlternatives is whether the chosen credential was picked among interchangeable + // alternatives (agreeing on every declared field id) whose credentialSubjects nevertheless + // differ. The PD does not declare the differing fields, so they played no role in selection; + // this flag gives the operator visibility into a wallet holding such diverging credentials. + DivergingAlternatives bool } // CandidateReport explains one credential's evaluation against one descriptor. @@ -147,6 +154,10 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential descriptorReport.Skipped = selected == nil strictIDs := strictBoundIDs(*descriptor, initialBindings) + var selectedIDValues map[string]string + if selected != nil { + _, selectedIDValues = evaluateCandidate(pd, *descriptor, *selected) + } selectedSeen := false for _, candidate := range candidates { candidateReport := CandidateReport{} @@ -162,6 +173,11 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential selectedSeen = true // the chosen credential carries no dismissal default: 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) == tupleKey(selectedIDValues) && !subjectsEqual(candidate, *selected) { + descriptorReport.DivergingAlternatives = true + } } descriptorReport.Considered = append(descriptorReport.Considered, candidateReport) } @@ -170,6 +186,14 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential return report } +// subjectsEqual reports whether two credentials carry structurally equal credentialSubjects. +// Metadata (proof, VC id, dates) is deliberately not compared. +func subjectsEqual(a, b vc.VerifiableCredential) bool { + aJSON, errA := json.Marshal(a.CredentialSubject) + bJSON, errB := json.Marshal(b.CredentialSubject) + return errA == nil && errB == nil && bytes.Equal(aJSON, bJSON) +} + // evaluateCandidate re-runs the step-1 eligibility of one credential for one descriptor. func evaluateCandidate(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) (bool, map[string]string) { idValues := make(map[string]string) diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 0886a86b21..45d96d3a79 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -988,6 +988,44 @@ func TestSelect_Trace(t *testing.T) { assert.Equal(t, "1", dismissed.Dismissal.Found) }) + t.Run("diverging interchangeable alternatives are flagged", func(t *testing.T) { + // Two consents for the same bsn differ in an undeclared field: interchangeable by + // contract, but the report flags the divergence for the operator. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.True(t, result.Report.Descriptors[0].DivergingAlternatives) + }) + + t.Run("true duplicates are not flagged as diverging", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]} + }] + }`) + original := parseVC(t, `{"id": "consent-1", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + reissued := parseVC(t, `{"id": "consent-2", "credentialSubject": {"bsn": "999911234", "consentStatus": true}, "proof": [{"type": "JsonWebSignature2020"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{original, reissued}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.False(t, result.Report.Descriptors[0].DivergingAlternatives) + }) + t.Run("ambiguity is reported with the ambiguous descriptors", func(t *testing.T) { pd := parsePD(t, `{ "id": "test-pd", From 0358b0e7902e33a9e1dc13c5052e7d484fb2bacf Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 16:21:52 +0200 Subject: [PATCH 18/25] docs(pe): define the selection rules in the Select godoc Name the rules (binding consistency, prefer fill over skip, interchangeability, caller-bound multiplicity, unresolved-optional, ambiguity) and state the contract rationale: the PD is the data contract, only declared fields reach token introspection, and multi-value filters are deliberate equivalence declarations. Replace the policy-number references in comments with the named rules; the design issue is referenced once for provenance. Assisted-by: AI --- vcr/pe/select.go | 29 ++++++++++++++++++++++++++++- vcr/pe/select_test.go | 16 ++++++++-------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 0647e9033d..37802ad379 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -97,6 +97,33 @@ type Result struct { // returns the chosen descriptor-to-VC assignment. It is the single matching engine: it // matches each descriptor on its own (step 1), searches for a binding-consistent combination // across descriptors (step 2), and applies the submission requirement rules (step 3). +// +// The search follows these rules: +// +// - Binding consistency: a field id is a binding name. Wherever the same id appears, across +// descriptors or in the caller's initial bindings, the resolved values must agree in the +// chosen assignment. +// - Prefer fill over skip: an optional descriptor is left unfilled only after all its +// consistent candidates are exhausted; a required descriptor with no consistent candidate +// makes the search revise an earlier choice. +// - Interchangeability: credentials that resolve identical values for every declared field id +// are interchangeable, and the first in candidate order is used. Fields the PD does not +// declare play no role: the PD is the data contract (only declared fields are mapped to +// token introspection), and a filter admitting several values, such as an issuer pattern, +// is a deliberate equivalence declaration by the PD author. Interchangeable credentials +// whose subjects nevertheless differ are flagged in the MatchReport. +// - Caller-bound multiplicity: a descriptor with a field id in the initial bindings must +// resolve to exactly one interchangeable set; more than one is ErrMultipleCredentials, and +// the remedy is always a bindable key. The bound field must resolve on the chosen +// credential; an unresolved optional field does not satisfy a bound id. +// - Unresolved optional fields bind nothing: between descriptors, a field that resolves no +// value contributes no binding entry. +// - Ambiguity (Strict only): when a rival assignment exists that fills a common descriptor +// with a different binding tuple, the selection errors instead of silently picking one. +// +// These rules originate as the numbered policies of the design in +// https://github.com/nuts-foundation/nuts-node/issues/4253. Candidates are expected to be +// time-valid; the wallet filters expired and revoked credentials before calling the engine. func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (result Result, err error) { var options selectOptions for _, opt := range opts { @@ -393,7 +420,7 @@ type descriptorPool struct { lacking map[string][]int // strictIDs are the descriptor's field ids bound by the caller. For these ids a group must // resolve the field to the bound value; unresolved is not acceptable (legacy field-selector - // semantics). Policy 6 leniency covers only bindings accumulated during the search. + // semantics). The unresolved-optional leniency covers only bindings accumulated during the search. strictIDs []string } diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 45d96d3a79..a1926c1112 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -446,7 +446,7 @@ func TestSelect_InitialBindings(t *testing.T) { }) t.Run("unresolved optional field does not bind between descriptors", func(t *testing.T) { - // Policy 6: descriptor A's optional foo does not resolve on its candidate, so it must + // Unresolved-optional rule: descriptor A's optional foo does not resolve on its candidate, so it must // not manufacture a binding that conflicts with descriptor B's resolved foo=Z. pd := parsePD(t, `{ "id": "test-pd", @@ -477,7 +477,7 @@ func TestSelect_InitialBindings(t *testing.T) { t.Run("caller-bound field must resolve: all candidates unresolved is ErrNoCredentials", func(t *testing.T) { // Initial bindings are strict (legacy field-selector semantics): a caller who pins foo // never receives a credential without a resolvable foo. Two such candidates must not - // drift into ErrMultipleCredentials via the P6 unresolved-is-consistent leniency. + // drift into ErrMultipleCredentials via the unresolved-is-consistent leniency. pd := parsePD(t, `{ "id": "test-pd", "input_descriptors": [{ @@ -541,7 +541,7 @@ func TestSelect_InitialBindings(t *testing.T) { func TestSelect_SameIDBinding(t *testing.T) { t.Run("same id across descriptors must agree, backtracking into a consistent pair", func(t *testing.T) { - // Policy 3 worked example: HCP-A (ura=1) is tried first, conflicts with the only + // Same-id agreement worked example (design #4253): HCP-A (ura=1) is tried first, conflicts with the only // delegation (ura=2), so the search backtracks and lands on (HCP-B, Delegation-X). pd := parsePD(t, `{ "id": "test-pd", @@ -571,7 +571,7 @@ func TestSelect_SameIDBinding(t *testing.T) { }) t.Run("optional descriptor is skipped only after exhausting alternatives elsewhere", func(t *testing.T) { - // Policy 4 worked example: A1's binding admits B1 but kills C1; A2's binding kills B1 + // Prefer-fill-over-skip worked example (design #4253): A1's binding admits B1 but kills C1; A2's binding kills B1 // but admits C1. Skipping optional B under A1 does not save C, so the search must // revise A. Expected: A=A2, B=nil, C=C1. pd := parsePD(t, `{ @@ -719,7 +719,7 @@ func TestSelect_Bindings(t *testing.T) { }) t.Run("two-VP composition chains bindings through a field-id filter", func(t *testing.T) { - // Policy 2 as straight Go at the call site: build the org VP, filter its bindings down + // Cross-VP capture as straight Go at the call site: build the org VP, filter its bindings down // to the SP PD's field ids, seed the SP build with the survivors. orgPD := parsePD(t, `{ "id": "org-pd", @@ -760,7 +760,7 @@ func TestSelect_Bindings(t *testing.T) { } func TestSelect_Strategy(t *testing.T) { - // Policy 5 worked example: two genuinely different ways to satisfy the PD. + // Ambiguity worked example (design #4253): two genuinely different ways to satisfy the PD. ambiguousPD := `{ "id": "test-pd", "input_descriptors": [ @@ -951,7 +951,7 @@ func TestSelect_Trace(t *testing.T) { }) t.Run("binding conflict on the decisive path is reported", func(t *testing.T) { - // P3 worked example under trace: HCP-A is eligible but disagrees with the decisive + // Same-id agreement example under trace: HCP-A is eligible but disagrees with the decisive // assignment's org_ura=2. pd := parsePD(t, `{ "id": "test-pd", @@ -1095,7 +1095,7 @@ func TestSelect_NodeLimit(t *testing.T) { }) t.Run("a realistic search stays far below the default limit", func(t *testing.T) { - // sanity check: the P3 worked example never comes close to the cap + // sanity check: an ordinary single-descriptor match never comes close to the cap pd := parsePD(t, `{ "id": "test-pd", "input_descriptors": [{ From 57a9ee98cf2032c243aa91dc70ad21c30d8b2e25 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 16:33:29 +0200 Subject: [PATCH 19/25] test(pe): pin optionality vs multiplicity edges Optionality governs zero-vs-some, never one-vs-many: caller-bound multiplicity errors even on an optional descriptor (skipping would silently discard an explicit instruction), Strict flags a rival on an optional descriptor, and fill-versus-skip never counts as a rival. Assisted-by: AI --- vcr/pe/select_test.go | 99 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index a1926c1112..2bf12bf894 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -518,6 +518,43 @@ func TestSelect_InitialBindings(t *testing.T) { assert.Equal(t, "a-resolving", result.Candidates[0].VC.ID.String()) }) + t.Run("caller-bound multiplicity errors even on an optional descriptor", func(t *testing.T) { + // Optionality governs zero-vs-some, not one-vs-many: skipping an optional descriptor + // because the caller's explicit selection turned out ambiguous would silently discard + // an instruction. The error names the descriptor; the remedy is a second key (status). + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"bsn": "999911234", "status": "active"}}`) + b2 := parseVC(t, `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"bsn": "999911234", "status": "inactive"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, b1, b2}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"})) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + assert.ErrorContains(t, err, "B") + // full-length candidates: the pinned-but-ambiguous descriptor is unfilled, A is best-effort + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Nil(t, result.Candidates[1].VC) + }) + t.Run("unknown binding key is silently dropped", func(t *testing.T) { pd := parsePD(t, `{ "id": "test-pd", @@ -842,6 +879,68 @@ func TestSelect_Strategy(t *testing.T) { assert.Equal(t, "a-first", result.Candidates[0].VC.ID.String()) }) + t.Run("Strict flags a rival on an optional descriptor", func(t *testing.T) { + // Optionality does not exempt a descriptor from ambiguity: two materially different + // credentials can fill it, so the selection is ambiguous even though skipping it would + // also have been acceptable. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"status": "active"}}`) + b2 := parseVC(t, `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"status": "inactive"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{a1, b1, b2}, WithStrategy(Strict)) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + assert.ErrorContains(t, err, "B") + }) + + t.Run("Strict stays silent on fill-versus-skip for an optional descriptor", func(t *testing.T) { + // With a single candidate for the optional descriptor, the skip branch also produces a + // complete assignment, but fill is deterministically preferred and never rivals skip. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"status": "active"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, b1}, WithStrategy(Strict)) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + t.Run("Strict does not flag an either-or pick-1 group", func(t *testing.T) { // The alternatives live in different descriptors of a pick-1 group; no descriptor is // filled by both assignments, so there is no rival. From 046278deed07ab0ec128eccbe4a8ebf58e31fae8 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 17:00:38 +0200 Subject: [PATCH 20/25] fix(pe): report losers correctly, name pinned descriptors as ambiguous Two MatchReport gaps found while widening its coverage: a candidate that merely lost to an earlier pick at its own descriptor was blamed for a binding conflict with the winner (losers are now explained against the bindings established by the other descriptors only), and a caller-bound multiplicity error produced OutcomeMultipleCredentials with an empty AmbiguousDescriptors (the pinned descriptors are now named). Also pins: Report nil on the error path without trace, Skipped/Optional/SelectedID on a skipped optional descriptor with a matched outcome, not_selected for eligible losers, and the bound-but-unresolved conflict shape. Assisted-by: AI --- vcr/pe/match_report.go | 26 ++++----- vcr/pe/select.go | 2 + vcr/pe/select_test.go | 117 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go index 1b30dab606..bcb4889391 100644 --- a/vcr/pe/match_report.go +++ b/vcr/pe/match_report.go @@ -129,20 +129,22 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential report.Outcome = OutcomeNoCredentials } - // the decisive bindings: the caller's plus everything the decisive assignment resolved - bindings := copyBindings(initialBindings) - for _, group := range assignment { - if group == nil { - continue - } - for id, value := range group.idValues { - if _, bound := bindings[id]; !bound { - bindings[id] = value + for i, descriptor := range pd.InputDescriptors { + // The decisive bindings for this descriptor's candidates: the caller's plus everything + // the OTHER descriptors' choices resolved. The own descriptor's pick is excluded, so a + // candidate that merely lost to an earlier candidate is not misreported as a binding + // conflict with the winner. + bindings := copyBindings(initialBindings) + for j, group := range assignment { + if j == i || group == nil { + continue + } + for id, value := range group.idValues { + if _, bound := bindings[id]; !bound { + bindings[id] = value + } } } - } - - for i, descriptor := range pd.InputDescriptors { descriptorReport := DescriptorReport{ DescriptorID: descriptor.Id, Optional: i < len(required) && !required[i], diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 37802ad379..b2ce53a64e 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -163,6 +163,8 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt for i := range pools { if err := callerBoundError(pools[i], options.initialBindings); err != nil { boundFailed[i] = true + // the pinned-but-ambiguous descriptors are the report's ambiguous descriptors + ambiguous = append(ambiguous, pools[i].descriptor.Id) if boundErr == nil { boundErr = err } diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 2bf12bf894..812ec2c827 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -1087,6 +1087,123 @@ func TestSelect_Trace(t *testing.T) { assert.Equal(t, "1", dismissed.Dismissal.Found) }) + t.Run("trace off leaves Report nil on the error path too", func(t *testing.T) { + pd := parsePD(t, filterPD) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + assert.Nil(t, result.Report) + }) + + t.Run("an eligible loser is reported as not_selected", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + winner := parseVC(t, `{"id": "vc-winner", "credentialSubject": {"patientId": "123"}}`) + loser := parseVC(t, `{"id": "vc-loser", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{winner, loser}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + loserReport := result.Report.Descriptors[0].Considered[1] + assert.True(t, loserReport.Eligible) + require.NotNil(t, loserReport.Dismissal) + assert.Equal(t, ReasonNotSelected, loserReport.Dismissal.Reason) + }) + + t.Run("a skipped optional descriptor is reported with a matched outcome", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMatched, result.Report.Outcome) + aReport := result.Report.Descriptors[0] + assert.False(t, aReport.Optional) + assert.False(t, aReport.Skipped) + assert.Equal(t, "a1", aReport.SelectedID) + bReport := result.Report.Descriptors[1] + assert.True(t, bReport.Optional) + assert.True(t, bReport.Skipped) + assert.Empty(t, bReport.SelectedID) + }) + + t.Run("caller-bound multiplicity names the descriptor in AmbiguousDescriptors", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [ + {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}, + {"id": "consent_status", "path": ["$.credentialSubject.consentStatus"]} + ]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"}), WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMultipleCredentials, result.Report.Outcome) + assert.Equal(t, []string{"consent_credential"}, result.Report.AmbiguousDescriptors) + }) + + t.Run("a bound field that does not resolve is reported as a binding conflict", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]} + }] + }`) + unresolved := parseVC(t, `{"id": "a-unresolved", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "x"}}`) + resolving := parseVC(t, `{"id": "a-resolving", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Z"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{unresolved, resolving}, + WithInitialBindings(map[string]string{"foo": "Z"}), WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + dismissed := result.Report.Descriptors[0].Considered[0] + assert.True(t, dismissed.Eligible) + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonBindingConflict, dismissed.Dismissal.Reason) + assert.Equal(t, "foo", dismissed.Dismissal.FieldID) + assert.Equal(t, "Z", dismissed.Dismissal.Expected) + assert.Empty(t, dismissed.Dismissal.Found) + }) + t.Run("diverging interchangeable alternatives are flagged", func(t *testing.T) { // Two consents for the same bsn differ in an undeclared field: interchangeable by // contract, but the report flags the divergence for the operator. From 3462e86d498484bf8e073fdf4a828f95635db386 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 17:02:06 +0200 Subject: [PATCH 21/25] docs(pe): document MatchReport usage, add runnable godoc examples The MatchReport godoc now explains how to read a report (outcome, per-descriptor selection, dismissals, ambiguous descriptors, the divergence flag) and its scope: diagnostics for developer tooling, not control flow. Runnable examples cover Select, WithInitialBindings, WithStrategy(Strict), WithSelectionTrace and the two-VP composition; they render in godoc and run as tests. Assisted-by: AI --- vcr/pe/example_select_test.go | 201 ++++++++++++++++++++++++++++++++++ vcr/pe/match_report.go | 32 +++++- 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 vcr/pe/example_select_test.go diff --git a/vcr/pe/example_select_test.go b/vcr/pe/example_select_test.go new file mode 100644 index 0000000000..a7c778ba94 --- /dev/null +++ b/vcr/pe/example_select_test.go @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe_test + +import ( + "encoding/json" + "fmt" + + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +func mustParsePD(jsonStr string) pe.PresentationDefinition { + var pd pe.PresentationDefinition + if err := json.Unmarshal([]byte(jsonStr), &pd); err != nil { + panic(err) + } + return pd +} + +func mustParseVC(jsonStr string) vc.VerifiableCredential { + var cred vc.VerifiableCredential + if err := json.Unmarshal([]byte(jsonStr), &cred); err != nil { + panic(err) + } + return cred +} + +// The engine picks a credential per input descriptor. A field id (patient_id) both names the +// value for token introspection and acts as a binding name. +func ExampleSelect() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`), + } + + result, err := pe.Select(pd, wallet) + if err != nil { + panic(err) + } + + fmt.Println(result.Candidates[0].VC.ID.String()) + fmt.Println(result.Bindings["patient_id"]) + // Output: + // vc-1 + // 123 +} + +// Initial bindings (the credential_selection parameter) pin a field id to a value, selecting +// the credential that carries it. +func ExampleWithInitialBindings() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`), + mustParseVC(`{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`), + } + + result, err := pe.Select(pd, wallet, pe.WithInitialBindings(map[string]string{"patient_id": "456"})) + if err != nil { + panic(err) + } + + fmt.Println(result.Candidates[0].VC.ID.String()) + // Output: vc-2 +} + +// Under Strict, a wallet that can satisfy the PD in more than one materially different way is +// an error naming the descriptors to disambiguate, instead of a silent first pick. +func ExampleWithStrategy() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`), + mustParseVC(`{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Y"}}`), + mustParseVC(`{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "X"}}`), + mustParseVC(`{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Y"}}`), + } + + _, err := pe.Select(pd, wallet, pe.WithStrategy(pe.Strict)) + + fmt.Println(err) + // Output: ambiguous input descriptors [A B]: multiple matching credentials +} + +// The selection trace explains why each candidate was or wasn't used: here the first credential +// fails the PD's filter, and the report names the reason. +func ExampleWithSelectionTrace() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"], "filter": {"type": "string", "const": "999"}}]} + }] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}}`), + mustParseVC(`{"id": "vc-right", "credentialSubject": {"patientId": "999"}}`), + } + + result, err := pe.Select(pd, wallet, pe.WithSelectionTrace()) + if err != nil { + panic(err) + } + + report := result.Report + fmt.Println(report.Outcome) + descriptor := report.Descriptors[0] + fmt.Println(descriptor.SelectedID) + dismissed := descriptor.Considered[0] + fmt.Println(dismissed.CredentialID, dismissed.Dismissal.Reason) + // Output: + // matched + // vc-right + // vc-wrong constraint_filter +} + +// Two-VP composition: build the first presentation, filter its captured bindings down to the +// field ids of the second PD, and seed the second selection with the survivors, so both +// presentations agree on the shared identity. +func ExampleSelect_twoVpComposition() { + orgPD := mustParsePD(`{ + "id": "org-pd", + "input_descriptors": [ + {"id": "org", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.orgDid"]}]}} + ] + }`) + spPD := mustParsePD(`{ + "id": "sp-pd", + "input_descriptors": [ + {"id": "delegation", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.issuedTo"]}]}} + ] + }`) + orgWallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "org-1", "credentialSubject": {"orgDid": "did:web:org"}}`), + } + spWallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "delegation-other", "credentialSubject": {"issuedTo": "did:web:other"}}`), + mustParseVC(`{"id": "delegation-org", "credentialSubject": {"issuedTo": "did:web:org"}}`), + } + + orgResult, err := pe.Select(orgPD, orgWallet) + if err != nil { + panic(err) + } + + // caller-side filter: keep only the captured bindings that are field ids on the SP PD + spFieldIDs := map[string]bool{"org_did": true} + seed := make(map[string]string) + for key, value := range orgResult.Bindings { + if spFieldIDs[key] { + seed[key] = value + } + } + + spResult, err := pe.Select(spPD, spWallet, pe.WithInitialBindings(seed)) + if err != nil { + panic(err) + } + + fmt.Println(spResult.Candidates[0].VC.ID.String()) + // Output: delegation-org +} diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go index bcb4889391..c905f597b8 100644 --- a/vcr/pe/match_report.go +++ b/vcr/pe/match_report.go @@ -58,7 +58,37 @@ const ( ) // MatchReport explains, per input descriptor, why each candidate credential was or wasn't -// selected. It is produced only under WithSelectionTrace. +// selected. It answers the developer question "why doesn't my wallet satisfy this presentation +// definition" without trimming the policy file by hand. +// +// A report is produced only when Select is called with WithSelectionTrace; without it, +// Result.Report is nil and the run pays no reporting cost. The report is assembled after the +// selection by re-evaluating each candidate, so enabling it does not change the outcome. +// +// How to read it: +// +// - Outcome classifies the run as a whole. OutcomeMatched can coexist with unfilled +// descriptors (optional ones that were skipped or dropped by a pick rule). +// - Descriptors follows PD order. Each entry names the selected credential (SelectedID), +// whether the descriptor went unfilled (Skipped) and whether that was allowed (Optional). +// - Considered holds one entry per candidate credential, in candidate order. The selected +// credential has a nil Dismissal; every other candidate carries the reason it was not used, +// with the offending field id, JSONPath, and expected/found values where known. +// - AmbiguousDescriptors names the descriptors that carried more than one choice when Outcome +// is OutcomeMultipleCredentials. These are the descriptors to disambiguate with +// credential_selection keys. +// - DivergingAlternatives (per descriptor) points at wallet hygiene: interchangeable +// credentials whose subjects differ in fields the PD does not declare. Selection is +// unaffected by contract, but the wallet holds potentially contradicting claims. +// +// Binding conflicts are explained against the decisive path: the chosen assignment (or the +// best-effort assignment on failure), with the candidate's own descriptor excluded so that a +// candidate that merely lost to an earlier pick reads as ReasonNotSelected, not as a conflict. +// The report does not narrate every combination the backtracking search visited. +// +// The report is a diagnostic surface for developer tooling (a dev endpoint or UI is a planned +// follow-up); selection outcomes should be branched on Result and the returned error, not on +// report contents. type MatchReport struct { // Descriptors reports on every input descriptor, in PD order. Descriptors []DescriptorReport From c3a188cf8772cf326358e42951a8d2fec75433b4 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 17:12:34 +0200 Subject: [PATCH 22/25] docs(pe): add package vocabulary, align engine docs to it A doc.go front page defines the terms the engine documentation uses (presentation definition as data contract, input descriptor, field id as binding name, binding tuple, initial bindings and caller-bound, eligible, interchangeable, assignment and decisive assignment, optional descriptor, strategy, selection trace) and carries the single provenance pointer to the design issue. The Select and MatchReport godocs now use that vocabulary instead of redefining terms, and the remaining id-bearing phrasing in comments is aligned. Assisted-by: AI --- vcr/pe/doc.go | 74 ++++++++++++++++++++++++++++++++++++++++++ vcr/pe/match_report.go | 8 ++--- vcr/pe/select.go | 49 +++++++++++++--------------- vcr/pe/select_test.go | 2 +- vcr/pe/types.go | 1 - 5 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 vcr/pe/doc.go diff --git a/vcr/pe/doc.go b/vcr/pe/doc.go new file mode 100644 index 0000000000..f4bbf5457b --- /dev/null +++ b/vcr/pe/doc.go @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package pe implements Presentation Exchange (PEX): matching the Verifiable Credentials in a +// wallet against a Presentation Definition, and building the Presentation Submission that +// presents the chosen ones. Select is the single matching engine; Match and the submission +// builder are layered on top of it. +// +// # Vocabulary +// +// The documentation in this package uses these terms consistently: +// +// - Presentation definition (PD): the verifier's description of the credentials it wants: a +// set of input descriptors, plus optional submission requirements. In this system the PD is +// the data contract: only fields the PD declares are mapped to token introspection, so a +// field the PD does not declare plays no role in credential selection either. A filter that +// admits several values, such as an issuer pattern, is a deliberate equivalence declaration +// by the PD author. +// - Input descriptor: one slot of the PD, filled by at most one credential. Its constraint +// fields (JSONPaths with optional filters) and format designations decide which credentials +// fit the slot. +// - Field id / binding name: the optional id on a constraint field. A field id names the +// resolved value in two directions: outward, as the key under which the value appears in +// token introspection and is addressed by the credential_selection parameter; and inward, +// as a binding name, meaning that wherever the same id appears in one selection, the +// resolved values must agree. +// - Binding: an id-to-value pair the selection has committed to. Bindings accumulate while +// the engine fills descriptors, and constrain every later choice. A credential's resolved +// id-to-value pairs for a descriptor are its binding tuple. +// - Initial bindings: bindings supplied before the search starts (WithInitialBindings): the +// caller's credential_selection, or values captured from an earlier presentation in a +// two-VP flow. A descriptor is caller-bound when one of its field ids appears in the +// initial bindings. A caller-bound descriptor must resolve the bound field to the bound +// value (an unresolved optional field does not qualify) and must resolve to exactly one +// interchangeable set of credentials. +// - Eligible credential: a credential that satisfies one input descriptor on its own, +// constraints and formats, before any cross-descriptor consistency is considered. +// - Interchangeable credentials: eligible credentials with identical binding tuples. By the +// data-contract principle nothing the PD declares tells them apart, and no +// credential_selection key could, so the engine treats them as a single choice and uses the +// first in candidate order. Interchangeable credentials whose credentialSubjects +// nevertheless differ are flagged in the MatchReport (DivergingAlternatives). +// - Assignment: a choice of credential, or none, per input descriptor. The engine searches +// for a complete assignment with consistent bindings. The assignment it settles on, also on +// failure, is the decisive assignment: it is what Result reports and what the MatchReport +// explains dismissals against. +// - Optional descriptor: a descriptor the submission requirements allow to go unfilled, for +// example a member of a pick group. Optionality governs whether zero matches is acceptable; +// it never relaxes the multiplicity or consistency rules (zero-vs-some, never one-vs-many). +// - Selection strategy: what to do when more than one materially different complete +// assignment exists. FirstMatch, the default, takes the first found; Strict reports +// ambiguity as ErrMultipleCredentials, naming the descriptors to disambiguate with +// credential_selection keys. +// - Selection trace: the opt-in diagnostic (WithSelectionTrace) that explains, per descriptor +// and candidate, why each credential was or wasn't used. See MatchReport. +// +// These concepts originate as the numbered policies of the design in +// https://github.com/nuts-foundation/nuts-node/issues/4253. +package pe diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go index c905f597b8..aedfd28c87 100644 --- a/vcr/pe/match_report.go +++ b/vcr/pe/match_report.go @@ -81,10 +81,10 @@ const ( // credentials whose subjects differ in fields the PD does not declare. Selection is // unaffected by contract, but the wallet holds potentially contradicting claims. // -// Binding conflicts are explained against the decisive path: the chosen assignment (or the -// best-effort assignment on failure), with the candidate's own descriptor excluded so that a -// candidate that merely lost to an earlier pick reads as ReasonNotSelected, not as a conflict. -// The report does not narrate every combination the backtracking search visited. +// Binding conflicts are explained against the decisive assignment, with the pick at the +// candidate's own descriptor excluded, so that a candidate that merely lost to an earlier pick +// reads as ReasonNotSelected, not as a conflict. The report does not narrate every combination +// the backtracking search visited. // // The report is a diagnostic surface for developer tooling (a dev endpoint or UI is a planned // follow-up); selection outcomes should be branched on Result and the returned error, not on diff --git a/vcr/pe/select.go b/vcr/pe/select.go index b2ce53a64e..958dce60fb 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -36,8 +36,8 @@ const ( // FirstMatch takes the first consistent assignment (the lenient, backward-compatible default). FirstMatch SelectionStrategy = iota // Strict returns ErrMultipleCredentials when a rival assignment exists: one that fills a - // common descriptor with different id-bearing values. Interchangeable credentials (identical - // binding tuples) are never rivals, since no credential_selection key could separate them. + // common descriptor with a different binding tuple. Interchangeable credentials are never + // rivals, since no credential_selection key could separate them. Strict ) @@ -87,7 +87,9 @@ type Result struct { // A nil VC means the descriptor was left unfilled (optional and skipped, dropped by a rule, // or unfillable). Candidates []Candidate - // Bindings holds the resolved field-id to value pairs of the chosen assignment. + // Bindings holds the id-to-value pairs resolved by the surviving credentials of the + // decisive assignment, for chaining into a next Select (the two-VP composition) or for + // reporting. Nil when Select returns an error. Bindings map[string]string // Report explains the selection per descriptor; non-nil only under WithSelectionTrace. Report *MatchReport @@ -95,35 +97,30 @@ type Result struct { // Select resolves a presentation definition against a set of candidate credentials and // returns the chosen descriptor-to-VC assignment. It is the single matching engine: it -// matches each descriptor on its own (step 1), searches for a binding-consistent combination -// across descriptors (step 2), and applies the submission requirement rules (step 3). +// determines each descriptor's eligible credentials (step 1), searches for an assignment with +// consistent bindings (step 2), and applies the submission requirement rules (step 3). // -// The search follows these rules: +// The vocabulary (binding, caller-bound, interchangeable, decisive assignment) is defined in +// the package documentation. The search follows these rules: // -// - Binding consistency: a field id is a binding name. Wherever the same id appears, across -// descriptors or in the caller's initial bindings, the resolved values must agree in the -// chosen assignment. +// - Binding consistency: equal field ids resolve to equal values in the chosen assignment, +// across descriptors and against the initial bindings. // - Prefer fill over skip: an optional descriptor is left unfilled only after all its // consistent candidates are exhausted; a required descriptor with no consistent candidate // makes the search revise an earlier choice. -// - Interchangeability: credentials that resolve identical values for every declared field id -// are interchangeable, and the first in candidate order is used. Fields the PD does not -// declare play no role: the PD is the data contract (only declared fields are mapped to -// token introspection), and a filter admitting several values, such as an issuer pattern, -// is a deliberate equivalence declaration by the PD author. Interchangeable credentials -// whose subjects nevertheless differ are flagged in the MatchReport. -// - Caller-bound multiplicity: a descriptor with a field id in the initial bindings must -// resolve to exactly one interchangeable set; more than one is ErrMultipleCredentials, and -// the remedy is always a bindable key. The bound field must resolve on the chosen -// credential; an unresolved optional field does not satisfy a bound id. +// - Interchangeability: credentials with identical binding tuples are a single choice; the +// first in candidate order is used. Fields the PD does not declare play no role. +// - Caller-bound multiplicity: a caller-bound descriptor must resolve to exactly one +// interchangeable set; more than one is ErrMultipleCredentials, and the remedy is always a +// bindable key. The bound field must actually resolve; an unresolved optional field does +// not satisfy a bound id. // - Unresolved optional fields bind nothing: between descriptors, a field that resolves no // value contributes no binding entry. -// - Ambiguity (Strict only): when a rival assignment exists that fills a common descriptor -// with a different binding tuple, the selection errors instead of silently picking one. +// - Ambiguity (Strict only): a rival assignment, one that fills a common descriptor with a +// different binding tuple, is ErrMultipleCredentials instead of a silent pick. // -// These rules originate as the numbered policies of the design in -// https://github.com/nuts-foundation/nuts-node/issues/4253. Candidates are expected to be -// time-valid; the wallet filters expired and revoked credentials before calling the engine. +// Candidates are expected to be time-valid; the wallet filters expired and revoked credentials +// before calling the engine. func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (result Result, err error) { var options selectOptions for _, opt := range opts { @@ -401,7 +398,7 @@ func callerBoundError(pool descriptorPool, initialBindings map[string]string) er } // candidateGroup is a set of credentials that are interchangeable for one descriptor: they passed -// its constraints and resolve identical values for every id-bearing field. The search branches per +// its constraints with identical binding tuples (they are interchangeable). The search branches per // group, not per credential; creds[0] represents the group in the final assignment. type candidateGroup struct { idValues map[string]string @@ -685,7 +682,7 @@ type eligibleCandidate struct { // eligibleCandidates returns the credentials that satisfy a single input descriptor on its own: // its constraints (matchConstraint) and both the PD-level and descriptor-level format gates. The -// matched id-bearing field values are recorded (stringified) for later consistency checks. +// resolved field-id values are recorded (stringified) as the candidate's binding tuple. func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]eligibleCandidate, error) { var eligible []eligibleCandidate for _, candidate := range candidates { diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 812ec2c827..0ca60cb4d4 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -858,7 +858,7 @@ func TestSelect_Strategy(t *testing.T) { }) t.Run("Strict does not flag interchangeable credentials", func(t *testing.T) { - // A reissued duplicate: identical id-bearing values, one binding tuple, one rival. + // A reissued duplicate: identical binding tuples, so a single interchangeable choice. pd := parsePD(t, `{ "id": "test-pd", "input_descriptors": [{ diff --git a/vcr/pe/types.go b/vcr/pe/types.go index 56253ded78..485b2a4b68 100644 --- a/vcr/pe/types.go +++ b/vcr/pe/types.go @@ -16,7 +16,6 @@ * */ -// Package pe stands for Presentation Exchange which includes Presentation Definition and Presentation Submission package pe import "errors" From f3c7ed6cc01721436654a9303b7387f99297b829 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 17:29:50 +0200 Subject: [PATCH 23/25] fix(pe): consume rule selections so a shared credential fills one descriptor A credential filling two descriptors of a pick group survived on both: rule-excluded descriptors were cleared by VC value membership, so pick count 1 could leave two descriptors filled. The selection is now consumed first-descriptor-wins (PD order), matching the legacy descriptor-map behavior and the documented dropped-by-rule contract. Assisted-by: AI --- vcr/pe/select.go | 28 +++++++++++++++------------- vcr/pe/select_test.go | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 958dce60fb..4b60c941c2 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -637,25 +637,27 @@ func applySubmissionRequirements(pd PresentationDefinition, candidates []Candida } selectedVCs = deduplicate(selectedVCs) - // Clear the descriptors whose chosen VC a rule excluded. + // Keep each selected credential on the first descriptor that carries it (PD order) and clear + // the rest. The rules select credentials, not descriptors: consuming the selection prevents a + // credential that fills several descriptors from multiplying past the rule's count, and + // matches the legacy first-descriptor mapping. + remaining := selectedVCs result := make([]Candidate, len(candidates)) for i, candidate := range candidates { result[i] = candidate - if candidate.VC == nil || !containsVC(selectedVCs, *candidate.VC) { - result[i].VC = nil + result[i].VC = nil + if candidate.VC == nil { + continue } - } - return result, nil -} - -// containsVC reports whether target is present in vcs, compared by value. -func containsVC(vcs []vc.VerifiableCredential, target vc.VerifiableCredential) bool { - for _, candidate := range vcs { - if vcEqual(candidate, target) { - return true + for j, selected := range remaining { + if vcEqual(selected, *candidate.VC) { + result[i].VC = candidate.VC + remaining = append(remaining[:j], remaining[j+1:]...) + break + } } } - return false + return result, nil } // requireAllFilled reports ErrNoCredentials when any descriptor was left unfilled. It encodes the diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 0ca60cb4d4..3c992f5164 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -1372,6 +1372,29 @@ func TestSelect_SubmissionRequirements(t *testing.T) { assert.Equal(t, "vc-2", result.Candidates[1].VC.ID.String()) }) + t.Run("one credential filling two pick-group descriptors is kept on the first only", func(t *testing.T) { + // The rules select credentials, not descriptors: pick count 1 selects one credential, + // and a credential filling several descriptors must not multiply past that count. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "A", "count": 1}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + // satisfies both descriptors + cred := parseVC(t, `{"id": "vc-both", "credentialSubject": {"f1": "x", "f2": "y"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-both", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) + t.Run("pick rule selects a subset and clears the rest", func(t *testing.T) { pd := parsePD(t, `{ "id": "test-pd", From 010fe8bcfaa6ec38fb3bd407557464abc1e05194 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 17:30:48 +0200 Subject: [PATCH 24/25] fix(pe): fail closed on unknown credential formats, add limit sentinel Restore the explicit format switch in credentialAsMap: a format the engine does not know resolves no fields and is silently ineligible, instead of being remarshaled by guesswork (a future token-backed format would marshal to a JSON string and abort the whole match with an unmarshal error). Export ErrSearchLimitReached and wrap it in the node-limit abort so callers can classify the third failure class once the engine reaches the public API surface. Assisted-by: AI --- vcr/pe/presentation_definition.go | 7 ++++++- vcr/pe/select.go | 7 ++++++- vcr/pe/select_test.go | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 42bd5a8200..26cdcb50b8 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -429,8 +429,13 @@ func credentialAsMap(credential vc.VerifiableCredential) (map[string]interface{} // with the VC properties, instead of a JWT string. type Alias vc.VerifiableCredential return remarshalToMap(Alias(credential)) - default: // JSON-LD or holder credential + case vc.JSONLDCredentialProofFormat, "": // "" is a holder credential return remarshalToMap(credential) + default: + // fail closed: a format this engine does not know resolves no fields and is never + // eligible, rather than being remarshaled by guesswork (a token-backed format would + // marshal to a JSON string and turn into an error that aborts the whole match). + return nil, nil } } diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 4b60c941c2..a0cc9a5f7a 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -205,7 +205,7 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt if limited { // A found-but-unproven assignment is not returned as a success: under Strict that would // silently downgrade the ambiguity guarantee. - return result, fmt.Errorf("presentation definition '%s': search aborted, node limit reached (%d)", pd.Id, searchNodeLimit) + return result, fmt.Errorf("presentation definition '%s': search aborted, %w (%d)", pd.Id, ErrSearchLimitReached, searchNodeLimit) } if len(ambiguous) > 0 { // The decisive assignment stays in Candidates so the caller can see what would have won. @@ -247,6 +247,11 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt return result, nil } +// ErrSearchLimitReached is returned when the backtracking search exceeds its node-visit limit, a +// defense against pathological (counterparty-supplied) presentation definitions. It is a distinct +// failure class: neither "no credentials" nor "multiple credentials". +var ErrSearchLimitReached = errors.New("search node limit reached") + // searchNodeLimit bounds the number of node visits of a single search. Presentation definitions // arrive from the counterparty in wallet flows, so an unbounded backtracking search would be // remotely triggerable CPU exhaustion; legitimate wallets stay orders of magnitude below this. diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go index 3c992f5164..389a0cbe3b 100644 --- a/vcr/pe/select_test.go +++ b/vcr/pe/select_test.go @@ -1305,9 +1305,9 @@ func TestSelect_NodeLimit(t *testing.T) { _, err := Select(pd, creds) - require.Error(t, err) + assert.ErrorIs(t, err, ErrSearchLimitReached) + assert.NotErrorIs(t, err, ErrNoCredentials) assert.ErrorContains(t, err, "exploding-pd") - assert.ErrorContains(t, err, "limit") }) t.Run("a realistic search stays far below the default limit", func(t *testing.T) { From 850d73eee515fb63ff9b61c50e4c504d4408bde8 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 15 Jul 2026 17:34:01 +0200 Subject: [PATCH 25/25] refactor(pe): share the step-1 primitive, cut search-node allocations Self-review follow-ups: evaluateCandidate is now the single step-1 eligibility primitive used by both the selection and the MatchReport, so a traced report cannot drift from what the selection did (and the selected credential is no longer evaluated twice per descriptor). consistentGroups picks the narrowest posting list by length before materializing one merge, returns a shared precomputed index for unnarrowed visits, and iterates the pool's id universe instead of the growing bindings map, removing all per-node allocations for the common cases. copyBindings uses maps.Copy; the MatchReport documentation now states the first-rival-only scope of AmbiguousDescriptors and the best-effort nature of failed-run explanations. Assisted-by: AI --- vcr/pe/match_report.go | 59 +++++++++------------ vcr/pe/select.go | 114 ++++++++++++++++++++++++++--------------- 2 files changed, 97 insertions(+), 76 deletions(-) diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go index aedfd28c87..6a333e415f 100644 --- a/vcr/pe/match_report.go +++ b/vcr/pe/match_report.go @@ -74,9 +74,10 @@ const ( // - Considered holds one entry per candidate credential, in candidate order. The selected // credential has a nil Dismissal; every other candidate carries the reason it was not used, // with the offending field id, JSONPath, and expected/found values where known. -// - AmbiguousDescriptors names the descriptors that carried more than one choice when Outcome -// is OutcomeMultipleCredentials. These are the descriptors to disambiguate with -// credential_selection keys. +// - AmbiguousDescriptors names the descriptors to disambiguate with credential_selection keys +// when Outcome is OutcomeMultipleCredentials. The search stops at the first rival assignment +// it finds, so the list covers that rival only: a wallet can hold further ambiguities that +// surface on a later run, after the named ones are pinned. // - DivergingAlternatives (per descriptor) points at wallet hygiene: interchangeable // credentials whose subjects differ in fields the PD does not declare. Selection is // unaffected by contract, but the wallet holds potentially contradicting claims. @@ -84,7 +85,11 @@ const ( // Binding conflicts are explained against the decisive assignment, with the pick at the // candidate's own descriptor excluded, so that a candidate that merely lost to an earlier pick // reads as ReasonNotSelected, not as a conflict. The report does not narrate every combination -// the backtracking search visited. +// the backtracking search visited. On a failed run the decisive assignment is best-effort: each +// descriptor's first candidate consistent with the initial bindings, chosen independently, so +// SelectedID and the Expected values in conflict explanations are relative to that assignment +// and can vary with candidate order; they show one concrete way the selection fails, not an +// authoritative commitment. // // The report is a diagnostic surface for developer tooling (a dev endpoint or UI is a planned // follow-up); selection outcomes should be branched on Result and the returned error, not on @@ -94,8 +99,8 @@ type MatchReport struct { Descriptors []DescriptorReport // Outcome classifies the overall result. Outcome Outcome - // AmbiguousDescriptors names the descriptors that carried more than one choice - // (OutcomeMultipleCredentials only). + // AmbiguousDescriptors names the descriptors to disambiguate (OutcomeMultipleCredentials + // only). Populated from the first rival assignment found; see the type documentation. AmbiguousDescriptors []string } @@ -187,8 +192,10 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential strictIDs := strictBoundIDs(*descriptor, initialBindings) var selectedIDValues map[string]string + selectedKey := "" if selected != nil { - _, selectedIDValues = evaluateCandidate(pd, *descriptor, *selected) + _, selectedIDValues, _ = evaluateCandidate(pd, *descriptor, *selected) + selectedKey = tupleKey(selectedIDValues) } selectedSeen := false for _, candidate := range candidates { @@ -196,18 +203,22 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential if candidate.ID != nil { candidateReport.CredentialID = candidate.ID.String() } - eligible, idValues := evaluateCandidate(pd, *descriptor, candidate) + if selected != nil && !selectedSeen && vcEqual(candidate, *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) candidateReport.Eligible = eligible - switch { - case !eligible: + if !eligible { candidateReport.Dismissal = explainIneligible(pd, *descriptor, candidate) - case selected != nil && !selectedSeen && vcEqual(candidate, *selected): - selectedSeen = true // the chosen credential carries no dismissal - default: + } 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) == tupleKey(selectedIDValues) && !subjectsEqual(candidate, *selected) { + if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate, *selected) { descriptorReport.DivergingAlternatives = true } } @@ -226,26 +237,6 @@ func subjectsEqual(a, b vc.VerifiableCredential) bool { return errA == nil && errB == nil && bytes.Equal(aJSON, bJSON) } -// evaluateCandidate re-runs the step-1 eligibility of one credential for one descriptor. -func evaluateCandidate(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) (bool, map[string]string) { - idValues := make(map[string]string) - if descriptor.Constraints != nil { - isMatch, values, err := matchConstraint(descriptor.Constraints, credential) - if err != nil || !isMatch { - return false, nil - } - for id, value := range values { - if s, ok := stringifyBindingValue(value); ok { - idValues[id] = s - } - } - } - if !matchFormat(pd.Format, credential) || !matchFormat(descriptor.Format, credential) { - return false, nil - } - return true, idValues -} - // 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 { diff --git a/vcr/pe/select.go b/vcr/pe/select.go index a0cc9a5f7a..1a7b8a270d 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -21,6 +21,7 @@ package pe import ( "errors" "fmt" + "maps" "sort" "strconv" "strings" @@ -426,6 +427,9 @@ type descriptorPool struct { // resolve the field to the bound value; unresolved is not acceptable (legacy field-selector // semantics). The unresolved-optional leniency covers only bindings accumulated during the search. strictIDs []string + // all holds every group index in ascending order, shared across search-node visits so an + // unnarrowed consistency check allocates nothing. Callers must not mutate it. + all []int } // strictBoundIDs returns the descriptor's field ids that appear in the caller's initial bindings. @@ -486,6 +490,10 @@ func buildPool(pd PresentationDefinition, descriptor InputDescriptor, candidates } } } + pool.all = make([]int, len(pool.groups)) + for i := range pool.all { + pool.all[i] = i + } return pool, nil } @@ -493,10 +501,13 @@ func buildPool(pd PresentationDefinition, descriptor InputDescriptor, candidates // every shared id. Bound ids outside the pool's universe are irrelevant, except strict ids, which // a group must resolve to the bound value. When at least one bound id is in the universe, the scan // is narrowed to the smallest posting list (groups carrying the bound value, plus, for non-strict -// ids, groups not resolving the id at all) before the full per-group check. +// ids, groups not resolving the id at all) before the full per-group check. This runs on every +// search-node visit: the narrowest list is chosen by length alone, and only that one is +// materialized, so an unnarrowed visit allocates nothing. func (p descriptorPool) consistentGroups(bindings map[string]string) []int { - var narrowed []int - haveNarrowed := false + narrowID := "" + narrowLen := -1 + narrowStrict := false for _, id := range p.strictIDs { bound, ok := bindings[id] if !ok { @@ -507,28 +518,34 @@ func (p descriptorPool) consistentGroups(bindings map[string]string) []int { if len(list) == 0 { return nil } - if !haveNarrowed || len(list) < len(narrowed) { - narrowed, haveNarrowed = list, true + if narrowLen < 0 || len(list) < narrowLen { + narrowID, narrowLen, narrowStrict = id, len(list), true } } - for id, value := range bindings { - values, known := p.byValue[id] - if !known { + // iterate the pool's id universe (bounded by the descriptor's declared ids) rather than the + // bindings map, which grows with search depth + for id := range p.byValue { + value, bound := bindings[id] + if !bound { continue } - merged := mergeSorted(values[value], p.lacking[id]) - if !haveNarrowed || len(merged) < len(narrowed) { - narrowed, haveNarrowed = merged, true + mergedLen := len(p.byValue[id][value]) + len(p.lacking[id]) + if narrowLen < 0 || mergedLen < narrowLen { + narrowID, narrowLen, narrowStrict = id, mergedLen, false } } - if !haveNarrowed { - all := make([]int, len(p.groups)) - for i := range all { - all[i] = i - } - return all + var narrowed []int + switch { + case narrowLen < 0: + // no bound id touches this pool: every group is consistent (a strict id is always + // bound, so strictIDs is empty here) and the shared index is returned as-is + return p.all + case narrowStrict: + narrowed = p.byValue[narrowID][bindings[narrowID]] + default: + narrowed = mergeSorted(p.byValue[narrowID][bindings[narrowID]], p.lacking[narrowID]) } - var consistent []int + consistent := narrowed[:0:0] // nil-preserving; appended below, never mutates narrowed for _, gi := range narrowed { group := p.groups[gi] if !consistentIDValues(group.idValues, bindings) { @@ -601,11 +618,10 @@ func tupleKey(idValues map[string]string) string { } // copyBindings clones the initial bindings so the search can mutate its working map freely. +// Not maps.Clone: that returns nil for a nil input, and the search needs a writable map. func copyBindings(bindings map[string]string) map[string]string { copied := make(map[string]string, len(bindings)) - for key, value := range bindings { - copied[key] = value - } + maps.Copy(copied, bindings) return copied } @@ -687,36 +703,50 @@ type eligibleCandidate struct { idValues map[string]string } -// eligibleCandidates returns the credentials that satisfy a single input descriptor on its own: -// its constraints (matchConstraint) and both the PD-level and descriptor-level format gates. The -// resolved field-id values are recorded (stringified) as the candidate's binding tuple. +// 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) { var eligible []eligibleCandidate for _, candidate := range candidates { - idValues := make(map[string]string) - if descriptor.Constraints != nil { - isMatch, values, err := matchConstraint(descriptor.Constraints, candidate) - if err != nil { - return nil, err - } - if !isMatch { - continue - } - for id, value := range values { - if s, ok := stringifyBindingValue(value); ok { - idValues[id] = s - } - } + ok, idValues, err := evaluateCandidate(pd, descriptor, candidate) + if err != nil { + return nil, err } - // InputDescriptor formats must be a subset of the PresentationDefinition formats, so satisfy both. - if !matchFormat(pd.Format, candidate) || !matchFormat(descriptor.Format, candidate) { - continue + if ok { + eligible = append(eligible, eligibleCandidate{vc: candidate, idValues: idValues}) } - eligible = append(eligible, eligibleCandidate{vc: candidate, 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) { + idValues := make(map[string]string) + if descriptor.Constraints != nil { + isMatch, values, err := matchConstraint(descriptor.Constraints, credential) + 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 + } + } + } + // InputDescriptor formats must be a subset of the PresentationDefinition formats, so satisfy both. + if !matchFormat(pd.Format, credential) || !matchFormat(descriptor.Format, credential) { + return false, nil, nil + } + return true, idValues, nil +} + // stringifyBindingValue renders a resolved field value as the string used for binding comparison: // strings as-is, float64 without a trailing zero exponent, bool as true/false. Any other type // (including nil, e.g. an unresolved optional field) is not bindable.