From 41948dbe8e9582643479460e723519154304ecbd Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:24:03 -0700 Subject: [PATCH 1/4] No status behind a seal that didn't hold Whether a credential has a status list, and which entry of it is its own, are the credential's claims. Behind a seal that didn't hold they vouch for nothing, yet the Status row said "the issuer set up no way to deactivate this" for a tampered credential with no status entry, which removing that entry would buy. It now reads "not checked", as the Dates row does since #31. The same holds for the headline: "deactivated" and "on hold" are believed only behind a held seal. The library halts after a failed seal, so it can't produce either today, but the rule must not depend on that. notWithdrawn needs no guard: everything that reads it already stands behind a held seal. A test that pinned "withdrawn" behind a missing seal, to check the action named nobody, now pins signature_unchecked and still checks that. The consistency suite gains the rule that the Status row reads "not checked" behind an unheld seal. Mutation: dropping the row guard fails 20782 tests, the withdrawn guard 1853, the on-hold guard 1852. Closes #32 Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 18 ++++++++++++--- test/consistency-combinations.ts | 7 ++++-- test/outcomes.test.ts | 39 +++++++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 5989582..5474f96 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -330,9 +330,15 @@ const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map): boolean => - hasProblem(checks.get(CHECK.status), PROBLEM.revoked) && !listNotIssuers(checks); + sealHeld(checks) && hasProblem(checks.get(CHECK.status), PROBLEM.revoked) && !listNotIssuers(checks); /** * The issuer has suspended this: set aside, possibly for now. verifier-core @@ -340,7 +346,7 @@ const isWithdrawn = (checks: Map): boolean => * "withdrawn". Withdrawal leads where a credential carries both. */ const isSuspended = (checks: Map): boolean => - hasProblem(checks.get(CHECK.status), PROBLEM.suspended) && !listNotIssuers(checks); + sealHeld(checks) && hasProblem(checks.get(CHECK.status), PROBLEM.suspended) && !listNotIssuers(checks); /** * The withdrawal list loaded, is believed (by default whoever signed it; with @@ -1244,7 +1250,13 @@ export const listChecks = (r: VerificationResponse, options: OutcomeOptions = {} }); const revocation = checks.get(CHECK.status); - if (!hasStatusList(r)) { + if (!sealHeld(checks)) { + // Behind a seal that didn't hold, whether the credential has a list, and + // which entry of it is its own, are the credential's own claims: removing + // its status entry would otherwise read "the issuer set up no way to + // deactivate this". So the row reads "not checked", as Dates does. + rows.push({ id: CHECK.status, label: 'Status', severity: 'unchecked', value: 'not checked' }); + } else if (!hasStatusList(r)) { // No list at all. Nothing failed, and we cannot say it was not withdrawn // either — there was no way to withdraw it. Saying so is for the issuer // debugging their own setup, who otherwise cannot tell this apart from a diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts index a01d22c..92e478f 100644 --- a/test/consistency-combinations.ts +++ b/test/consistency-combinations.ts @@ -772,10 +772,13 @@ export const agreementTests = (file: string): void => { expect(row(CHECK.signature)?.severity, `${where}: a date reported without the seal`).toBe('success'); } - // ...and the Dates row says the same: no date at all, "in date" - // included, behind a seal that didn't hold. + // ...and the Dates and Status rows say the same: no date and no status + // at all behind a seal that didn't hold — not "in date", not "not + // deactivated", and not "no way to deactivate this", which removing a + // credential's status entry would otherwise buy. if (row(CHECK.signature)?.severity !== 'success') { expect(row(DATES_ROW)?.value, `${where}: the Dates row behind an unheld seal`).toBe('not checked'); + expect(row(CHECK.status)?.value, `${where}: the Status row behind an unheld seal`).toBe('not checked'); } // Only the library says a credential has run out. An end date in the diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index eb3ab94..2b30e55 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -588,13 +588,17 @@ describe('an action names the issuer only when the seal held', () => { expect(summarise(r).action).toBe('Ask Springfield College for a current copy.'); }); - it('says "the issuer" when the seal never reported', () => { + it('names nobody, and believes no withdrawal, when the seal never reported', () => { + // Which entry of the list is this credential's is its own claim, so a + // withdrawal behind an unchecked seal is no more believed than an expiry. + // The library halts after a failed seal; the rule must not depend on it. const r = ok(); revoke(r); remove(r, CHECK.signature); const out = summarise(r); - expect(out.code).toBe('withdrawn'); - expect(out.action).toBe('Ask the issuer for a current copy.'); + expect(out.code).toBe('signature_unchecked'); + expect(`${out.headline} ${out.detail} ${out.action}`).not.toContain('Springfield College'); + expect(listChecks(r).find((c) => c.id === CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); it.each([ @@ -1894,6 +1898,35 @@ describe('an expired credential, now that expiry stops nothing after it', () => }); }); +describe('the Status row behind a seal that did not hold', () => { + // verifier-plugin#32. Whether a credential has a status list, and which + // entry of it is its own, are the credential's claims; behind a broken + // seal they are worth nothing, so the row says "not checked". + it.each([ + ['tampered', () => tamperedSignature()], + ['unreachable', () => fail(CHECK.signature, [{ type: PROBLEM.didWebUnresolved, title: 't', detail: 'd' }], true)], + ])('reads "not checked" when the seal is %s and there is no list', (_, signature) => { + const r = ok(); + r.verifiableCredential = { ...withoutStatusList }; + remove(r, CHECK.status); + set(r, signature()); + expect(row(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); + }); + + it('reads "not checked" when the seal is tampered and the list came back clear', () => { + const r = ok(); + set(r, tamperedSignature()); + expect(row(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); + }); + + it('still says the issuer set up no way to deactivate it, behind a seal that held', () => { + const r = ok(); + r.verifiableCredential = { ...withoutStatusList }; + remove(r, CHECK.status); + expect(row(r, CHECK.status)?.value).toBe('the issuer set up no way to deactivate this'); + }); +}); + describe('dates nobody checked, though the seal held', () => { // A check that throws comes back as a failure carrying CHECK_ERROR, with no // date named; a run limited to some phases leaves no row at all. From a8bc3d1dd732d443b13d8ed80046df8ae3b3c33f Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:27:56 -0700 Subject: [PATCH 2/4] Review: a test that can fail, and the guard on notWithdrawn back - The "names nobody" test pinned signature_unchecked, whose words never carry a name, so it could no longer fail. It now pins "built wrong" behind a missing seal, the verdict whose action would name the issuer; ungating `named` fails it. The withdrawal half is its own test. - notWithdrawn is guarded like its siblings. No test can catch it today, since every reader stands behind a held seal; it guards the one claim that must never leak past a broken seal. - The consistency rule checks the Status row's severity as well as its value (40860 failures when it reads success). - Row-level tests for a tampered seal over a list that came back clear, says deactivated, or says on hold. A duplicate of an existing test goes. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 8 +++++--- test/consistency-combinations.ts | 1 + test/outcomes.test.ts | 31 +++++++++++++++++++------------ 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 5474f96..0e734c7 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -334,8 +334,10 @@ const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map): boolean => sealHeld(checks) && hasProblem(checks.get(CHECK.status), PROBLEM.revoked) && !listNotIssuers(checks); @@ -354,7 +356,7 @@ const isSuspended = (checks: Map): boolean => * this. The only grounds for saying the issuer hasn't withdrawn it. */ const notWithdrawn = (checks: Map): boolean => - passed(checks.get(CHECK.status)) && !listNotIssuers(checks); + sealHeld(checks) && passed(checks.get(CHECK.status)) && !listNotIssuers(checks); /** The withdrawal check ran, failed, and established nothing either way. */ const withdrawalUnavailable = (check: CheckResult | undefined): boolean => diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts index 92e478f..132d4af 100644 --- a/test/consistency-combinations.ts +++ b/test/consistency-combinations.ts @@ -779,6 +779,7 @@ export const agreementTests = (file: string): void => { if (row(CHECK.signature)?.severity !== 'success') { expect(row(DATES_ROW)?.value, `${where}: the Dates row behind an unheld seal`).toBe('not checked'); expect(row(CHECK.status)?.value, `${where}: the Status row behind an unheld seal`).toBe('not checked'); + expect(row(CHECK.status)?.severity, `${where}: the Status row behind an unheld seal`).toBe('unchecked'); } // Only the library says a credential has run out. An end date in the diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index 2b30e55..f8fe9a7 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -588,16 +588,24 @@ describe('an action names the issuer only when the seal held', () => { expect(summarise(r).action).toBe('Ask Springfield College for a current copy.'); }); - it('names nobody, and believes no withdrawal, when the seal never reported', () => { + it('says "the issuer" when the seal never reported', () => { + // "Built wrong" is about the file, so it leads even behind an unchecked + // seal, and its action is the one that would name them. + const r = withSchema(missingProperty); + remove(r, CHECK.signature); + const out = summarise(r); + expect(out.code).toBe('malformed'); + expect(out.action).toBe("The issuer needs to fix how this was issued. There's nothing for you to do."); + }); + + it('believes no withdrawal when the seal never reported', () => { // Which entry of the list is this credential's is its own claim, so a // withdrawal behind an unchecked seal is no more believed than an expiry. // The library halts after a failed seal; the rule must not depend on it. const r = ok(); revoke(r); remove(r, CHECK.signature); - const out = summarise(r); - expect(out.code).toBe('signature_unchecked'); - expect(`${out.headline} ${out.detail} ${out.action}`).not.toContain('Springfield College'); + expect(summarise(r).code).toBe('signature_unchecked'); expect(listChecks(r).find((c) => c.id === CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); @@ -1913,18 +1921,17 @@ describe('the Status row behind a seal that did not hold', () => { expect(row(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); - it('reads "not checked" when the seal is tampered and the list came back clear', () => { + it.each([ + ['came back clear', () => pass(CHECK.status, 'Credential status is valid (not revoked or suspended).')], + ['says deactivated', () => revokedBy(PROBLEM.revoked, 'Credential Revoked')], + ['says on hold', () => revokedBy(PROBLEM.suspended, 'Credential Suspended')], + ])('reads "not checked" when the seal is tampered and the list %s', (_, status) => { const r = ok(); set(r, tamperedSignature()); + set(r, status()); + expect(summarise(r).code).toBe('invalid_signature'); expect(row(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); - - it('still says the issuer set up no way to deactivate it, behind a seal that held', () => { - const r = ok(); - r.verifiableCredential = { ...withoutStatusList }; - remove(r, CHECK.status); - expect(row(r, CHECK.status)?.value).toBe('the issuer set up no way to deactivate this'); - }); }); describe('dates nobody checked, though the seal held', () => { From b2fece4147c91c61bfe6e322f6dac3d29dfd05e3 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:29:44 -0700 Subject: [PATCH 3/4] Review: notWithdrawn's reason in its own comment; say what tests cover The reason notWithdrawn keeps a guard no test can catch moves to its own docblock, so it doesn't look removable. The tampered-seal row tests say they pin the row's guard, and name where the predicate guards are pinned; their headline assertion, which couldn't fail, goes. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 10 ++++++---- test/outcomes.test.ts | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 0e734c7..36d5b4f 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -334,10 +334,7 @@ const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map): boolean => sealHeld(checks) && hasProblem(checks.get(CHECK.status), PROBLEM.revoked) && !listNotIssuers(checks); @@ -354,6 +351,11 @@ const isSuspended = (checks: Map): boolean => * The withdrawal list loaded, is believed (by default whoever signed it; with * `requireIssuerSignedStatusList`, only the issuer's own), and doesn't list * this. The only grounds for saying the issuer hasn't withdrawn it. + * + * Behind a held seal only, like its siblings above. No test can catch this + * guard today, since everything that reads this already stands behind a + * held seal; it is here because "the issuer hasn't deactivated it" is the + * claim that must never leak past a broken seal. Don't remove it. */ const notWithdrawn = (checks: Map): boolean => sealHeld(checks) && passed(checks.get(CHECK.status)) && !listNotIssuers(checks); diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index f8fe9a7..d41747f 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1926,10 +1926,12 @@ describe('the Status row behind a seal that did not hold', () => { ['says deactivated', () => revokedBy(PROBLEM.revoked, 'Credential Revoked')], ['says on hold', () => revokedBy(PROBLEM.suspended, 'Credential Suspended')], ])('reads "not checked" when the seal is tampered and the list %s', (_, status) => { + // The row's own guard, whatever the list said. The guards on believing a + // withdrawal or a hold are pinned by "believes no withdrawal when the + // seal never reported" and by the consistency suite. const r = ok(); set(r, tamperedSignature()); set(r, status()); - expect(summarise(r).code).toBe('invalid_signature'); expect(row(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); }); From c3cb3496c3a700ab009fddbf1f87739dde1f053a Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:44:54 -0700 Subject: [PATCH 4/4] Review: a unit test for the hold guard; say what the row tests pin From the final review (24 October 2026): - isSuspended's seal guard had no unit test; only the consistency suite caught its removal. "Believes no hold when the seal never reported" mirrors the withdrawal test. Mutation: the guard removed fails it (it failed no unit test before). - The three tampered-list Status row tests said they pinned the row's own guard. They pass if either the row's guard or the matching predicate's is there (notWithdrawn's for a clear list); the comment now says so, and names what pins each alone. Checked: the row guard removed fails only the two "no list" cases; the withdrawal guard, "believes no withdrawal"; the hold guard, the new test; the row and notWithdrawn guards together, the clear-list case. - The consistency comment on the Status row behind a broken seal is in the present tense again: this PR is the verifier-plugin#32 fix. Checked: lint, typecheck, 105,720 tests. Co-Authored-By: Claude Opus 5.5 --- test/consistency-combinations.ts | 6 +++--- test/outcomes.test.ts | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts index 132d4af..d738185 100644 --- a/test/consistency-combinations.ts +++ b/test/consistency-combinations.ts @@ -642,9 +642,9 @@ export const agreementTests = (file: string): void => { stranger ? /^Let .+ know we couldn't check whether this copy is still active\.$/ : /^Try again in a little while\.$/, ); } - // Only behind a seal that held: behind a broken one the Status row - // should be about the seal, not the list. verifier-plugin#32 is that - // fix, making it "not checked" whatever the list said. + // Only behind a seal that held: behind a broken one the Status row is + // about the seal and reads "not checked" whatever the list said + // (verifier-plugin#32), as asserted below with the Dates row. if (row(CHECK.signature)?.severity === 'success') { expect(row(CHECK.status)?.value, `${where}: the wrong Status row`).toBe( stranger ? "couldn't check — the information we found may not be from the issuer" : 'not checked', diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index d41747f..d4f92dc 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -609,6 +609,15 @@ describe('an action names the issuer only when the seal held', () => { expect(listChecks(r).find((c) => c.id === CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); + it('believes no hold when the seal never reported', () => { + // As for a withdrawal, the same for a hold. + const r = ok(); + set(r, revokedBy(PROBLEM.suspended, 'Credential Suspended')); + remove(r, CHECK.signature); + expect(summarise(r).code).toBe('signature_unchecked'); + expect(listChecks(r).find((c) => c.id === CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); + }); + it.each([ ['only an identifier', 'did:key:z6Mkn'], ['an identifier and no name', { id: 'did:key:z6Mkn' }], @@ -1926,9 +1935,13 @@ describe('the Status row behind a seal that did not hold', () => { ['says deactivated', () => revokedBy(PROBLEM.revoked, 'Credential Revoked')], ['says on hold', () => revokedBy(PROBLEM.suspended, 'Credential Suspended')], ])('reads "not checked" when the seal is tampered and the list %s', (_, status) => { - // The row's own guard, whatever the list said. The guards on believing a - // withdrawal or a hold are pinned by "believes no withdrawal when the - // seal never reported" and by the consistency suite. + // "Not checked" whatever the list said. Either the row's own guard or the + // matching predicate's is enough to pass these: notWithdrawn's for a list + // that came back clear, isWithdrawn's and isSuspended's for the other two. + // The row's guard alone is pinned by the "no list" cases above; + // isWithdrawn's and isSuspended's by "believes no withdrawal" and + // "believes no hold when the seal never reported". notWithdrawn's can't + // be pinned alone (see its docblock). const r = ok(); set(r, tamperedSignature()); set(r, status());