Credential lifecycle
Nothing in v0.3 is deleted. A credential keeps its anchor forever and its status changes over time in the StatusRegistry. Every change records a reason code, the time, the account that made it and the hash of an optional note, and emits an event, so the history is public and permanent.
States and transitions
| Status | Value | Meaning | Final |
|---|---|---|---|
| None | 0 | No credential with this id | |
| Active | 1 | Issued and standing | No |
| Suspended | 2 | Temporarily not valid | No |
| Revoked | 3 | Cancelled for good | Yes |
| Superseded | 4 | Replaced by another credential, named in supersededBy | Yes |
Valid is more than Active. isValid(id) is true only when the status is Active, the issuer (resolved to the key in charge) is not suspended issuer-wide, and the credential has not expired (validUntil is 0 or block.timestamp <= validUntil). The verify page shows "Active, but not currently valid: the issuer is suspended or the credential has expired." for an Active credential that is not valid.
Who may do what
The "issuer" below is the key in charge of the credential's issuer: IssuerRegistry.currentIssuerOf(issuer), which follows key retirements on sets that have them (0.5.0). A retired key has no authority over its old credentials; its successor does. On sets without key lineage the issuer is the signing key.
| Function | Caller | Allowed from | Effect | Reason code |
|---|---|---|---|---|
suspend(id, reason, noteHash) | Issuer, or the registry admin | Active | Suspended. adminHold is set when the admin (and not the issuer) suspends. | Issuer: 0 to 7 or 1000 and up. Admin: any. |
reinstate(id, noteHash) | Issuer; the admin when adminHold is set | Suspended | Active, hold cleared, reason 0 | None |
revoke(id, reason, noteHash) | Issuer only | Active or Suspended (even under an admin hold) | Revoked, hold cleared | 0 to 7 or 1000 and up |
supersede(id, replacementId, noteHash) | Issuer only | Active or Suspended without an admin hold | Superseded, supersededBy set | 0 |
supersedeFor(issuer, id, replacementId, noteHash) | The CredentialRegistry only, inside issueReplacing | As supersede | As supersede | 0 |
supersedeToRegistry(id, replacementId, successorRegistry, noteHash) | Issuer only | As supersede | Superseded, pointing to a credential in another set | 0 |
setIssuerSuspended(issuer, true, reason) | A guardian (the admin counts as one) | Any | All of that issuer's credentials stop being valid | Any |
setIssuerSuspended(issuer, false, reason) | The admin only | Any | Lifts the issuer-wide suspension | Any |
Any other transition reverts with InvalidTransition(from, to). A caller without the right gets NotAuthorised; an unknown id gets UnknownCredential.
Status changes are not stopped by the system pause. The pause stops creating (issuing, redeeming invitations, registering record types); an issuer can still revoke during an incident.
Supersede
supersede (in the same set) requires a replacement that is a different credential, currently Active, and issued under the same key in charge. Otherwise it reverts with ReplacementInvalid ("The replacement must be a different, active credential issued by the same account."). The CredentialSuperseded(id, replacementId, successorRegistry) event names the registry where the replacement lives: this set's own registry for supersede, or another set's for supersedeToRegistry.
supersedeToRegistry cannot check a credential in another contract, so it only requires a non-zero replacement id different from the old one and a successor registry that is not zero and not this set's own. The verify page then checks the named replacement itself (it must exist in a listed set, have the same key in charge and be in good standing). supersedeFor, supersedeToRegistry and issueReplacing exist on sets 0.4.0 and later.
An admin hold blocks supersede: incident response may end in revocation, but the issuer cannot quietly point a held credential at a replacement.
Reasons and notes
| Code | Label |
|---|---|
| 0 | Unspecified |
| 1 | Issued in error |
| 2 | Data correction |
| 3 | Fraud or misrepresentation |
| 4 | Academic misconduct |
| 5 | Administrative hold |
| 6 | Issuer key compromise |
| 7 | Holder request |
| 8 to 999 | Reserved; the contract refuses them from issuers (InvalidReason) and the app shows "Reserved (n)" |
| 1000 and up | Issuer-defined; shown as "Issuer-defined (n)" |
A note is free text typed in the UI. Only keccak256 of the trimmed text is sent (noteHash), or the zero hash when there is no note. The text itself is never stored; the issuer keeps it if it matters.
From the dashboard
The issuer dashboard (/dashboard) lists every credential the issuer key and its retired predecessors issued, in every listed contract set, read from CredentialIssued events with the status of each (in batches of 10). Each row has Verify, Open document, Replace key, Manage access where the access gate applies, Re-issue when supersede is possible, and Revoke.
The dialog Revoke this credential? says "Anyone who checks it will see it as revoked. This cannot be undone." and offers Reason (codes 0 to 7) and an optional Note ("Only a hash of the note is stored on-chain."), confirmed with Revoke credential. The call goes to the status registry of the set that holds the credential.
Re-issue opens the issue form with ?replaces=<id>; see Issuing.
From the verify page
On /verify?id=..., Manage as issuer connects a wallet and compares it with the key in charge. A different wallet sees "The connected wallet is not the issuer of this credential." The issuer then sees Issuer tools:
| Status | Buttons |
|---|---|
| Active | Suspend, Revoke, Supersede |
| Suspended | Reinstate, Revoke, Supersede |
| Suspended with an admin hold | Revoke only, with "A registry administrator placed this hold, so only the administrator can lift it, and it cannot be superseded." |
| Revoked or Superseded | "This status is final; no further changes are possible." |
Each button arms the action and shows Reason, Note (only its hash is stored on-chain) and, for supersede, Replacement credential id; Confirm suspend (and so on) sends the transaction, Cancel disarms. A badly formed replacement id gives "The replacement must be a 0x-prefixed 32-byte credential id." After the transaction the status is read again.
Admin and guardian actions
There is no screen in the app for an admin hold on one credential or for issuer-wide suspension. The admin (a Safe on Sepolia, see Governance) or a guardian calls suspend or setIssuerSuspended on the contract directly. The app reflects the result: the verify page marks an admin hold, the status line explains an issuer-wide suspension, and the dashboard and holder lists show the badge.
Expiry
The contract supports validUntil, and isValid turns false after it. The issue form always writes 0 (no expiry), so credentials issued through the app do not expire. A credential issued with an expiry by other tooling would show as Active but not valid after the date.
Errors
| Revert | Message |
|---|---|
NotAuthorised | "Only the issuer of this credential can do that. Switch to the issuing account." |
InvalidTransition | "This credential can no longer be changed this way. Its status may already be final, or held by an administrator." |
ReplacementInvalid | "The replacement must be a different, active credential issued by the same account." |
UnknownCredential | "This credential was not found." |
InvalidReason | "That reason code is not allowed." |
What changes and what does not
| Changes | Never changes |
|---|---|
status, reason, updatedAt, updatedBy, adminHold, supersededBy | The anchor, the signature event, the credential id |
Events: StatusChanged(id, from, to, reason, by, noteHash), CredentialSuperseded, IssuerSuspensionChanged | Earlier events (the full history stays readable) |
Packages, VCs and disclosure files carry no status. Every verifier reads it from the chain at the time of checking, and status lists, when configured, mirror it for standard VC tooling.
Code map
| Concern | File |
|---|---|
| Contract | blockchain/contracts/v03/StatusRegistry.sol, libraries/CredentialTypes.sol |
| Service | src/services/domain/status-service.ts, note-hash.ts |
| Reasons and actions | src/lib/status-reasons.ts, src/lib/status-actions.ts, src/lib/status-errors.ts |
| Dashboard | src/app/dashboard/IssuerDashboardPage.tsx, IssuedCredentialsTable.tsx, RevokeDialog.tsx, src/services/domain/issued-credentials-service.ts |
| Verify page | src/app/verify/ManageCredential.tsx, CredentialStatusBanner.tsx |
| Badges | src/components/credential-status.ts |
Tests
blockchain/test/v03/StatusRegistry.test.ts,ExpiryBoundary.test.ts,Revisions.test.ts,KeyLineage.test.ts,PauseClassification.test.ts, and the invariant suite underblockchain/test/invariantssrc/services/domain/status-service.test.ts,src/lib/status-actions.test.ts,src/lib/status-reasons.test.ts,src/lib/status-errors.test.tssrc/app/verify/ManageCredential.test.tsx,src/app/dashboard/RevokeDialog.test.tsx- E2E:
e2e/journeys/revoke-and-reverify.journey.ts,dashboard-actions.journey.ts,key-retirement.journey.ts,pause-and-restart.journey.ts