Skip to main content

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

SecretConsequence of disclosure
MASTER_ENCRYPTION_KEYAll memory plaintext at rest is recoverable
JWT_SECRETSessions forgeable for any user ⇒ all plaintext via the API
authority-keypair.jsonAnchoring can be stalled or misdirected. No plaintext exposure
A user's wallet keyFull 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.

There is no key rotation

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:

SignalMeaning
Authority SOL balanceBelow a few hundred anchors' worth ⇒ anchoring is about to stop
Age of the oldest anchored: false memoryShould never exceed one sweep interval plus confirmation time
Count of commit_batches in failedSustained growth ⇒ RPC trouble or a nonce war
Count of commit_batches stuck in submittedConfirmation timeouts. Usually benign - the next rolling root subsumes them
Sweep durationApproaching ANCHOR_INTERVAL_MS ⇒ you are falling behind
Tree save conflictsShould 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:

  1. Deploy toda-commit; set TODA_PROGRAM_ID.
  2. Point SOLANA_RPC_URL at a paid mainnet endpoint.
  3. Set SIWS_CHAIN_ID=mainnet so the message users sign names the right cluster.
  4. Fund and monitor the authority.
Commitments do not migrate

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