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/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
new file mode 100644
index 0000000000..6a333e415f
--- /dev/null
+++ b/vcr/pe/match_report.go
@@ -0,0 +1,319 @@
+/*
+ * 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 (
+ "bytes"
+ "encoding/json"
+ "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 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 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.
+//
+// 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. 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
+// report contents.
+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 to disambiguate (OutcomeMultipleCredentials
+ // only). Populated from the first rival assignment found; see the type documentation.
+ 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
+ // 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.
+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
+ }
+
+ 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
+ }
+ }
+ }
+ 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)
+
+ var selectedIDValues map[string]string
+ selectedKey := ""
+ if selected != nil {
+ _, selectedIDValues, _ = evaluateCandidate(pd, *descriptor, *selected)
+ selectedKey = tupleKey(selectedIDValues)
+ }
+ selectedSeen := false
+ for _, candidate := range candidates {
+ candidateReport := CandidateReport{}
+ if candidate.ID != nil {
+ candidateReport.CredentialID = candidate.ID.String()
+ }
+ 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
+ if !eligible {
+ candidateReport.Dismissal = explainIneligible(pd, *descriptor, candidate)
+ } else {
+ candidateReport.Dismissal = explainNotChosen(idValues, bindings, strictIDs)
+ // An interchangeable alternative (same binding tuple as the chosen credential)
+ // whose subject nevertheless differs is worth the operator's attention.
+ if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate, *selected) {
+ descriptorReport.DivergingAlternatives = true
+ }
+ }
+ descriptorReport.Considered = append(descriptorReport.Considered, candidateReport)
+ }
+ report.Descriptors = append(report.Descriptors, descriptorReport)
+ }
+ 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)
+}
+
+// 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..26cdcb50b8 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,24 @@ 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))
+ 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
+ }
+}
+
// 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
new file mode 100644
index 0000000000..1a7b8a270d
--- /dev/null
+++ b/vcr/pe/select.go
@@ -0,0 +1,764 @@
+/*
+ * 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"
+ "maps"
+ "sort"
+ "strconv"
+ "strings"
+
+ "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 a different binding tuple. Interchangeable credentials 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
+ // trace enables the MatchReport diagnostics.
+ trace bool
+}
+
+// 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)
+
+// 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
+ }
+}
+
+// WithStrategy selects the ambiguity policy; the default is FirstMatch.
+func WithStrategy(s SelectionStrategy) Option {
+ return func(o *selectOptions) {
+ o.strategy = s
+ }
+}
+
+// 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
+ // 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 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
+}
+
+// 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
+// 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 vocabulary (binding, caller-bound, interchangeable, decisive assignment) is defined in
+// the package documentation. The search follows these rules:
+//
+// - 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 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): a rival assignment, one that fills a common descriptor with a
+// different binding tuple, is ErrMultipleCredentials instead of a silent pick.
+//
+// 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 {
+ opt(&options)
+ }
+
+ 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, poolErr := buildPool(pd, *descriptor, candidates)
+ if poolErr != nil {
+ return result, poolErr
+ }
+ pool.strictIDs = strictBoundIDs(*descriptor, options.initialBindings)
+ 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
+ // the pinned-but-ambiguous descriptors are the report's ambiguous descriptors
+ ambiguous = append(ambiguous, pools[i].descriptor.Id)
+ if boundErr == nil {
+ boundErr = err
+ }
+ }
+ }
+
+ // 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)
+ }
+ }
+ 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]]
+ }
+ }
+ }
+ for i := range assignment {
+ if assignment[i] != nil {
+ result.Candidates[i].VC = &assignment[i].creds[0]
+ }
+ }
+ 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, %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.
+ 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.
+ if len(pd.SubmissionRequirements) == 0 {
+ // With no submission requirements every descriptor is required.
+ 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
+ }
+ 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)
+ }
+
+ // 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
+}
+
+// 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.
+// 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
+ 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
+ // 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
+// 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 {
+ s.visited++
+ if s.visited > searchNodeLimit {
+ s.limited = true
+ return true
+ }
+ if i == len(s.pools) {
+ return s.emit(current)
+ }
+ for _, gi := range s.pools[i].consistentGroups(bindings) {
+ group := &s.pools[i].groups[gi]
+ current[i] = group
+ added := addBindings(bindings, group.idValues)
+ stop := s.search(i+1, bindings, current)
+ for _, key := range added {
+ delete(bindings, key)
+ }
+ if stop {
+ return true
+ }
+ }
+ current[i] = nil
+ if !s.required[i] {
+ 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
+}
+
+// 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 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
+ }
+ if len(pool.consistentGroups(initialBindings)) > 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 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
+ 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
+ // 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). 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.
+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
+// 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)
+ }
+ }
+ }
+ pool.all = make([]int, len(pool.groups))
+ for i := range pool.all {
+ pool.all[i] = i
+ }
+ 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, 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. 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 {
+ narrowID := ""
+ narrowLen := -1
+ narrowStrict := 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 narrowLen < 0 || len(list) < narrowLen {
+ narrowID, narrowLen, narrowStrict = id, len(list), true
+ }
+ }
+ // 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
+ }
+ mergedLen := len(p.byValue[id][value]) + len(p.lacking[id])
+ if narrowLen < 0 || mergedLen < narrowLen {
+ narrowID, narrowLen, narrowStrict = id, mergedLen, false
+ }
+ }
+ 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])
+ }
+ consistent := narrowed[:0:0] // nil-preserving; appended below, never mutates narrowed
+ for _, gi := range narrowed {
+ 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 {
+ 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.
+// 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))
+ maps.Copy(copied, bindings)
+ 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) {
+ // 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)
+
+ // 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
+ result[i].VC = nil
+ if candidate.VC == nil {
+ continue
+ }
+ for j, selected := range remaining {
+ if vcEqual(selected, *candidate.VC) {
+ result[i].VC = candidate.VC
+ remaining = append(remaining[:j], remaining[j+1:]...)
+ break
+ }
+ }
+ }
+ 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
+}
+
+// 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,
+// each paired with its binding tuple.
+func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]eligibleCandidate, error) {
+ var eligible []eligibleCandidate
+ for _, candidate := range candidates {
+ ok, idValues, err := evaluateCandidate(pd, descriptor, candidate)
+ if err != nil {
+ return nil, err
+ }
+ if ok {
+ 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.
+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
new file mode 100644
index 0000000000..389a0cbe3b
--- /dev/null
+++ b/vcr/pe/select_test.go
@@ -0,0 +1,1419 @@
+/*
+ * 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"
+ "fmt"
+ "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())
+ })
+
+ 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"}}`)
+
+ 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) {
+ 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())
+ })
+}
+
+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, `{
+ "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 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": [{
+ "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"}}`)
+
+ 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)
+ require.Len(t, result.Candidates, 1)
+ 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 (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": "status", "path": ["$.credentialSubject.status"]}
+ ]}},
+ {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}}
+ ]
+ }`)
+ 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},
+ 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",
+ "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("unresolved optional field does not bind between descriptors", func(t *testing.T) {
+ // 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",
+ "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 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("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",
+ "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())
+ })
+}
+
+func TestSelect_SameIDBinding(t *testing.T) {
+ t.Run("same id across descriptors must agree, backtracking into a consistent pair", func(t *testing.T) {
+ // 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",
+ "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) {
+ // 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, `{
+ "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_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) {
+ // 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",
+ "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) {
+ // Ambiguity worked example (design #4253): 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 binding tuples, so a single interchangeable choice.
+ 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 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.
+ 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_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) {
+ // 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",
+ "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("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.
+ 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",
+ "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_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)
+
+ assert.ErrorIs(t, err, ErrSearchLimitReached)
+ assert.NotErrorIs(t, err, ErrNoCredentials)
+ assert.ErrorContains(t, err, "exploding-pd")
+ })
+
+ t.Run("a realistic search stays far below the default limit", func(t *testing.T) {
+ // sanity check: an ordinary single-descriptor match 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, `{
+ "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"}}`)
+
+ 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) {
+ 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("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",
+ "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)
+ })
+}
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"