Skip to content

[#4253 1/8] feat(pe): core selection engine Select + MatchReport#4280

Draft
stevenvegt wants to merge 1 commit into
feature/4253-credential-selectionfrom
4253-1-select-engine
Draft

[#4253 1/8] feat(pe): core selection engine Select + MatchReport#4280
stevenvegt wants to merge 1 commit into
feature/4253-credential-selectionfrom
4253-1-select-engine

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

@stevenvegt stevenvegt commented May 26, 2026

Parent PRD

#4253

Item 1 of 8. Foundation for the credential-selection rewrite. Also folds in the developer-diagnostics work from #4218 (this MatchReport supersedes the logging approach in closed PR #4225).

Summary

Introduce the new pure matching engine pe.Select in vcr/pe, with structured diagnostics (MatchReport). Select is consolidated: it owns the whole matching pipeline in three steps: (1) find which credentials match each descriptor's constraints and format; (2) search for a combination of credentials whose shared-id field values agree; (3) apply the PD's submission-requirement rules (pick/all/min/max) to that combination, reusing the existing groups()/apply()/deduplicate() helpers. No existing caller is changed in this PR; the engine is exercised entirely by a new black-box test suite. Item #3 then rewires Match onto Select and deletes matchConstraints/matchBasic/matchSubmissionRequirements/MatchWithSelector and the CredentialSelector abstraction.

Design note (decided during implementation): the engine is consolidated, not layered. Once Select receives the whole PD it can derive everything it needs from the PD itself, including which descriptors are allowed to go unfilled, so there is no reason for the caller to compute and pass that. Floor-aware pick min>=1 cooperation stays out of scope (PRD #4253 "Out of Scope"); Select uses a simple skip rule.

Implementation Spec

Surface (all new, in vcr/pe)

type SelectionStrategy int
const (
    FirstMatch SelectionStrategy = iota // default ("loose"): take the first consistent assignment
    Strict                              // ErrMultipleCredentials on a second consistent assignment
)

type Option func(*selectOptions)
func WithInitialBindings(b map[string]string) Option // seed id->value (credential_selection)
func WithStrategy(s SelectionStrategy) Option
func WithSelectionTrace() Option                     // populate Result.Report; default off

func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (Result, error)

type Result struct {
    Candidates []Candidate        // reuse existing type; VC == nil => descriptor unfilled (skipped, or dropped by a submission-requirement rule)
    Bindings   map[string]string  // resolved id->value of the chosen assignment
    Report     *MatchReport       // non-nil only when WithSelectionTrace() is set
}

Bindings is map[string]string, reusing the value stringification already in matchesSelections (selector.go).

Algorithm (three steps)

Step 1: match each descriptor on its own. For each input descriptor, evaluate every candidate credential via the existing matchConstraint (presentation_definition.go:400) + matchFormat, with no cross-descriptor binding yet. Record pass/fail and, on fail, the reason (no value / filter / format). No new field-matching logic.

Step 2: search for a consistent combination (depth-first over the matching credentials, in PD order):

  • Binding consistency (P3, always on): a candidate is consistent only if its id-bearing resolved values agree with the running bindings. Seed from WithInitialBindings; keys not present as field ids on this PD are dropped.
  • Backtracking + optional skip (P4): try every consistent candidate; a required descriptor with no consistent candidate fails the branch and backtracks; an optional descriptor is skipped (VC=nil) only after its candidates are exhausted. Whether a descriptor may be left unfilled is derived from pd.SubmissionRequirements with a simple rule: with no submission requirements every descriptor is required; otherwise a descriptor is optional when its group can be satisfied without it.
  • Unresolved optional doesn't bind (P6): a candidate's optional:true field that resolves to no value contributes no binding.
  • Ambiguity (P5): see strategies below.

Step 3: apply the submission-requirement rules. Select passes the step-2 combination to the existing groups()/apply()/deduplicate() helpers (rule logic unchanged) to enforce the PD's pick/all/count/min/max/nested rules and produce the final selection; a descriptor dropped by a rule ends with VC=nil. The descriptor-map / JSON-path formatting that matchBasic and matchSubmissionRequirements did is not part of Select; it moves to the thin Match wrapper in item #3. The simple skip rule from step 2 can disagree with exact rule satisfaction in the deferred pick min>=1 case; that limitation is documented and now lives in one place.

Selection strategies

The engine always enforces binding consistency (P3), backtracking/optional-skip (P4), and unresolved-optional-doesn't-bind (P6). The strategy controls only what happens when more than one complete, binding-consistent assignment exists:

  • FirstMatch (default): return the first assignment. This is the lenient, backward-compatible behavior. For any PD that does not reuse an id across descriptors it reproduces today's matcher exactly, including NewFieldSelector's ErrMultipleCredentials when a caller-bound descriptor still resolves to more than one credential.
  • Strict: a second complete consistent assignment is ErrMultipleCredentials (Policy 5), naming the ambiguous descriptors so the caller can add disambiguating selection keys. Strict supersedes FirstMatch.

Policy 5 (ambiguity-as-error) is the behavior-risk to be aware of. It is the only rule here that can turn a previously-succeeding match into an error: a descriptor with several equally-valid credentials that the caller did not disambiguate is silently first-picked today but errors under Strict. That is exactly why it is gated behind the strategy rather than always on. By contrast Policy 3 (same-id agreement) is always on but safe — it only affects PDs that reuse an id across descriptors, which is a new pattern with no deployed users.

Strategy is an input, not parsed here (scope)

Select takes a SelectionStrategy value and honors it. How that value is chosen is out of scope for this PR. The intent is to declare it per-PD at the config layer (policy credentialProfileConfig / discovery ServiceDefinition), defaulting to FirstMatch ("loose") and flipped to Strict in a future major release — but parsing and registration of that config land in items #5/#7. This PR only defines the enum and makes the engine behave correctly for each value.

MatchReport (diagnostics; supersedes closed PR #4225)

type MatchReport struct {
    Descriptors          []DescriptorReport // one per input descriptor, in PD order
    Outcome              Outcome            // matched | no_credentials | multiple_credentials
    AmbiguousDescriptors []string           // descriptor ids carrying >1 choice (multiple_credentials only)
}
type DescriptorReport struct {
    DescriptorID string
    Optional     bool
    Considered   []CandidateReport // every candidate VC evaluated against this descriptor
    SelectedID   string            // chosen credential id; "" if skipped/unfilled
    Skipped      bool
}
type CandidateReport struct {
    CredentialID string
    Eligible     bool       // passed this descriptor's constraints + format
    Dismissal    *Dismissal // why it wasn't used; nil when selected
}
type Dismissal struct {
    Reason   DismissalReason
    FieldID  string // constraint/binding reasons, when known
    Path     string // JSON path evaluated (constraint reasons)
    Expected string // filter const/type, or the bound value (binding conflict)
    Found    string // value found; "" = none
    Message  string // pre-rendered human-readable line (lazy, only under trace)
}
type DismissalReason string
const (
    ReasonNoValue         DismissalReason = "constraint_no_value"
    ReasonFilter          DismissalReason = "constraint_filter"
    ReasonFormat          DismissalReason = "format_mismatch"
    ReasonBindingConflict DismissalReason = "binding_conflict"
    ReasonNotSelected     DismissalReason = "not_selected"
)

Populated only under WithSelectionTrace() (Message rendered lazily, so a non-trace run pays nothing). Trace depth: step-1 (per-descriptor) dismissals recorded fully; binding conflicts recorded on the decisive path (the chosen assignment, or the conflicts that caused failure/ambiguity), not every backtracking visit. No surface (endpoint/UI) in this PR — consuming the report is a follow-up to #4218.

Testing

Black-box select_test.go, no mocks or on-disk fixtures. Two buckets:

  • Characterization (proves equivalence before Add error handling middleware to echo server #3 rewires callers): FirstMatch+bindings reproduces the TestNewFieldSelector matrix (single/multi-field AND, multi-descriptor, type conversions string/float64/bool, zero-match -> unfilled); FirstMatch with no bindings reproduces FirstMatchSelector; an all-required PD reproduces matchBasic (every descriptor must fill, else ErrNoCredentials); a representative submission-requirements PD reproduces matchSubmissionRequirements (all, and pick with min/max); ErrNoCredentials cases.
  • New policies: P3, P4, P5 (both strategies), P6 worked examples from the PRD; trace on/off asserting MatchReport contents for a dismissed VC and for an ambiguity; the two-VP composition pattern (two Select calls chained through a bindings filter).

Exhaustive submission-requirement rule cases stay in submission_requirement_test.go (the apply() logic is unchanged) and the integration-level TestMatch suites; #1 adds only representative satisfaction characterization, not a full duplication.

Acceptance Criteria

  • Select with functional options as specified; Result reuses Candidate.
  • P3/P4/P6 always on; P5 gated by Strict; FirstMatch is the default.
  • Characterization tests prove FirstMatch reproduces both FirstMatchSelector and NewFieldSelector behavior.
  • Select applies submission-requirement rules by reusing groups()/apply()/deduplicate(); whether a descriptor may go unfilled is derived from pd.SubmissionRequirements.
  • Characterization tests prove FirstMatch also reproduces matchBasic (all-required) and matchSubmissionRequirements (all/pick/min/max).
  • MatchReport populated only under WithSelectionTrace(), with the specified reasons and decisive-path conflict narration.
  • No caller or behavior changes outside vcr/pe; existing vcr/pe tests still green.
  • go build ./... and go test ./vcr/pe/... pass.

@qltysh
Copy link
Copy Markdown
Contributor

qltysh Bot commented May 26, 2026

Qlty


Coverage Impact

This PR will not change total coverage.

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant