Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
57e7ba5
chore: scaffold draft PR for #4253 item 1 (select engine)
stevenvegt May 26, 2026
3f42622
feat(pe): add Select tracer (single descriptor match)
stevenvegt May 29, 2026
a4e3595
feat(pe): reproduce FirstMatch selection in Select
stevenvegt May 29, 2026
51efc51
feat(pe): reproduce field-selector bindings in Select
stevenvegt May 29, 2026
a5eb6fb
feat(pe): apply submission-requirement rules in Select
stevenvegt May 29, 2026
368ddb6
fix(pe): keep failing descriptor in Select candidates on error
stevenvegt Jun 12, 2026
6a11e51
test(pe): backfill format-gating and nil-constraints coverage for Select
stevenvegt Jul 14, 2026
9d5637c
fix(pe): keep Result.Candidates full length on selection error
stevenvegt Jul 14, 2026
5c70bf6
refactor(pe): replace flat selection loop with recursive search core
stevenvegt Jul 14, 2026
bb6906d
test(pe): pin same-id binding policies with the PRD worked examples
stevenvegt Jul 14, 2026
18d27d0
feat(pe): make initial bindings strict, pin P6 non-binding semantics
stevenvegt Jul 14, 2026
ceed7ef
feat(pe): add Strict strategy with binding-tuple ambiguity (Policy 5)
stevenvegt Jul 14, 2026
b03249f
feat(pe): expose resolved id-values as Result.Bindings
stevenvegt Jul 14, 2026
008fdd9
feat(pe): produce MatchReport diagnostics under WithSelectionTrace
stevenvegt Jul 14, 2026
ce799ec
feat(pe): abort pathological searches at a node-visit limit
stevenvegt Jul 14, 2026
5433b86
feat(pe): count caller-bound multiplicity per binding tuple
stevenvegt Jul 15, 2026
7e48396
feat(pe): flag diverging interchangeable alternatives in MatchReport
stevenvegt Jul 15, 2026
0358b0e
docs(pe): define the selection rules in the Select godoc
stevenvegt Jul 15, 2026
57a9ee9
test(pe): pin optionality vs multiplicity edges
stevenvegt Jul 15, 2026
046278d
fix(pe): report losers correctly, name pinned descriptors as ambiguous
stevenvegt Jul 15, 2026
3462e86
docs(pe): document MatchReport usage, add runnable godoc examples
stevenvegt Jul 15, 2026
c3a188c
docs(pe): add package vocabulary, align engine docs to it
stevenvegt Jul 15, 2026
f3c7ed6
fix(pe): consume rule selections so a shared credential fills one des…
stevenvegt Jul 15, 2026
010fe8b
fix(pe): fail closed on unknown credential formats, add limit sentinel
stevenvegt Jul 15, 2026
850d73e
refactor(pe): share the step-1 primitive, cut search-node allocations
stevenvegt Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions vcr/pe/doc.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*
*/

// 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
201 changes: 201 additions & 0 deletions vcr/pe/example_select_test.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*
*/

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
}
Loading
Loading