From 9c5ef80a88f6713c18ceba9c741fb0ca43ef14dd Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Wed, 14 Oct 2026 16:45:29 -0700 Subject: [PATCH] docs(results): make the verification result docs match what the code does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verified` means no *fatal* check failed, but the type comments, the README and the verification-results guide described it as "no check returned a failure" and showed a registry-only failure as a failed verdict. Say what the code does, and name the advisory (non-fatal) checks — `registry.issuer`, `recognition.profile`, `status.list-issuer`, the Open Badges checks, and any caller check without `fatal` — everywhere the verdict is described. The UI recipe and the prompt-ready appendix now show an advisory failure inside a verified result and a fatal one beside it. Also: `SuiteSummary.status` is `'failure'`, not `'mixed'`, for failures plus skips; the returned credential and presentation are the caller's original objects rather than anything "parsed"; and the timing guide's parse-failure example uses the id the code emits, `cryptographic.parsing.envelope`. A new test pins the policy through the verifier: a credential whose only failing check is `registry.issuer` is `verified: true` with a `trust.registry` summary of status `'failure'`. Co-Authored-By: Claude Opus 5 --- README.md | 24 ++++-- docs/api/timing.md | 11 +-- docs/api/verification-results.md | 122 ++++++++++++++++++++++--------- docs/architecture.md | 4 +- src/types/result.ts | 30 ++++++-- src/types/suite-summary.ts | 6 +- test/verify-credential.spec.ts | 49 +++++++++++++ 7 files changed, 187 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index b08d66d..4f66268 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,14 @@ interface CredentialVerificationResult { } ``` -`verified` is `true` when no check returned a failure. `results` carries every +`verified` is `true` when no **fatal** check returned a failure. Some checks are +advisory: `trust.registry.issuer`, `recognition.profile`, +`cryptographic.status.list-issuer`, the Open Badges checks (the semantic +`schema.obv3.*` checks and the Open Badges JSON-schema check), and any check of +your own that leaves `fatal` unset. An advisory failure never flips `verified`, +so a credential whose only failing check is the registry lookup is still +`verified: true` — its `trust.registry` summary carries `status: 'failure'` and +no `fatalFailureAt`, which is how a UI surfaces it as a warning. `results` carries every check — the ones that passed, the ones that were skipped (with a reason), and the ones that were not run after a fatal failure — while `summary` provides the per-suite rollup (see [Per-suite summaries](#per-suite-summaries)). @@ -244,7 +251,7 @@ interface ProblemDetail { ```json { "verified": true, - "verifiableCredential": { "...parsed credential..." }, + "verifiableCredential": { "...the credential you passed in, unchanged..." }, "results": [ { "id": "cryptographic.core.context-exists", "suite": "core", "outcome": { "status": "success", "message": "Credential has a valid @context property." } }, { "id": "cryptographic.core.vc-context", "suite": "core", "outcome": { "status": "success", "message": "..." } }, @@ -656,10 +663,15 @@ Presentation verification does two things: 2. **Verifies each embedded credential** — extracts credentials from the VP and runs `verifyCredential` on each. Results go in `credentialResults`. -`verified` is `true` only if both the presentation and all embedded credentials -pass. +`verified` is `true` when no **fatal** presentation check failed and every +embedded credential is itself `verified`. The same checks are advisory here as +for a single credential (`trust.registry.issuer`, `recognition.profile`, +`cryptographic.status.list-issuer`, the Open Badges checks, and any check of +your own that leaves `fatal` unset): an embedded credential whose only failure +is advisory is `verified: true`, and so, on its account, is the presentation. -The parsed VP is returned as `verifiablePresentation`, mirroring the wire-level +The VP is returned as `verifiablePresentation` — the object you passed in, +unchanged — mirroring the wire-level property name so callers (and downstream systems whose templates reach into result objects by property path) can reach it without carrying the original input separately. @@ -1132,7 +1144,7 @@ small migrations: - `PresentationVerificationResult.allResults` removed — replace with `flattenPresentationResults(result)`. - `PresentationVerificationResult.verifiablePresentation` added — new field - carrying the parsed VP. + carrying the VP you passed in, unchanged. - **Default cache isolation:** two `createVerifier()` calls without an explicit `cacheService` no longer share a process-wide cache. To preserve the previous diff --git a/docs/api/timing.md b/docs/api/timing.md index 8f49d44..19a97b6 100644 --- a/docs/api/timing.md +++ b/docs/api/timing.md @@ -146,7 +146,7 @@ of the suite's own work. When a credential or presentation envelope fails to parse, the verifier short-circuits with a single synthetic -`parsing.envelope` `CheckResult`. With `timing: true`, this +`cryptographic.parsing.envelope` `CheckResult`. With `timing: true`, this synthetic check, its synthetic suite summary, and the top-level result all carry `timing` — same as a normal run: @@ -154,7 +154,7 @@ result all carry `timing` — same as a normal run: { "verified": false, "results": [ - { "id": "cryptographic.parsing.parsing.envelope", + { "id": "cryptographic.parsing.envelope", "outcome": { "status": "failure", "problems": [/* ... */] }, "fatal": true, "timing": { "startedAt": "...", "endedAt": "...", "durationMs": 0.001 } } @@ -237,9 +237,10 @@ counter semantics and `seed` options (`baseDateMs`, `performanceTickMs`). The `TimeService` is the source of truth for any check that -needs to ask "what time is it?". `proof.signature` already reads -it to judge credential expiry (`validUntil` / `expirationDate`) -and `status.bitstring` to judge status-list freshness, so a +needs to ask "what time is it?". `cryptographic.proof.signature` +already reads it to judge credential expiry (`validUntil` / +`expirationDate`) and `cryptographic.status.bitstring` to judge +status-list freshness, so a verifier built on a `FakeTimeService` decides both at the pinned moment. Signature clock-skew windows and key rotation belong there too. Read it from `VerificationContext.timeService` inside diff --git a/docs/api/verification-results.md b/docs/api/verification-results.md index 134366a..019f444 100644 --- a/docs/api/verification-results.md +++ b/docs/api/verification-results.md @@ -15,6 +15,7 @@ ## Table of Contents 1. [Overview](#overview) + - [The top-level verdict](#the-top-level-verdict) - [Check payloads](#check-payloads) 2. [Presentation vs credential scopes](#presentation-vs-credential-scopes) 3. [Phase model](#phase-model) @@ -31,12 +32,33 @@ were skipped (with the reason), and the ones that were not run because an earlier fatal failure halted the pipeline. `summary[]`, new in v2.0.0, is the per-suite rollup over those rows. +### The top-level verdict + +`verified` is `true` when no **fatal** check failed. For a presentation it +also requires every embedded credential to be `verified`. + +Some checks are **advisory** (non-fatal), and an advisory failure never +flips `verified`: + +- `trust.registry.issuer` +- `recognition.profile` +- `cryptographic.status.list-issuer` +- the Open Badges checks — the semantic `schema.obv3.*` checks and the + Open Badges JSON-schema check +- any caller-supplied check that leaves `fatal` unset + +So a credential whose only failing check is the registry lookup is +`verified: true`, while its `trust.registry` summary has +`status: 'failure'` and no `fatalFailureAt`. A UI that wants to show +"issuer not registered" reads the summary rows, not `verified`; see +[§6 UI rendering recipe](#ui-rendering-recipe). + A successful single-credential verification: ```jsonc { "verified": true, - "verifiableCredential": { "...parsed credential..." }, + "verifiableCredential": { "...the credential you passed in, unchanged..." }, "results": [ { "id": "cryptographic.core.context-exists", "suite": "core", @@ -476,33 +498,38 @@ function suiteBadge(s: SuiteSummary): Badge { } ``` -A presentation with two credentials, one verified with warnings and -one not verified, gets rendered as: +A presentation with two credentials — the first failing only advisory +checks, the second failing a fatal one — gets rendered as: ``` Presentation: ✗ (1 of 2 credentials verified) ├─ VP signature: ✓ -├─ Credential 1 (obv3p0.openbadge): ✓ -│ ├─ cryptographic ✓ -│ ├─ trust ⚠ (1 of 1 check failed) +├─ Credential 1 (obv3p0.openbadge): ✓ — verified, with advisory failures +│ ├─ cryptographic ✓ (4 + 1 + 1 = 6 checks passed) +│ ├─ trust ⚠ (1 of 1 check failed — advisory) │ │ └─ trust.registry.issuer — Issuer Not Registered │ ├─ recognition ✓ -│ └─ semantic ⚠ (1 of 5 checks failed, 4 passed) [openBadgesSuite] +│ └─ semantic ⚠ (1 of 5 checks failed, 4 passed — advisory) │ └─ semantic.openbadges.schema.obv3.result-ref — Invalid Result Reference └─ Credential 2 (obv3p0.openbadge): ✗ - ├─ cryptographic ✗ (proof: 1 of 1 check failed; status: not run) - │ └─ cryptographic.proof.signature — Credential Expired - ├─ trust — (registry not run: proof.signature failed) + ├─ cryptographic ✗ (1 of 1 check failed) + │ └─ cryptographic.proof.signature — Invalid Signature + ├─ trust — (not run after fatal) ├─ recognition ✓ - └─ semantic — (not run: proof.signature failed) + └─ semantic — (not run after fatal) ``` -Credential 1 is verified: its registry and Open Badges checks are -non-fatal, so their failures are warnings (amber), not errors. -Credential 2 is not verified because `proof.signature` failed fatally; -its proof suite carries `fatalFailureAt: "cryptographic.proof.signature"`, -and every later check was not run. A phase takes the most severe badge -of its suites. +Credential 1 shows the rule from +[§1 The top-level verdict](#the-top-level-verdict): the registry and +Open Badges checks are advisory, so their failures leave +`credentialResults[0].verified === true` even though those two suites +report `status: 'failure'` and `'mixed'`. Neither carries +`fatalFailureAt`, so they render as warnings (amber), not errors. Only +credential 2's fatal signature failure makes the presentation's own +`verified` false. A UI that hid advisory failures because the verdict +was `true` would be hiding exactly what a user wants to know, so render +them — just not as a failed verdict. A phase takes the most severe +badge of its suites. The `└─` rows under each failing phase row come from `failureDetail()`. Lazy-expand them — most users only want the verdict + per-phase status. @@ -543,6 +570,17 @@ dcc-transaction-service UI implementation. > every check — passes, skips with a reason, and rows not run after a > fatal failure — so a row's `outcome.status` is what distinguishes > them. +> +> `verified` means "no fatal check failed" (and, for a presentation, +> that every embedded credential is itself verified). The advisory +> (non-fatal) checks are `trust.registry.issuer`, +> `recognition.profile`, `cryptographic.status.list-issuer`, the Open +> Badges checks (the semantic `schema.obv3.*` checks and the Open +> Badges JSON-schema check), and any caller-supplied check without +> `fatal`. A credential whose only failing check is advisory is +> `verified: true` with `status: 'failure'` (or `'mixed'`) and no +> `fatalFailureAt` on that suite — render the failure rows as warnings, +> but do not render the verdict as failed. --- @@ -551,7 +589,7 @@ dcc-transaction-service UI implementation. ```jsonc { "verified": false, - "verifiablePresentation": { /* parsed VP */ }, + "verifiablePresentation": { /* the VP you passed in, unchanged */ }, "presentationResults": [ { "id": "cryptographic.core.vp-structure", /* success */ }, { "id": "cryptographic.proof.signature", /* success */ } @@ -564,8 +602,9 @@ dcc-transaction-service UI implementation. ], "credentialResults": [ { + // Only advisory checks failed, so the verdict stays true. "verified": true, - "verifiableCredential": { /* VC from an unregistered issuer */ }, + "verifiableCredential": { /* VC with an unregistered issuer */ }, "recognizedProfile": "obv3p0.openbadge", "results": [ { "id": "cryptographic.core.context-exists", /* success */ }, @@ -607,8 +646,9 @@ dcc-transaction-service UI implementation. ] }, { + // A fatal failure: this is what makes a verdict false. "verified": false, - "verifiableCredential": { /* expired VC */ }, + "verifiableCredential": { /* VC with a broken signature */ }, "recognizedProfile": "obv3p0.openbadge", "results": [ { "id": "cryptographic.core.context-exists", /* success */ }, @@ -618,18 +658,25 @@ dcc-transaction-service UI implementation. { "id": "cryptographic.core.proof-exists", /* success */ }, { "id": "recognition.profile", /* success */ }, { "id": "cryptographic.proof.signature", - "suite": "proof", "check": "proof.signature", + "suite": "proof", "outcome": { "status": "failure", "problems": [ - { "type": "https://www.w3.org/TR/vc-data-model#CREDENTIAL_EXPIRED", - "title": "Credential Expired", - "detail": "..." } + { "type": "https://www.w3.org/TR/vc-data-model#INVALID_SIGNATURE", + "title": "Invalid Signature", + "detail": "The credential's signature did not verify." } ]}, "fatal": true }, + { "id": "cryptographic.status.bitstring", + "suite": "status", + "outcome": { "status": "skipped", + "reason": "Not run: proof.signature failed" } }, + { "id": "cryptographic.status.list-issuer", + "suite": "status", + "outcome": { "status": "skipped", + "reason": "Not run: proof.signature failed" } }, { "id": "trust.registry.issuer", - "suite": "registry", "check": "registry.issuer", + "suite": "registry", "outcome": { "status": "skipped", - "reason": "Not run: proof.signature failed" }, - "fatal": false } + "reason": "Not run: proof.signature failed" } } ], "summary": [ { "id": "cryptographic.core", "phase": "cryptographic", @@ -646,6 +693,10 @@ dcc-transaction-service UI implementation. "message": "1 of 1 check failed", "counts": { "passed": 0, "failed": 1, "skipped": 0 }, "fatalFailureAt": "cryptographic.proof.signature" }, + { "id": "cryptographic.status", "phase": "cryptographic", + "suite": "status", "status": "skipped", + "message": "status not run: proof.signature failed", + "counts": { "passed": 0, "failed": 0, "skipped": 2 } }, { "id": "trust.registry", "phase": "trust", "suite": "registry", "status": "skipped", "message": "registry not run: proof.signature failed", @@ -710,15 +761,14 @@ function failureDetail( ``` Presentation: ✗ (1 of 2 credentials verified) ├─ VP signature: ✓ -├─ Credential 1 (obv3p0.openbadge): ✓ +├─ Credential 1 (obv3p0.openbadge): ✓ — verified, with advisory failures │ ├─ cryptographic ✓ -│ ├─ trust ✓ -│ ├─ recognition ✓ -│ └─ semantic ✓ +│ ├─ trust ✗ (1 of 1 check failed — advisory) +│ │ └─ trust.registry.issuer — Issuer Not Registered +│ └─ recognition ✓ └─ Credential 2 (obv3p0.openbadge): ✗ - ├─ cryptographic ✓ - ├─ trust ✗ (1 of 1 check failed) - │ └─ trust.registry.issuer — Issuer Not Registered - ├─ recognition ✓ - └─ semantic ✓ + ├─ cryptographic ✗ (1 of 1 check failed) + │ └─ cryptographic.proof.signature — Invalid Signature + ├─ trust — (not run after fatal) + └─ recognition ✓ ``` diff --git a/docs/architecture.md b/docs/architecture.md index 3ad96ff..f5e9afb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -181,8 +181,8 @@ caller's original object is what the suites verify (see the invariant in `src/ve The credential is `unknown` on entry — callers don't need to pre-validate. **Context.** Each `verifyCredential` / `verifyPresentation` call builds an internal -`VerificationContext` from the verifier's long-lived services plus per-call inputs (the parsed -credential or presentation, optional `additionalSuites`, optional `registries` override, optional +`VerificationContext` from the verifier's long-lived services plus per-call inputs (the caller's +original credential or presentation object — Zod gates it, it is never rewritten — optional `additionalSuites`, optional `registries` override, optional `challenge` / `domain` / `unsignedPresentation` for VPs). The context is the seam the suites consume; it is not a public composition point. Because `documentLoader` and `cacheService` live on the verifier, they're shared across every call on the same instance — this is what makes batch verification diff --git a/src/types/result.ts b/src/types/result.ts index f9c2970..dd4c68d 100644 --- a/src/types/result.ts +++ b/src/types/result.ts @@ -6,13 +6,25 @@ import { VerifiablePresentation } from '../schemas/presentation.js'; export interface CredentialVerificationResult { /** - * True when no fatal check failed. Non-fatal failures (warnings) - * leave this `true`; see {@link SuiteSummary.fatalFailureAt}. + * True if no *fatal* check returned a failure outcome. + * + * Advisory (non-fatal) checks never flip it: `registry.issuer`, + * `recognition.profile`, `status.list-issuer`, the Open Badges + * checks (the semantic `schema.obv3.*` checks and the Open + * Badges JSON-schema check), and any caller-supplied check that + * leaves `fatal` unset. So a credential whose only failing check + * is the registry lookup is `verified: true`, with + * `status: 'failure'` and no `fatalFailureAt` on its + * `trust.registry` summary — read {@link summary} to surface + * advisory failures. `verified` is `false` exactly when one of + * the summaries carries {@link SuiteSummary.fatalFailureAt}. */ verified: boolean; /** - * The Zod-parsed credential that was verified. + * The credential that was verified: the caller's original + * object, returned as it came in. Zod gates the input but does + * not normalize it, so nothing here is rewritten. * * Field name matches the W3C / VCALM property name so the * verification result can be spread directly into a VCALM @@ -86,16 +98,20 @@ export interface CredentialVerificationResult { export interface PresentationVerificationResult { /** - * True when no presentation-level check failed fatally and every - * entry in {@link credentialResults} is `verified`. + * True if no *fatal* presentation check failed and every + * embedded credential is itself `verified`. Advisory + * (non-fatal) failures never flip it; see + * {@link CredentialVerificationResult.verified} for which + * checks are advisory. */ verified: boolean; /** - * The Zod-parsed presentation that was verified. + * The presentation that was verified: the caller's original + * object, returned as it came in. * * Mirror of {@link CredentialVerificationResult.verifiableCredential}. - * Allows VCALM consumers to reach the parsed VP via the documented + * Allows VCALM consumers to reach the VP via the documented * `results..verifiablePresentation.…` JSONata path without * carrying the original input separately. */ diff --git a/src/types/suite-summary.ts b/src/types/suite-summary.ts index 3484e77..cf57bee 100644 --- a/src/types/suite-summary.ts +++ b/src/types/suite-summary.ts @@ -55,10 +55,10 @@ export interface SuiteSummary { /** * Aggregate status across the suite's child checks: * - `'success'` — every check that ran returned `success`. - * - `'failure'` — every check that ran returned `failure`. + * - `'failure'` — at least one check failed and none passed + * (failures alone, or failures plus skips). * - `'mixed'` — at least one `failure` and at least one - * `success`. A suite whose only non-failures are skips is - * `'failure'`. + * `success`. Skips alone never make a suite `'mixed'`. * - `'skipped'` — no check ran (suite predicate returned false, * every check was filtered out by `appliesTo`, or every check * was halted by an earlier fatal failure). diff --git a/test/verify-credential.spec.ts b/test/verify-credential.spec.ts index 318bf0d..266ee00 100644 --- a/test/verify-credential.spec.ts +++ b/test/verify-credential.spec.ts @@ -23,6 +23,11 @@ import { import { buildTestContext } from './factories/services/build-test-context.js'; import { FakeCryptoService } from './factories/services/fake-crypto-service.js'; import { FakeDocumentLoader } from './factories/services/fake-document-loader.js'; +import { + FakeHttpGetService, + okJsonBody +} from './factories/services/fake-http-get-service.js'; +import { InMemoryCacheService } from '../src/services/cache-service/in-memory-cache-service.js'; import { v1Expired } from './fixtures/v1-expired.js'; import { v2Expired } from './fixtures/v2-expired.js'; import { v2WithValidStatus } from './fixtures/v2-with-valid-status.js'; @@ -687,6 +692,50 @@ describe('verifyCredential', () => { }); }); + describe('advisory (non-fatal) failures', () => { + // `registry.issuer` is advisory: it reports that the issuer is not + // listed, but it does not decide authenticity, so it never flips the + // top-level verdict. The suite rollup is where a UI finds it. + it('keeps verified: true when the registry check is the only failure', async () => { + const credential = CredentialFactory({ version: 'v2', credential: {} }); + const result = await verifyCredential({ + credential, + ...fakeVerified, + httpGetService: FakeHttpGetService({ + 'https://factory.test/registry/legacy.json': okJsonBody({ + registry: {} + }) + }), + cacheService: InMemoryCacheService(), + registries: [ + { + name: 'Unit Test Registry', + type: 'dcc-legacy', + url: 'https://factory.test/registry/legacy.json' + } + ] + }); + + expect( + result.results + .filter(r => r.outcome.status === 'failure') + .map(r => r.id) + ).toEqual(['trust.registry.issuer']); + const registryCheck = result.results.find( + r => r.id === 'trust.registry.issuer' + ); + expect(registryCheck?.fatal).toBeFalsy(); + + expect(result.verified).toBe(true); + + const registrySummary = result.summary.find( + s => s.id === 'trust.registry' + ); + expect(registrySummary?.status).toBe('failure'); + expect(registrySummary?.fatalFailureAt).toBeUndefined(); + }); + }); + describe('revocable VC — Phase B proof/status decoupling (createVerifier)', () => { const STATUS_LIST_URL = 'https://raw.githubusercontent.com/digitalcredentials/verifier-core/refs/heads/main/src/test-fixtures/status/e5WK8CbZ1GjycuPombrj';