An on-chain certificate system for Superteam Brasil — "DocuSign on Solana." Sysadmins draft certificate editions (a course, cohort, or turma), invite 2–6 named signers by email, and — once every seat is accepted — push the edition on-chain. Students request a certificate, and once every signer has signed, claim a soulbound NFT into their wallet forever, with a sealed PDF export alongside it. Signers get a mass-sign dashboard (one wallet popup for many certificates) behind a consent ceremony. Anyone can verify a certificate publicly — by link, by its 8-character verify code, by its SHA-256 hash, or by uploading the image or the PDF itself. Email notifications (Resend) cover every hand-off: invited, pending, ready, rejected, revoked, claimed.
The app has two front doors — certificates and attendance — each with its own role-gated entry points; see "Page map" below.
Built devnet-first, autonomously. See WAKEUP.md for what a human needs to
do before this is fully live, and CHANGELOG.md for the
milestone-by-milestone history.
Three pieces, one rule: the chain is authoritative.
programs/certify/ Pinocchio program (Solana). Owns: signatures, statuses,
certificate/edition state, the artifact hash, the
multisig-style admin/signer thresholds. Nothing about
"is this certificate valid" is ever decided anywhere
but here.
packages/certify-client/
Hand-written @solana/kit codec client (no Anchor IDL —
there's no Anchor). Instruction builders, PDA
derivation, account decoders, an error-code map.
Golden-vector tested against real on-chain account
dumps, not just against the Rust source.
apps/web/ Next.js 15 app. Supabase Postgres is a READ CACHE / UX
layer, never a source of truth — every public and
student-facing page does a live on-chain re-check
(see "Chain-authoritative model" below) before trusting
a status the mirror shows. Auth is Privy (email ->
embedded Solana wallet, or a native wallet); Supabase
has no auth role at all.
The DB mirror exists so pages paint instantly and so you can query "all pending certificates for signer X" without an RPC round trip per row — none of that would be reasonably fast against the chain directly. But the mirror can lag (a revoke that landed on-chain 2 seconds ago might not be synced yet), so every surface where trusting a stale "valid" would be a real problem does a browser-side chain re-check on top of the server-painted page:
/verify/[id]: the server-rendered page paints instantly from the DB mirror (also gives you the OG tags); a client island (VerifyChainStamp+VerifyStatusBanner) then re-fetches theCertificateaccount directly and, if chain disagrees with the mirror toward Revoked, overrides the banner — a stale-mirror-revoked certificate never shows a misleadingly-green "válido" verdict, even for the few hundred milliseconds before the chain-check resolves.- The NFT link on the verify page is never taken from the mirror — it's the
live on-chain
Certificate.assetfield, checked against the actual minted asset (the "back-reference doctrine": an NFT is legitimate iffCertificate.assetpoints back at it — this is the only rogue-mint defense, since all certificates share one global Metaplex Core collection). - The mass-sign dashboard resumes from a chain re-query on every page load, never from client-held batch state — a partially-completed batch just shows a smaller inbox next time, nothing to reconcile.
- NOTARY — co-signs
claim_certificateonly, attesting that the artifact hash matches what the server itself rendered. A leaked notary key can, at absolute worst, collude with a student to poison that student's own certificate hash — it cannot forge a signer's approval, a name, or a revocation. - OPERATOR — single-signs the "creation class" of admin ops
(
create_edition,set_edition_status,set_max_supply,record_asset) after the API has already verified the caller is an allowlisted sysadmin. The destructive class (revoke_certificate,set_notary,add_admin,remove_admin) requires 2 pairwise-distinct admin signatures — seeWAKEUP.md"Custody" for why that matters and what to do about it.
| Layer | Choice |
|---|---|
| Program | Pinocchio 0.11 (#![no_std], zero unsafe, zero heap allocation) |
| Program tests | LiteSVM (real-transaction integration tests) + Mollusk (per-instruction CU regression gates) |
| Client codec | Hand-written @solana/kit ^6.10 codecs (no Anchor IDL) |
| NFT | Metaplex Core 1.10, server-side mint via Umi (soulbound: permanent-freeze + permanent-burn-delegate plugins) |
| Web app | Next.js 15.5 (App Router, React 19, TS strict), Tailwind 4 + shadcn/ui |
| Auth | Privy v3 (@privy-io/react-auth) — embedded Solana wallets + external wallet-standard adapters |
| Database | Supabase Postgres (RLS everywhere) + Storage (public, content-addressed buckets) |
| Rendering | satori + @resvg/resvg-js, pinned exact versions for byte-determinism |
| Data fetching | TanStack Query for authed dashboards; RSC reads for public pages |
| Forms | react-hook-form + zod 4 |
Resend, behind a degrade-gracefully wrapper (lib/email/) — skips + logs when unconfigured, never throws |
|
| PDF export | pdf-lib 1.17.1 (deterministic, byte-identical builds) + @signpdf/node-forge for an optional pluggable cryptographic seal |
| Designer | @dnd-kit + react-moveable + react-selecto + @scena/react-guides, touch-first rebuild |
| CI | GitHub Actions (.github/workflows/ci.yml) — typecheck/lint/vitest/next build on every PR, plus a paths-gated program job (fmt/clippy/build-sbf/test) that only runs when programs/**/tests/** changed |
The 2026-08 overhaul split the app into two front doors — the certificate
product and the attendance product — and renamed every certificate-side
route. Old links keep working: next.config.ts 301-redirects every renamed
route permanently, so printed QRs, bookmarks, and links already handed to
students/signers all still resolve. Routes under /api/** did not move.
| Route | Old route | Purpose | Auth |
|---|---|---|---|
/ |
/ |
Landing — three intent cards: Emitir certificados, Verificar documento, Eventos & presença | none |
/certificates |
/editions |
Public catalog of open editions | none |
/certificates/[slug] |
/editions/[slug] |
Edition page + certificate request form | form gated on Privy |
/verify, /verify/[id] |
same | Verification — link, 8-char verify code, SHA-256 hash, or PNG/PDF upload; ?lang= override; "Detalhes técnicos" disclosure |
none |
/me |
same | "Meus documentos" — certificates and attendance claims together, claim ceremony, Baixar PDF | requireUser |
/sign |
/certificator |
Signer inbox — consent panel, batch signing, completion summary | requireCertifier |
/studio |
/admin |
Issuer dashboard — stat cards, edition list, Atividade feed | requireSysadmin |
/studio/editions/new |
/admin/editions/new |
Creation wizard — draft autosave, seats/invites, designer, explicit "Criar on-chain" step | sysadmin |
/studio/editions/[id] |
— (new) | Edition management — seat/invite status, distribution kit, per-certificate pipeline, reminders | sysadmin |
/invite/[token] |
— (new) | Signer invite acceptance — Privy login, linked-wallet selection/binding, distinct dead-states for expired/already-accepted tokens | token + Privy |
/events |
same | Attendance event creation/dashboard | creator-wallet check |
/attend/[token] |
same | Attendance claim link | token |
/nft/[assetId] |
same | Attendance NFT detail | none |
New API surface (all under the existing apiRoute() + auth helpers — see
each route file for request/response shapes):
/api/studio/drafts,/api/studio/drafts/[id],.../invites,.../invites/[inviteId],.../invites/[inviteId]/remind,.../create-onchain— draft-first edition lifecycle + signer invites./api/certificates/[addr]/pdf— sealed PDF export (claimed certificates only)./api/verify/resolve-code/[code]— 8-char verify code lookup./api/me/attendance— attendance claims for the session's linked wallets, feeding/me./api/cron/digest— daily signer digest (Vercel Cron; see "Environment variables").
programs/certify/ the Pinocchio program
tests/ LiteSVM + Mollusk test suite (separate crate — cargo
build-sbf must NOT include this; see below)
packages/certify-client/ the hand-written TS client, golden-vector tested
apps/web/ the Next.js app
scripts/ deploy/seed/E2E/RLS-probe scripts (isolated
node_modules — see "Why scripts/ has its own
package.json" below)
supabase/migrations/ 0001_init.sql — schema + RLS policies
.superpowers/sdd/ every milestone's brief + report + review, the full
build history, in more detail than this file has room for
pnpm install # apps/ + packages/ (workspace-managed)
pnpm dev # http://localhost:3000First time, or waking this project up after a break: read WAKEUP.md. It's
short, ordered, and is the actual list of things only a human can do
(paste the Supabase URL, flip two Privy dashboard toggles, log in once with
a real OTP).
Next.js/Turbopack only inlines NEXT_PUBLIC_* vars from .env* files inside
the app's own directory (apps/web/), not the repo root, even though this is
a pnpm workspace. apps/web/.env.local -> ../../.env makes the root .env
the one canonical file without duplicating it. If you ever see a
NEXT_PUBLIC_* var behaving as if it's empty despite being set in .env,
check this symlink still exists first.
Adding a dependency to the workspace root or apps/web mid-build triggers
pnpm's "remove all modules and reinstall from scratch" prompt — a full
node_modules purge, disruptive if anything else is mid-build. scripts/
gets its own isolated pnpm install --ignore-workspace (own
node_modules + lockfile) so its dependencies (kit, Umi, mpl-core,
@supabase/supabase-js, the workspace-local @certify/client via a file:
dependency) never touch the root install. tsx still runs these scripts
from the repo root (pnpm deploy, pnpm seed, etc.) — module resolution is
based on each script file's own location, not the invoking shell's cwd, so
this works transparently.
One .env file at the repo root (the symlink above makes it the app's
too). .env.example is the template — copy it to .env and fill in real
values; never commit .env itself. Every var below is read somewhere in
apps/web unless noted otherwise (confirmed by grepping process.env.
across the app).
| Var | Purpose |
|---|---|
NEXT_PUBLIC_RPC_URL |
Devnet RPC endpoint |
NEXT_PUBLIC_WS_URL |
Devnet websocket endpoint |
NEXT_PUBLIC_PROGRAM_ID |
Certify program id (filled by pnpm seed:onchain) |
NEXT_PUBLIC_APP_URL |
Canonical origin — builds every absolute link the server sends out (invite emails, verify links, the PDF's verify URL) |
CORE_COLLECTION_ADDRESS |
The one global Metaplex Core collection (filled by pnpm seed:onchain) |
| Var | Purpose |
|---|---|
NEXT_PUBLIC_PRIVY_APP_ID |
Privy app id |
PRIVY_APP_SECRET |
Privy server secret |
| Var | Purpose |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Anon key — RLS-restricted, see "Security posture" |
SUPABASE_SERVICE_ROLE_KEY |
Service-role key — server routes only, never sent to the client |
SUPABASE_DB_URL |
Optional; lets pnpm setup:supabase apply migrations directly (Dashboard → Connect → URI). Without it, paste supabase/migrations/*.sql into the SQL editor by hand. Read by scripts/, not by the app itself. |
Path to a solana-keygen JSON file, or the JSON array itself. See "Security
posture" below and WAKEUP.md "Custody" before using any of these in
production.
| Var | Purpose |
|---|---|
NOTARY_SECRET_KEY |
Co-signs claim_certificate only |
OPERATOR_SECRET_KEY |
Single-signs the admin "creation class" ops; one of the two required signers for the destructive class |
DEPLOYER_SECRET_KEY |
Program upgrade authority; doubles as the 2nd destructive-op admin signer until a real admin wallet registers |
| Var | Purpose |
|---|---|
ADMIN_EMAILS |
Comma-separated sysadmin allowlist, by Privy email |
ADMIN_WALLETS |
Comma-separated sysadmin allowlist, by wallet |
| Var | Purpose | Unset behavior |
|---|---|---|
RESEND_API_KEY |
Resend API key | emailConfigured() (lib/email/send.ts) returns false; sendEmail() logs one warning and returns { sent: false, reason: "unconfigured" } instead of throwing. Every notification trigger (invite, cert-ready, rejected, revoked, claim receipt, reminder, digest) becomes a no-op, never a failed request. |
EMAIL_FROM |
Verified sender, e.g. Superteam Certify <no-reply@certify.superteam.com.br> |
Required together with the key above — either missing disables sending |
| Var | Purpose | Unset behavior |
|---|---|---|
CRON_SECRET |
Bearer token GET /api/cron/digest requires (Vercel Cron calls it daily at 12:00 UTC per apps/web/vercel.json) |
Fails closed — every request 401s, including Vercel's own trigger. Unlike the vars above, this is not a graceful degrade; set it before relying on the digest. |
Vercel only reads vercel.json from the project's configured Root
Directory (apps/web here) — a copy at the repo root is silently ignored,
which is exactly what happened until commit 289ee4c moved it. Set
CRON_SECRET in the Vercel project's environment variables (not just
locally): Vercel then sends it automatically as the Authorization: Bearer
header on every cron invocation, so there is no separate webhook-secret
step to configure.
| Var | Purpose | Unset behavior |
|---|---|---|
SEAL_P12_BASE64 |
Base64 of a self-managed org .p12/.pfx (base64 -i seal.p12) |
sealConfigured() (lib/pdf/seal.ts) returns false; GET /api/certificates/[addr]/pdf ships the PDF unsealed — still valid, still verifiable, just without the cryptographic signature panel |
SEAL_P12_PASSPHRASE |
Passphrase for the .p12 above |
Ignored when SEAL_P12_BASE64 is unset |
An ICP-Brasil A1 (file) certificate is deliberately not supported this
way — Adobe reports an A1 used outside its issued device as invalid. The
upgrade path is a PSC-hosted cloud A3 certificate behind the same
CertificateSource interface (lib/pdf/seal.ts), not yet implemented.
Shown on /verify/[id] and printed in the PDF footer/evidence page and the
email footer (lib/issuer.ts, lib/email/templates.ts).
| Var | Purpose | Unset behavior |
|---|---|---|
ISSUER_NAME |
Issuer display name | Hides the issuer identity block on /verify/[id] entirely; the PDF and email footer fall back to the platform name |
ISSUER_CONTACT_URL |
Optional issuer contact link | Omitted when unset |
ISSUER_CNPJ |
Optional issuer CNPJ | Omitted when unset |
| Var | Purpose |
|---|---|
NEXT_PUBLIC_UI_MOCK |
1 renders every screen from fixtures — no Privy, Supabase or RPC. See apps/web/README.md "UI mock mode". Ignored whenever NODE_ENV=production. |
ATTENDANCE_MERKLE_TREE, ATTENDANCE_CREATOR_WALLETS,
ATTENDANCE_SESSION_SECRET — see "Attendance NFTs" below.
| Var | Purpose |
|---|---|
HELIUS_API_KEY |
Powers the Helius MCP dev tool only — the app itself reads RPC from NEXT_PUBLIC_RPC_URL/NEXT_PUBLIC_WS_URL |
# App
pnpm --filter web dev / build / lint / typecheck / test
# Program (from repo root)
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo build-sbf --manifest-path programs/certify/Cargo.toml # NOT a bare
# workspace-root `cargo build-sbf` — that also tries to SBF-compile the
# tests/ crate, whose LiteSVM/Mollusk deps pull in `getrandom`, which
# doesn't support the SBF target and fails the build.
# Devnet operational scripts (see scripts/ — all idempotent / skip-if-exists)
pnpm deploy # cargo build-sbf + program deploy/upgrade
pnpm seed:onchain # init_config + the one global Core collection
pnpm e2e # full request->sign->claim->mint->record_asset E2E
pnpm setup:supabase # apply migrations + create storage buckets
pnpm rls-probe # security gate — proves the anon key can't write
# anywhere and can't read profiles/events
pnpm seed # demo data: 1 edition, 5 certificates covering
# every dashboard/verify statusSee WAKEUP.md step 4 — the 5-minute walkthrough (login through revoke)
that can't be automated in this build environment (Privy email OTP needs a
real inbox; there's no Chrome binary in the sandbox for a headless
keyboard-only Playwright run either). Everything upstream of "click login"
is covered by the automated gates below.
- Program:
cargo fmtclean,cargo clippy --all-targets -- -D warningsclean,cargo test --workspace— 44/44 passing acrosstests/*.rs(batch, bug-class, claim/reject backfill matrices, CU gates, edition/supply boundaries, golden-vector decode equality, full lifecycle, smoke, threshold/aliasing). - App:
pnpm --filter web test(vitest) — 159/159 passing.tsc --noEmitclean.eslintclean.next build— 31/31 routes compile. - RLS:
pnpm rls-probe— not runnable tonight (Supabase URL pending); seeWAKEUP.mdstep 1.5. The migration's policies were read closely (onlyeditions/edition_signers/certificateshave an anon SELECT policy;profiles/eventshave zero policies, meaning RLS-enabled-with-no-policy denies everything to non-service-role) but the probe script itself is the actual gate, not this sentence — run it.
A transient CU-gate test failure was observed exactly once, during a
workspace-wide cargo test run under heavy concurrent load (this sandbox
had other agents building at the same time) — not reproducible in isolation
or on immediate retry, and the CU numbers involved (LiteSVM-simulated
compute-unit counts) have no legitimate source of run-to-run variance. Noted
here for transparency, not treated as a real finding.
Full detail lives in the plan (.claude-adjacent
you-are-going-to-foamy-stallman.md) and the milestone reviews; this is the
condensed, "what actually shipped" version.
- Name impersonation (the top human risk — a signer rubber-stamping a
fake name): the sign UI shows the student's name as the loudest element on
every row;
lib/schemas.ts's name sanitizer strips bidi-override and zero-width characters, NFC-normalizes, enforces a letters/marks/numbers/ space/'/-/. charset (blocks emoji and most symbols), caps length at 64, and — added in M7 — rejects names mixing Latin with Cyrillic or Greek letters (the classic homoglyph substitution, e.g. a Cyrillic "а" standing in for Latin "a"). This runs identically on the client (live preview) and the server (prepare-request, same schema, same code) — seelib/__tests__/schemas.test.tsfor the fixture set. - Onchain admin gating: every privileged instruction checks the
program's own
Config.adminsregistry — client/API checks are UX only, never the actual authorization boundary. - RLS: enabled on every table; only
editions/edition_signers/certificateshave an anon SELECT policy; all writes are service-role only, from server route handlers, after the caller's ownership/authority has already been checked.pnpm rls-probeis the negative-test gate for this (see above). - HashIndex anti-squat: created only inside the notary-cosigned claim
instruction (
init, neverinit_if_needed) — a program's own PDAs cannot be created from outside the program, so pre-squatting an artifact hash is structurally impossible, not just discouraged. - LGPD: the plaintext student name and its salt live only in
off-chain, deletable layers (the
certificatesmirror row, the rendered PNG, the metadata JSON) — the on-chainCertificateaccount stores onlysha256(salt ‖ NFC(name)). Erasure = null the two DB columns + delete the storage objects + (if already minted) revoke and burn. Not yet automated into a one-click admin action — see "Known limitations." - Destructive-op threshold: 2 pairwise-distinct admin signatures for
revoke_certificate/set_notary/add_admin/remove_admin. Tonight that's deployer+operator (acknowledged bootstrap theater — seeWAKEUP.md"Custody"); becomes operator+a-real-human's-wallet the moment an allowlisted admin logs in once.
A second, self-contained flow living alongside the certificate system:
whitelisted wallets create attendance events at /events
(wallet-standard sign-in, Privy email as a no-wallet fallback); each event
mints its own per-event Metaplex Core collection with the BubblegumV2
plugin. Participants open a secret per-event link at /attend/<token>,
connect any wallet-standard wallet (or Privy email), prove ownership with a
signed message (or their existing Privy session), and the server mints a
Bubblegum v2 compressed NFT straight to them — the operator pays every
fee, participants pay nothing. Creators get supply caps, claim deadlines,
pause/resume, and link rotation (invalidates the old link immediately).
Copy says "attendance NFT" (pt: "NFT de presença") throughout, deliberately
avoiding the more common but trademarked term for this pattern.
Each event's shared metadata JSON follows that pattern's conventions
(adapted to the Metaplex standard): optional location, end date
(multi-day events) and event URL from the creation form become
Title-Case attributes (Location, Event Date, End Date, Year,
Event URL, Issuer, Event ID) plus external_url and
properties.files. Every attendee's cNFT also carries a per-mint serial
in its on-chain leaf name ("Meetup SP #42") — assigned atomically by the
claim reservation, truncated byte-aware to Bubblegum's 32-byte name cap
(lib/attendance/metadata.ts#attendanceLeafName). Events created before
migration 0004 keep their original JSONs.
Three env vars, all server-only:
| Var | What |
|---|---|
ATTENDANCE_MERKLE_TREE |
Shared Bubblegum v2 tree address, created once by pnpm tree:attendance (depth 14 / buffer 64 / canopy 8) |
ATTENDANCE_CREATOR_WALLETS |
Comma-separated, exact-case base58 allowlist of wallets that may create events |
ATTENDANCE_SESSION_SECRET |
HMAC secret for the creator session cookie (32+ random bytes, e.g. openssl rand -base64 32) |
pnpm tree:attendance # one-time: creates the shared Merkle tree, prints
# the ATTENDANCE_MERKLE_TREE line to add to .env
pnpm e2e:attendance # devnet: createCollection + mintV2 + parseLeaf round tripMeasured cost per mintV2 on devnet (pnpm e2e:attendance, 2026-08-19):
95,000 lamports (~0.000095 SOL) — Bubblegum protocol fee + base tx fee,
paid entirely by the operator.
Admin / incident scripts (scripts/admin/, run with npx tsx; mutating
ones default to dry-run and need --execute; see
docs/runbooks/attendance-incident-playbook.md):
npx tsx scripts/admin/preflight.ts # run before every event: Supabase, operator balance, RPC, tree capacity
npx tsx scripts/admin/operator-balance.ts # operator SOL vs a mint-floor
npx tsx scripts/admin/integrity-report.ts # reconcile minted_count vs claims; flag double-mints/desync
npx tsx scripts/admin/reserve-sweep.ts # release stale pending reservations (--execute)
npx tsx scripts/admin/event-control.ts # pause/resume/rotate/invalidate an event from the CLI (--execute)
npx tsx scripts/admin/tree-capacity.ts # on-chain leaf usage vs the 16,384 cap
npx tsx scripts/admin/nonce-sweep.ts # delete expired nonces (--execute)Security migration supabase/migrations/0003_hardening.sql must be applied
(pnpm setup:supabase, or paste into the Supabase SQL editor to see the
NOTICEs). It closes a verified-live anon read of name_salt/owner_did/
owner_wallet on every certificate, revokes anon EXECUTE on the attendance
RPCs, and adds idempotency indexes/constraints. Apply it before relying on the
RLS boundary.
Ledger-triage dispositions (every deferred minor / parked line from the
build's SDD progress log) are folded in here; the full detail for each is in
.superpowers/sdd/you-are-going-to-foamy-stallman/task-m7-hardening-report.md.
Before mainnet — read this one first:
- Persistent-tombstone reject-grief (program, parked, not fixed
tonight). An edition signer or admin can, in a single transaction,
bundle
reject_requestwith a smallSystem.transferto the just-freed certificate PDA — funding the "tombstone" account above zero defeats the transaction-end garbage collection, so the PDA survives program-owned across transactions. Result: a permanent, irreversible block on that (edition, student) pair ever requesting again, for about 0.0009 SOL of griefer cost. Ruled acceptable to ship devnet-with tonight because (a) the only actor who can do it is an already-trusted certifier or admin, who has strictly worse griefing options available anyway (deny every request outright, commit fraud), and (b) the correct fix — a new admin-only reclaim-tombstone instruction, deliberately not composable in the same transaction asreject— needed more time than was safe to spend at 3am without risking reopening the already-fixed same-transaction revival bug it's adjacent to. Design direction is written down; hand tosolana-architector a red-team pass before mainnet. - Anon SELECT on
certificatesreturnsowner_did(PII) to anyone.anon_select_certificates(supabase/migrations/0001_init.sql) isusing (true)with no column allowlist, so any anonymous PostgRESTselect("*")againstcertificates— not just the verify page's own narrower query — can readowner_did(the student's Privy DID) alongsideowner_walletandname_salt. Devnet-acceptable (no real student data on this cluster); a real PII exposure once mainnet holds real students. Fix: restrict the anon policy to a column-limited view (dropowner_did/owner_wallet/name_saltfrom the anon projection — the verify page only needs the public fields it already renders) before mainnet. Not attempted blind tonight; needs a live Supabase instance to test the RLS/view change against.
Explicitly deferred in M7 (documented rather than fixed — see the M7 report for the reasoning behind each):
- Revoked-certificate OG image:
generateMetadatacurrently omitsopenGraph.imagesentirely for a revoked certificate rather than pointing at a static "revoked" card — a share of a revoked cert's link today shows no image preview instead of a clearly-labeled revoked one. - Signer-table timestamps prefer the mirror over the chain: the verify
page's signer table shows
signedAtfrom the DB mirror (certificates.signer_txs), even on the same page whereVerifyChainStamphas already fetched the authoritative on-chainsig_timestampsfor its own verdict. Not wired through to the table yet.
Carried forward from earlier milestones (still true, not M7's to fix):
- Cross-platform render determinism unverified. The satori/resvg double-render byte-identity proof has only ever run on this one dev machine. Trust model note: the renderer never re-runs to verify an existing certificate (the on-chain hash is the permanent source of truth), so this only matters if a render host migration or a retried claim ever re-renders on a different platform. Pin the render host, or do a Linux smoke test, before any host migration.
- Live Privy -> role resolution and silent embedded-wallet batch
signing were both verified only by source-reading + a partial spike
(headless OTP can't complete; embedded batch-sign was inferred from the
SDK's documented behavior, not observed live). Covered by
WAKEUP.mdstep 4's manual pass. - CU-simulation uses a static fallback rather than an actual
simulateTransactionround trip before setting the compute budget (brief-permitted shortcut, M4). - The app hardcodes the devnet cluster in a few places rather than reading
it everywhere from
NEXT_PUBLIC_CLUSTER(M4).
Attendance NFTs (see .superpowers/sdd/2026-08-19-attendance-nft/):
- Mint idempotency (hardened 2026-08-20). The mint-succeeds-then-DB-write-fails
interleaving no longer double-mints:
app/api/attendance/claimsplits its catch soreleaseClaimruns only when the on-chain mint itself throws, andmarkClaimMintedWithRetryretries the DB write with backoff (logging a greppable[attendance:reconcile]line with claimId/txSig/wallet on exhaustion instead of releasing). Migration0003adds afailed -> mintedslot-retake trigger so even a released claim whose mint later lands keeps the counter honest. The certificate flow got the parallel fix:mintCertificateAssetwrites its idempotency event before the visibility wait, andgetMintedAssetFromEventsnow fails closed (throws retryable) instead of reading a DB error as "never minted". Still accepted: apendingreservation older than 90 seconds is treated as a crashed attempt and re-mintable even if merely slow — bounded to a duplicate collectible (~0.0001 SOL of operator-paid fees, never fund loss or wrong owner);scripts/admin/reserve-sweep.ts+integrity-report.tsdetect and reconcile it.
Deliberately out of scope tonight (per the plan's stretch/later list, not regressions):
- CSV pre-approval for names (the structural fix for impersonation, beyond tonight's sanitizer mitigation).
- Signature drawing pad (a rendered cursive font stands in tonight).
- Rust CPI mint via
mpl-core'sCpiBuilder(server-side Umi mint stands; this would close the "back-reference is the only rogue-mint defense" caveat by binding mint authority into the program itself). - Admin add/remove UI (env-configured allowlist + day-1 auto-registration stands in).
- Automated LGPD-erasure tooling (the manual runbook — null 2 columns, delete 2-3 storage objects, optionally revoke+burn — works today; no one-click admin action for it yet).
- Unrevoke policy — undecided, no instruction for it either way.
- Squads v4 as mainnet upgrade authority + moving the deployer key off the
server-readable path — see
WAKEUP.md"Custody" and "Before mainnet."
MIT — see LICENSE.