Skip to content

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

Draft
stevenvegt wants to merge 25 commits 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 25 commits into
feature/4253-credential-selectionfrom
4253-1-select-engine

Conversation

@stevenvegt

@stevenvegt stevenvegt commented May 26, 2026

Copy link
Copy Markdown
Member

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. Initial bindings are stricter (decided 2026-07-14): a candidate for a descriptor whose field id appears in the initial bindings must resolve that field to the bound value; unresolved optional fields are rejected for that descriptor (preserves legacy matchesSelections semantics).
  • Ambiguity (P5): see strategies below.
  • Scale (decided 2026-07-14): eligible pools are hash-indexed by their id-bearing values, so binding-consistency filtering is a lookup, not a scan; large homogeneous wallets (20k per-patient credentials) cost a hash join, linear in wallet size. The search carries a node-visit cap (order 1e6) and aborts with an error naming the PD when hit, because PDs are counterparty-supplied in wallet flows and an unbounded search would be remotely triggerable CPU exhaustion.

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, including NewFieldSelector's ErrMultipleCredentials when a caller-bound descriptor still resolves to more than one binding tuple. Matches identical on every declared field id are interchangeable and first-picked (decided 2026-07-15: the PD is the data contract; only declared fields reach token introspection, and multi-value filters such as issuer patterns are deliberate equivalence declarations). The one deliberate loosening vs. today: a reissued duplicate no longer fails a caller-bound request; diverging credentialSubjects among interchangeable matches are flagged in the MatchReport instead.
  • 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. Rivals are counted per distinct binding tuple, not per credential tuple (decided 2026-07-14): credentials identical in every id-bearing value are interchangeable, never ambiguous (no credential_selection key could separate them; the engine picks the first). This also keeps Strict uniqueness proofs cheap on wallets holding many interchangeable credentials.

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).
  • Scale and contract edges (added 2026-07-14): interchangeable credentials (identical binding tuples) are not ambiguous under Strict while distinct binding tuples are; the node-visit cap aborts a crafted exponential PD with a clear error; Result.Candidates keeps one entry per input descriptor, in PD order, on every error path; format gating (PD-level and descriptor-level) and nil-Constraints descriptors are covered.

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.
  • Ambiguity counted per distinct binding tuple; interchangeable credentials never error.
  • Caller-bound multiplicity counted per binding tuple; both consent scenarios pinned (declared field errors, undeclared field first-picks); diverging interchangeable alternatives flagged in MatchReport.
  • Initial bindings require resolution (legacy field-selector semantics); P6 leniency covers only search-accumulated bindings.
  • Eligible pools are hash-indexed; node-visit cap aborts pathological searches with a clear error.
  • Result.Candidates is full length (one entry per descriptor, PD order) on every path, including errors.
  • No caller or behavior changes outside vcr/pe; existing vcr/pe tests still green.
  • go build ./... and go test ./vcr/pe/... pass.

@qltysh

qltysh Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on feature/4253-credential-selection by 0.16%.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
vcr/pe/presentation_definition.go66.7%434-438
New Coverage rating: A
vcr/pe/match_report.go97.0%246-247, 279-280
New Coverage rating: A
vcr/pe/select.go92.9%150-151, 231-234...
Total93.1%
🤖 Increase coverage with AI coding...
In the `4253-1-select-engine` branch, add test coverage for this new code:

- `vcr/pe/match_report.go` -- Lines 246-247 and 279-280
- `vcr/pe/presentation_definition.go` -- Line 434-438
- `vcr/pe/select.go` -- Lines 150-151, 231-234, 373-374, 458-459, 514, 552, 555, 567-568, 578-579, 589-595, 640-641, 713-714, and 732-733

🚦 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.

stevenvegt added 24 commits May 29, 2026 20:17
Introduce the new pure matching engine Select with its public surface
(Result, Option) and the step-1 per-descriptor eligibility pass. This
first slice binds a single descriptor to its one matching candidate.

Tests build presentation definitions and credentials from their JSON
wire form, which keeps the deeply-nested PDs readable; the PD helper uses
DisallowUnknownFields so a misspelled key fails loudly.

Assisted-by: AI
Enforce the all-required rule for presentation definitions without
submission requirements: an unfilled descriptor yields ErrNoCredentials,
with Candidates still populated for diagnostics. The remaining FirstMatch
behaviors (all-fill, first-pick, multi-field AND) are covered by the
step-1 eligibility pass and added as regression guards.

