Production Readiness
Read this before you deploy. It is a checklist of the things that are your responsibility, the things that will bite you, and the things toda deliberately does not do.
Secrets
| Secret | Consequence of disclosure |
|---|---|
MASTER_ENCRYPTION_KEY | All memory plaintext at rest is recoverable |
JWT_SECRET | Sessions forgeable for any user ⇒ all plaintext via the API |
authority-keypair.json | Anchoring can be stalled or misdirected. No plaintext exposure |
| A user's wallet key | Full takeover of that user, and of their stake vault |
Load them through a secrets manager. @toda/config exposes a SecretProvider seam for exactly this. Every value is validated at startup and the process fails fast on a missing or malformed one - a toda that starts is a toda that is configured.
Rotating MASTER_ENCRYPTION_KEY invalidates every wrapped_data_key and renders every memory undecryptable. The envelope design makes rotation tractable - unwrap each data key under the old KEK, rewrap under the new, touch no memory ciphertext - but the migration does not exist. Treat the master key as a value you must never lose and never leak.
Never commit authority-keypair.json. It is gitignored. Verify that before your first push.
The anchor service
Run exactly one instance. The nonce guard makes concurrent instances safe but useless - each extra one loses the race, marks a batch failed, and burns a transaction fee learning that.
Keep the authority funded. An unfunded authority means anchoring silently stops while writes continue to succeed. Memories accumulate as anchored: false, the tip goes stale, and nothing surfaces the failure except the logs. Alert on the balance.
Monitor these:
| Signal | Meaning |
|---|---|
| Authority SOL balance | Below a few hundred anchors' worth ⇒ anchoring is about to stop |
Age of the oldest anchored: false memory | Should never exceed one sweep interval plus confirmation time |
Count of commit_batches in failed | Sustained growth ⇒ RPC trouble or a nonce war |
Count of commit_batches stuck in submitted | Confirmation timeouts. Usually benign - the next rolling root subsumes them |
| Sweep duration | Approaching ANCHOR_INTERVAL_MS ⇒ you are falling behind |
| Tree save conflicts | Should be zero with one instance. Non-zero ⇒ you are running two |
RPC
The anchor service reads the on-chain nonce before every commit, then confirms every transaction. A public endpoint will rate-limit you.
Use an endpoint you control or pay for. A flaky RPC does not corrupt anything - reads that fail are treated as unknown, never as zero - but it does stall anchoring, and on the staking path it turns POST /staking/unstake into a 503.
Database
Postgres is the only stateful component, and the commitment on-chain proves nothing you can no longer produce. Back it up.
users wrapped data keys - losing these loses all plaintext
memories ciphertext, IVs, leaf hashes, leaf indices
merkle_trees persisted incremental trees (regenerable, but O(n) to rebuild)
commit_batches the anchoring audit trail
siws_nonces ephemeral
api_keys hashes only
stake_lots maturity timing cache (the chain is the balance)
merkle_trees is a cache - the anchor service rebuilds a missing or drifted tree from the leaves, which is the one O(n) path and self-heals corruption. users.wrapped_data_key is not a cache. Losing a row means that user's memories are permanently undecryptable.
Rate limiting
Neither API throttles. There is no per-user quota, no request cap, and no write limit.
Deploy behind a gateway that provides them. Pay particular attention to:
POST /auth/challenge- writes a row per call. Rate-limit by IP.POST /memories- unbounded write growth per user.GET /agent-cases/:token- the audit table grows per attempt, including failures.POST /memories/search- decrypts the caller's entire log on every call.
That last one is the scaling wall. Recall is O(log size) in decryptions per query because memory bodies are encrypted and cannot be indexed. It is bounded to one user's log, but a user with 100 000 memories will feel it. The mitigation is upstream: ingest() exists to keep logs small and dense - atomic durable facts, not conversation transcripts.
TLS and CORS
TLS is your deployment's job. toda does not terminate it.
The core API's CORS reflects any origin (origin: (o) => o ?? "*"). Restrict it. The extension API is stricter - it accepts any chrome-extension:// origin plus a configured allowlist - because it must, and because the connect flow refuses any callback that is not a chrome-extension:// URL.
Cluster
Devnet resets. Commitments anchored there are not permanent and are not evidence of anything durable.
Before mainnet:
- Deploy
toda-commit; setTODA_PROGRAM_ID. - Point
SOLANA_RPC_URLat a paid mainnet endpoint. - Set
SIWS_CHAIN_ID=mainnetso the message users sign names the right cluster. - Fund and monitor the authority.
A commitment account is a PDA under a specific program on a specific cluster. Moving clusters starts a new log. The devnet tip means nothing on mainnet.
Known limits
State these to your users, because they will discover them.
A memory is not provable immediately. verify() returns false until the seal policy fires and the batch confirms - up to 5 minutes plus confirmation, for a low-volume user.
verify() compares against the current tip. It returns true only for memories anchored under the most recent root. Older memories are still committed; proving them requires folding their proof to the current root rather than comparing batchRoot to the tip. See anchored under an older root.
verify() trusts the API for the on-chain root. It is a convenience, not a third-party-presentable verification. Fetch the tip from your own RPC.
Recall is substring matching, not semantic search. Pgvector-backed retrieval would require either decrypting into an index or searchable encryption, each with its own leakage profile.
Metadata is public to anyone with database access, and is hashed into the leaf.
toda can read your memories. Encrypted at rest, not end-to-end. If the operator is in your threat model, encrypt content before you call remember(), and accept that recall() and ingest() stop working.
Anchoring leaks timing and volume. A chain observer learns when a wallet's log grew and by how many memories. Never what they say.
No non-inclusion proofs. You can prove a memory is in a log. You cannot prove one is absent, and toda cannot prove a log is complete.
Deletion is detectable, not prevented. Removing a row removes your ability to produce the memory. The commitment still acknowledges it, and memory_count is monotonic on-chain, so a shortened log cannot be anchored.
Testing before you trust
bun test # the workspace
bun test programs/toda-staking/tests/ # 12 tests, LiteSVM, needs anchor build first
programs/toda-commit/tests/ is currently test.todo scaffolds - five todos, zero assertions. It documents the intended contract (nonce, count, and signer rejection; TS↔Rust root byte-equality) without exercising the program. The invariants it names are real and are enforced in lib.rs; they are simply not covered by an executing test. Do not read a green suite as coverage of the commitment program.
The property test asserting that the incremental tree's root equals computeRoot() at every size does execute, and it is the single most important test in the repository - it is the invariant that keeps every anchored root and every issued proof simultaneously valid.
Checklist
[ ] MASTER_ENCRYPTION_KEY and JWT_SECRET generated fresh, in a secrets manager
[ ] authority-keypair.json gitignored, funded, alerted on
[ ] Exactly one anchor service instance
[ ] Paid Solana RPC endpoint
[ ] SIWS_DOMAIN / SIWS_URI / SIWS_CHAIN_ID match the deployment
[ ] Postgres backed up; users.wrapped_data_key included
[ ] Rate limiting in front of both APIs
[ ] CORS restricted on the core API
[ ] TLS terminated
[ ] Alerts: authority balance, oldest unanchored memory, failed batches
[ ] Users told: Toda can read their memories