Key decisions
Every significant choice in Certichain is recorded as an architecture decision record (ADR) in docs/progress/DECISIONS.md in the repository. That log is long and chronological. This page groups the decisions that shaped the architecture by theme, gives the reason for each in a sentence or two, and names the ADR so you can read the full text, the options that were rejected and any later amendments.
"ADR-0088" means the entry headed ## ADR-0088 in docs/progress/DECISIONS.md. Later decisions sometimes refine earlier ones; where that happened, both are named here and the later one wins.
How the decisions build on each other
Each phase rests on the one before it: the privacy layer needs the signed, commitment-based credential of Phase 1, the standards exports reuse its signature, and production readiness changes how the Phase 1 contracts are deployed and governed without changing what they mean.
Ground rules
| Decision | Why | ADR |
|---|---|---|
| Sepolia (or a local Hardhat node) only until the production launch gate. No real names, student ids or documents until personal data is off chain and documents are encrypted; demo data must be obviously fictitious. | A public chain never forgets. Testing with real people's data before the privacy work landed would have been irreversible. | ADR-0007 |
| Spec first and test-driven development for every step. | Most of the system's guarantees are cryptographic and easy to break silently; a failing test written first pins the behaviour. | ADR-0010 |
| Prototype zero-knowledge code from early branches is never merged, and a mock verifier is never deployed. | Those prototypes had mock verifiers and misaligned signals. A deployed mock verifier would accept any proof. | ADR-0008 |
Contract design
| Decision | Why | ADR |
|---|---|---|
Build a new v0.3 contract set from scratch instead of patching the v0.2 AcademicRecords contract. | The new anchor struct, event-first reads and namespaced identifiers would have meant a patchy rewrite, and there was no testnet data worth migrating. | ADR-0034 |
| OpenZeppelin 5 for v0.3 (v0.2 stays on 4.9). | Custom errors, AccessControlDefaultAdminRules (a delayed, two-step admin transfer) and maintained EIP712, ECDSA and SignatureChecker. | ADR-0035 |
| Custom errors instead of revert strings. | Smaller bytecode, lower gas, and ethers decodes them into typed errors the app can explain. | ADR-0036 |
Issuer signatures checked with SignatureChecker, so EOAs and ERC-1271 contract wallets both work. | Cheap at the start, and needed for a multisig issuer later; adding it afterwards would have changed the signing path. | ADR-0037 |
recordType is a uint32: 0 to 31 are built in, 32 to 999 are reserved and refused, and 1000 and up are defined per issuer in a separate RecordTypeRegistry. (ADR-0038 first reserved the whole 0 to 999 range for built-in types; ADR-0053 narrowed it.) | Institutions need their own record types without a contract change, and types must not collide between issuers. | ADR-0038, ADR-0053 |
Addresses live in one generated manifest per chain; CredentialRegistry is the single entry point that exposes the other registries; every interface has a shared behaviour test suite. | A redeploy or restructure costs only a regenerated manifest and a pass of the suites. No contract address is ever typed into an environment variable. | ADR-0040 |
| No unbounded loops: every v0.3 operation does constant work, guarded by source, ABI and gas tests; issuer names are capped at 128 bytes. | A loop over a growing list eventually exceeds the block gas limit and locks the function for ever. | ADR-0050 |
| Contracts are immutable. A change ships as a new complete set next to the old ones; old sets stay live; one set is active for new issuance; every verification surface reads all listed sets. | No upgrade key to steal, and a verifier can rely on the code behind an address never changing. The cost is routing by registry address everywhere. | ADR-0022, ADR-0088 |
| Revisions R1 to R5 (atomic replace-and-supersede, signature lookup without the stored block, successor registry hint, ERC-165 and version getters, a domain version rule) go into the first production set. R6 (batch anchoring) is built only if the chosen chain makes it worthwhile. | Fix what a production deployment needs before it exists, and avoid building batching that a cheap chain would not need. | ADR-0088, ADR-0089 |
See Contract versions and sets and Smart contracts.
Credentials, signatures and personal data
| Decision | Why | ADR |
|---|---|---|
The credential id is the EIP-712 digest of the signed Credential struct, under a domain that names the chain and the registry. | The id commits to every signed field and cannot be replayed on another chain or registry. Clients compute it before issuing. | ADR-0044 |
The issuer's EIP-712 signature is always required, even when the issuer sends the transaction itself, and is emitted in a separate CredentialSigned event. A per-issuer nonce is part of the typed data. | The signature is the portable proof a holder carries away from the website. The extra event keeps CredentialIssued unchanged. | ADR-0045 |
| The status registry is bound once to its credential registry. Issuers manage their own credentials; the admin can only suspend, lift its own holds and suspend a whole issuer. An admin hold can be revoked by the issuer but not superseded. | Nothing is ever deleted, and a stolen issuer key cannot quietly link a held credential to a replacement it controls. | ADR-0046 |
| No plaintext personal data on chain. The subject is a salted commitment and the display data is a salted metadata hash; the holder's package carries the display fields, the salt and the CID. | An unsalted hash of a name and student id lets anyone confirm a guess against the chain. | ADR-0047 |
| Each student has one stable enrolment commitment per issuer, with a student salt derived from an issuer signature over typed data. Wallets bind only through issuer-signed, wallet-bound invitations (at most 30 days) with binding epochs that kill stale invitations. | Lets a holder wallet see all its credentials from one issuer without putting the student id on chain, and lets a wrong or lost wallet be cancelled. Credentials of one student at one issuer become linkable on chain, never across issuers. | ADR-0048, ADR-0049 |
| Contract-wallet issuers can issue on chain, but the website cannot derive student salts or encryption keys for them. | Their signatures can change with the signer set, so anything derived from a signature would not be repeatable. | ADR-0049, ADR-0063 |
See Issuing a credential, Enrolment and Privacy design.
Reading the chain
| Decision | Why | ADR |
|---|---|---|
No hosted indexer. Lists are built from topic-filtered eth_getLogs scans in bounded, resumable windows from the manifest's deployment block, behind a CredentialIndex interface, with a browser cache. | Nothing new to host or secure. A hosted indexer can replace the implementation later without changing pages or services. | ADR-0051, ADR-0052 |
issueBlock(credentialId) lets a client fetch the signature event with a one-block query. Later revised so the lookup works by event topic too (R2). | Public RPC providers cap eth_getLogs ranges, and on some chains the stored block number is not the one the RPC reports. | ADR-0045, ADR-0088 |
| One RPC provider factory with ordered failover at every call site, and retries with backoff on log scans. | A single flaky public endpoint should not break verification. | ADR-0087 |
See Reading the chain.
Server
| Decision | Why | ADR |
|---|---|---|
Uploads need a Sign-In with Ethereum session in a short-lived HttpOnly cookie, built on siwe and jose; rate limits and nonces live in Upstash Redis in production and in memory locally. | Only registered issuers may pin files, without accounts or passwords. | ADR-0016, ADR-0026 |
| The upload request cap is 4 MB, so a document may be at most 3.5 MB and its encrypted, padded container still fits. | Vercel functions reject bodies over 4.5 MB before the route runs. | ADR-0029, ADR-0065 |
| A role-check outage answers 503, never "allowed"; rate limits are atomic; client IP headers are trusted only on Vercel. | Fail closed, and stop a caller from spoofing its address to dodge limits. | ADR-0031 |
| Only issuers pin. Holders who want a recipient cut off from future copies ask the issuer to rotate the key. | Keeps the upload route's single rule and its abuse surface small. | ADR-0063 |
| Verification API answers are EIP-712 signed by a dedicated, funds-free key with an echoed nonce and a signed data age; API keys are stored only as SHA-256 hashes. | A client that pins the signer address gets integrity beyond the transport, and a cached answer cannot pass for a fresh one. | ADR-0083 |
| Status lists are signed by a separate funds-free publisher key and served from the deployment's own domain. | A key compromise affects availability of lists only; verifiers that read the chain are unaffected. | ADR-0078 |
See Server and APIs.
Documents and sharing
| Decision | Why | ADR |
|---|---|---|
Documents are encrypted in the browser before upload (AES-GCM content key, wrapped to recipients with X25519 through HPKE). The issuer's X25519 key is derived from a repeatable wallet signature over EncryptionKey(owner, keyVersion), held in memory only. The app signs twice and refuses a wallet whose signatures differ. | No key registry and no contract change; the server and the pinning service only ever see ciphertext. Accepted weakness: any site that obtains the same signature derives the same key. | ADR-0063, ADR-0065 |
| The document key travels in the credential package, which becomes the secret that opens the document. | A holder can open the document with the package alone, without a wallet. | ADR-0063 |
| Sharing uses share codes (a checksummed public key with a fingerprint compared out of band), grant files and capability links whose key sits in the URL fragment. A grant discloses the signed fields, the CID and the wrapped key only. Key replacement removes the issuer's older pins, never the last one. | Narrower than handing over the whole package. Replacement is best-effort clean-up, not revocation: someone who saved the old container and key keeps access. | ADR-0066, ADR-0067 |
See Document storage and Sharing.
Zero-knowledge access and selective disclosure
| Decision | Why | ADR |
|---|---|---|
Private access uses Semaphore v4 unchanged (circuit, Groth16 verifier, group contract) behind a thin CertichainAccessGate. The bound holder wallet or the issuer admits a requester; a proof earns an on-chain receipt only, and documents are still released off chain through grants. | Reuse an audited, widely used circuit instead of writing one, and keep the proof's power small. | ADR-0068 |
| Circuit artifacts are pinned and checked: the verification keys of all pinned zkeys must match the pinned verifier, and the zkeys reproduce from the circuit source against the public Powers of Tau. | Shows consistency between what is deployed and what was published. It cannot show that any ceremony contributor was honest. | ADR-0069, ADR-0070 |
The browser prover ships, with GPL-3.0 snarkjs imported only inside the proving worker. | Proofs in the browser in one to two seconds. The licence boundary stays one file wide; the repository licence text is the owner's to write. | ADR-0072 |
| Selective disclosure needs no circuit: the issuer signs a tree of salted display fields, the holder signs a disclosure file bound to the verifier's challenge, and the verify page checks it without a wallet. | Disclosing chosen fields is enough for the current use; a predicate proof waits until an institution asks for one. | ADR-0074, ADR-0075 |
See Private access proofs and Selective disclosure.
Standards and integrations
| Decision | Why | ADR |
|---|---|---|
The credential package stays canonical; a W3C VC 2.0 document is an export of it, never containing the document key or the CID. Issuers are did:pkh:eip155:<chainId>:<address>. The proof reuses the issuer's EIP-712 signature as the certichain-eip712-2026 cryptosuite. | One source of truth and no second issuer key for the basic export. Third-party verifiers need a small plugin, which the interop tests prove works. | ADR-0076 |
Open Badges 3.0 is a separate export. A second, standard eddsa-rdfc-2022 proof is added by the issuer from a key derived from and attested by the wallet. | Badge platforms expect a standard suite that verifies with no plugin. | ADR-0079, ADR-0082 |
| One independent JavaScript verifier library checks the exports in CI. | Proves the formats are readable by code Certichain did not write. | ADR-0080 |
| A private, unpublished SDK accepts API answers only from a pinned signer, with no signer discovery. | Integrators get the API's integrity guarantees without writing signature code. | ADR-0084 |
| The phone wallet is an installable web app with compact static QR codes checked offline, plus a holder-bound presentation signed for the verifier's challenge. Only hashes, addresses and the signature are stored by default. | No app store and no relay service. A static code is a copyable bearer proof, and the pages say so. | ADR-0085 |
See Integrations and Data formats.
Governance
| Decision | Why | ADR |
|---|---|---|
The IssuerRegistry holds a guardian role and the pause flag. Pause stops creating things (issue, redeem, rebind, register a type) and never stops revocation, suspension or reads. The guardian can pause and suspend an issuer at once; the admin transfer is delayed and two-step (3 days on Sepolia). | A fast brake that cannot be used to undo history, and an admin change that everyone can see coming. | ADR-0057 |
| The admin of the v0.3 registries on Sepolia is a 2 of 3 Safe. | No single key controls issuer registration. | ADR-0058, ADR-0060 |
| An OpenZeppelin timelock run by the Safe: the Safe proposes and cancels, anyone executes after the delay (2 days in production, 1 hour for the Sepolia rehearsal). | A public delay before any admin action runs, so issuers and verifiers can react. | ADR-0090, ADR-0092 |
| Two revisions in a new set: R7, retiring an issuer key in favour of a successor so older credentials stay in good standing; R8, the guardian may unpause. | Institutions rotate keys, and recovery from a pause should not wait for the admin delay. | ADR-0090, ADR-0091 |
| Consortium admission is a charter and a recorded accreditor sign-off whose hash becomes the timelock salt, not a contract rule. | Keeps the contracts unchanged while giving a public link between an admission and its approval. | ADR-0095 |
See Governance and Key retirement.
Operations and the interface
| Decision | Why | ADR |
|---|---|---|
Alerts go to one configured webhook; a scheduled route runs a read-only monitor; a second pinning provider sits behind a PinProvider interface; the RPC list holds two hosted providers and the chain's public endpoint. | Vendor-neutral code, configured by lists. Everything stays off until the owner sets the secrets. | ADR-0087 |
| Service levels are measured by the same monitor with daily counts and a canary probe, and reported monthly. | No public status page to run, but numbers partners can hold the service to. | ADR-0095 |
| The production chain is chosen on measured gas per operation, a cost model and a compatibility checklist; security and long-term checkability come before cost. | A credential must stay checkable for decades, which outweighs a cheaper fee. | ADR-0086 |
| The interface follows one "modern security print" design system: OKLCH tokens, shared primitives, role workspaces, and a deterministic seal per credential that is a recognition aid only. | One coherent product, and a visual cue that never pretends to be a check. | ADR-0096 |
See Operations and Web application.
Source files
docs/progress/DECISIONS.md(the full decision log)docs/progress/DEVIATIONS.md(where the build departed from a plan, and why)docs/specs/(the specs each ADR confirms)