Assisted-by: AI
Add WithInitialBindings so callers can seed id->value bindings. Step 1
now records each candidate's resolved id-bearing field values
(stringified the same way as the legacy field selector), and candidates
are filtered to those consistent with the bindings. A descriptor pinned
by the caller that still resolves to more than one candidate yields
ErrMultipleCredentials; zero matches fall through to ErrNoCredentials.

Numeric/bool values, multi-key AND, independent keys across descriptors,
and silently-dropped unknown keys are covered as regression guards.

Assisted-by: AI
When the presentation definition carries submission requirements, run the
chosen assignment through the rule engine (groups, match, deduplicate) as
step 3: an unsatisfied "all" group yields ErrNoCredentials, a satisfied
"all" group keeps every descriptor, and a "pick" rule keeps its selected
subset while clearing the descriptors it excludes (VC=nil). Without
submission requirements every descriptor stays required.

Assisted-by: AI
Result.Candidates promises a best-effort assignment even on error, but
the caller-bound multiplicity check returned before appending the
descriptor it failed on, leaving it out of the diagnostics. Append it
unfilled before returning. The other error paths already kept their
candidates; their tests now assert that contract instead of discarding
the result.

Assisted-by: AI
Both eligibility gates (PD-level and descriptor-level format) and the
nil-Constraints descriptor path were implemented without a driving test.

Assisted-by: AI
A caller-bound multiplicity error truncated Candidates after the failing
descriptor. The contract is one entry per input descriptor, in PD order,
on every path; MatchReport will depend on it.

Assisted-by: AI
The flat greedy loop cannot express the backtracking that Policies 3-5
require (a later conflict must be able to revise an earlier choice).
Restructure step 2 as a recursive depth-first search that threads a
running bindings map and restores it on backtrack, with optionality
derived from the submission requirements (coarse rule).

Eligible credentials are grouped per descriptor by their binding tuple
(interchangeable credentials form one group) and indexed by id value,
so consistency filtering is a lookup instead of a scan; wallets holding
many credentials of one type stay linear. The legacy caller-bound
multiplicity check runs as a pre-pass tied to the initial bindings
only, deliberately separate from whole-assignment ambiguity.

Behavior is unchanged for every pinned case; the black-box suite stays
green without modification.

Assisted-by: AI
P3 (same-id agreement with backtracking), P4 (optional skip only after
exhausting alternatives) and the either-or pick-1 case are satisfied by
the recursive search core; these tests protect the worked examples from
the PRD.

Assisted-by: AI
A caller-bound field id must resolve to the bound value on the chosen
candidate; a candidate whose optional field does not resolve is
rejected for that descriptor. This preserves the legacy field-selector
semantics (a caller who pins a value never receives a credential
without it) and keeps all-unresolved wallets at ErrNoCredentials
instead of drifting into ErrMultipleCredentials. Policy 6 leniency
(unresolved optional does not bind) applies only to bindings
accumulated between descriptors during the search, now pinned by its
own test.

Assisted-by: AI
WithStrategy(Strict) keeps searching past the first complete assignment
and errors when a rival exists. Rivals are counted per binding tuple: a
later assignment is a rival only when it fills a descriptor the first
one also fills with different id-bearing values. Interchangeable
credentials share a group and can never differ, so reissued duplicates
do not error, and a skipped descriptor is not an alternative to a
filled one, which keeps either-or pick groups deterministic. The error
names the ambiguous descriptors so the caller knows which
credential_selection keys to add.

Assisted-by: AI
Bindings carries the id->value pairs of the credentials that survive
rule application, enabling the two-VP composition: filter the org
result's bindings to the SP PD's field ids and seed the SP Select call.
Unknown initial keys are not echoed and errors produce no bindings.

Assisted-by: AI
Result.Report explains, per input descriptor, why each candidate was or
wasn't selected: constraint filter rejection, missing value, format
mismatch, binding conflict against the decisive assignment, or simply
not selected. The report is assembled after the run by re-evaluating
step 1, so the search itself stays free of report bookkeeping and a
non-traced run pays nothing. Folds in the developer-diagnostics goal of
issue 4218; a surface for the report is a follow-up.

Assisted-by: AI
Presentation definitions are counterparty-supplied in wallet flows; a
crafted PD with many independent descriptors and an unfillable final
one forces the naive backtracking search to visit exponentially many
nodes before concluding failure. Count node visits and abort with an
error naming the PD at one million, far above anything a legitimate
wallet produces with the indexed pools.

