[#4253 1/8] feat(pe): core selection engine Select + MatchReport#4280
Draft
stevenvegt wants to merge 25 commits into
Draft
[#4253 1/8] feat(pe): core selection engine Select + MatchReport#4280stevenvegt wants to merge 25 commits into
stevenvegt wants to merge 25 commits into
Conversation
Assisted-by: AI
Contributor
|
Coverage Impact ⬆️ Merging this pull request will increase total coverage on Modified Files with Diff Coverage (3)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
This was referenced May 27, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Parent PRD
#4253
Item 1 of 8. Foundation for the credential-selection rewrite. Also folds in the developer-diagnostics work from #4218 (this
MatchReportsupersedes the logging approach in closed PR #4225).Summary
Introduce the new pure matching engine
pe.Selectinvcr/pe, with structured diagnostics (MatchReport).Selectis 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-idfield values agree; (3) apply the PD's submission-requirement rules (pick/all/min/max) to that combination, reusing the existinggroups()/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 rewiresMatchontoSelectand deletesmatchConstraints/matchBasic/matchSubmissionRequirements/MatchWithSelectorand theCredentialSelectorabstraction.Implementation Spec
Surface (all new, in
vcr/pe)Bindingsismap[string]string, reusing the value stringification already inmatchesSelections(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):
WithInitialBindings; keys not present as field ids on this PD are dropped.pd.SubmissionRequirementswith a simple rule: with no submission requirements every descriptor is required; otherwise a descriptor is optional when its group can be satisfied without it.optional:truefield 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 legacymatchesSelectionssemantics).Step 3: apply the submission-requirement rules.
Selectpasses the step-2 combination to the existinggroups()/apply()/deduplicate()helpers (rule logic unchanged) to enforce the PD'spick/all/count/min/max/nested rules and produce the final selection; a descriptor dropped by a rule ends withVC=nil. The descriptor-map / JSON-path formatting thatmatchBasicandmatchSubmissionRequirementsdid is not part ofSelect; it moves to the thinMatchwrapper in item #3. The simple skip rule from step 2 can disagree with exact rule satisfaction in the deferredpick min>=1case; 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 anidacross descriptors it reproduces today's matcher, includingNewFieldSelector'sErrMultipleCredentialswhen 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 theMatchReportinstead.Strict: a second complete consistent assignment isErrMultipleCredentials(Policy 5), naming the ambiguous descriptors so the caller can add disambiguating selection keys.StrictsupersedesFirstMatch. 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 (nocredential_selectionkey 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-idagreement) is always on but safe — it only affects PDs that reuse anidacross descriptors, which is a new pattern with no deployed users.Strategy is an input, not parsed here (scope)
Selecttakes aSelectionStrategyvalue 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 (policycredentialProfileConfig/ discoveryServiceDefinition), defaulting toFirstMatch("loose") and flipped toStrictin 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)
Populated only under
WithSelectionTrace()(Messagerendered 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:FirstMatch+bindings reproduces theTestNewFieldSelectormatrix (single/multi-field AND, multi-descriptor, type conversions string/float64/bool, zero-match -> unfilled);FirstMatchwith no bindings reproducesFirstMatchSelector; an all-required PD reproducesmatchBasic(every descriptor must fill, elseErrNoCredentials); a representative submission-requirements PD reproducesmatchSubmissionRequirements(all, andpickwithmin/max);ErrNoCredentialscases.MatchReportcontents for a dismissed VC and for an ambiguity; the two-VP composition pattern (twoSelectcalls chained through a bindings filter).Strictwhile distinct binding tuples are; the node-visit cap aborts a crafted exponential PD with a clear error;Result.Candidateskeeps one entry per input descriptor, in PD order, on every error path; format gating (PD-level and descriptor-level) and nil-Constraintsdescriptors are covered.Exhaustive submission-requirement rule cases stay in
submission_requirement_test.go(theapply()logic is unchanged) and the integration-levelTestMatchsuites; #1 adds only representative satisfaction characterization, not a full duplication.Acceptance Criteria
Selectwith functional options as specified;ResultreusesCandidate.Strict;FirstMatchis the default.FirstMatchreproduces bothFirstMatchSelectorandNewFieldSelectorbehavior.Selectapplies submission-requirement rules by reusinggroups()/apply()/deduplicate(); whether a descriptor may go unfilled is derived frompd.SubmissionRequirements.FirstMatchalso reproducesmatchBasic(all-required) andmatchSubmissionRequirements(all/pick/min/max).MatchReportpopulated only underWithSelectionTrace(), with the specified reasons and decisive-path conflict narration.MatchReport.Result.Candidatesis full length (one entry per descriptor, PD order) on every path, including errors.vcr/pe; existingvcr/petests still green.go build ./...andgo test ./vcr/pe/...pass.