Assisted-by: AI
The PD is the data contract: only declared fields reach token
introspection, so any claim the relying party consumes is necessarily a
declared field id, and filters such as issuer patterns admitting
several issuers are deliberate equivalence declarations by the PD
author. Caller-bound matches that agree on every declared id are
therefore interchangeable and the first is used (a reissued duplicate
no longer fails the request), while matches contradicting on another
declared id still error, with a bindable key as the remedy. Pinned with
both consent scenarios: status declared errors, status undeclared
first-picks.

Assisted-by: AI
When the engine first-picks among credentials that agree on every
declared field id but differ in their credentialSubjects, the report
marks the descriptor. The difference plays no role in selection by
contract (undeclared fields are not introspectable), but a wallet
holding semantically diverging same-tuple credentials is worth the
operator's attention.

Assisted-by: AI
Name the rules (binding consistency, prefer fill over skip,
interchangeability, caller-bound multiplicity, unresolved-optional,
ambiguity) and state the contract rationale: the PD is the data
contract, only declared fields reach token introspection, and
multi-value filters are deliberate equivalence declarations. Replace
the policy-number references in comments with the named rules; the
design issue is referenced once for provenance.

Assisted-by: AI
Optionality governs zero-vs-some, never one-vs-many: caller-bound
multiplicity errors even on an optional descriptor (skipping would
silently discard an explicit instruction), Strict flags a rival on an
optional descriptor, and fill-versus-skip never counts as a rival.

Assisted-by: AI
Two MatchReport gaps found while widening its coverage: a candidate
that merely lost to an earlier pick at its own descriptor was blamed
for a binding conflict with the winner (losers are now explained
against the bindings established by the other descriptors only), and a
caller-bound multiplicity error produced OutcomeMultipleCredentials
with an empty AmbiguousDescriptors (the pinned descriptors are now
named). Also pins: Report nil on the error path without trace,
Skipped/Optional/SelectedID on a skipped optional descriptor with a
matched outcome, not_selected for eligible losers, and the
bound-but-unresolved conflict shape.

Assisted-by: AI
The MatchReport godoc now explains how to read a report (outcome,
per-descriptor selection, dismissals, ambiguous descriptors, the
divergence flag) and its scope: diagnostics for developer tooling, not
control flow. Runnable examples cover Select, WithInitialBindings,
WithStrategy(Strict), WithSelectionTrace and the two-VP composition;
they render in godoc and run as tests.

Assisted-by: AI
A doc.go front page defines the terms the engine documentation uses
(presentation definition as data contract, input descriptor, field id
as binding name, binding tuple, initial bindings and caller-bound,
eligible, interchangeable, assignment and decisive assignment, optional
descriptor, strategy, selection trace) and carries the single
provenance pointer to the design issue. The Select and MatchReport
godocs now use that vocabulary instead of redefining terms, and the
remaining id-bearing phrasing in comments is aligned.

Assisted-by: AI
…criptor

A credential filling two descriptors of a pick group survived on both:
rule-excluded descriptors were cleared by VC value membership, so
pick count 1 could leave two descriptors filled. The selection is now
consumed first-descriptor-wins (PD order), matching the legacy
descriptor-map behavior and the documented dropped-by-rule contract.

Assisted-by: AI
Restore the explicit format switch in credentialAsMap: a format the
engine does not know resolves no fields and is silently ineligible,
instead of being remarshaled by guesswork (a future token-backed format
would marshal to a JSON string and abort the whole match with an
unmarshal error). Export ErrSearchLimitReached and wrap it in the
node-limit abort so callers can classify the third failure class once
the engine reaches the public API surface.

Assisted-by: AI
Self-review follow-ups: evaluateCandidate is now the single step-1
eligibility primitive used by both the selection and the MatchReport,
so a traced report cannot drift from what the selection did (and the
selected credential is no longer evaluated twice per descriptor).
consistentGroups picks the narrowest posting list by length before
materializing one merge, returns a shared precomputed index for
unnarrowed visits, and iterates the pool's id universe instead of the
growing bindings map, removing all per-node allocations for the common
cases. copyBindings uses maps.Copy; the MatchReport documentation now
states the first-rival-only scope of AmbiguousDescriptors and the
best-effort nature of failed-run explanations.

Assisted-by: AI
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