Skip to content

Database

PostgreSQL 17. Migrations live in backend/migrations, and migrate() applies them in version order at server startup. The applied history is kept in umul_internal.schema_migrations as version, name, and SHA-256 checksum. If the content of an already-applied file changes, startup aborts with MIGRATION_CHECKSUM_MISMATCH.

What is never stored

  • Ordinary message plaintext
  • XMTP decryption keys and wallet private keys
  • Session token and login nonce plaintext (only SHA-256 hashes are stored)
  • Evidence archive plaintext and content keys (only client-locked ciphertext and wrapped keys are kept, and the server can open neither)

001_auth.sql

wallet_login_nonces

Column Description
id uuid primary key
address Lowercase wallet address. CHECK (address = lower(address))
nonce_hash SHA-256 of the nonce. UNIQUE
expires_at 5 minutes after issuance
consumed_at Time of use. The basis for single-use guarantees

sessions

Column Description
id uuid primary key
address Lowercase wallet address
token_hash SHA-256 of the session token. UNIQUE
expires_at 7 days after issuance
revoked_at Sign-out time

Consuming a nonce and creating a session happen atomically in a single CTE statement. Concurrent requests consume it exactly once.

002_evidence.sql

evidence_requests

Column Description
id uuid primary key
idempotency_key UNIQUE. Blocks duplicate creation
evidence_id 32 bytes. UNIQUE
requester_address Lowercase address
content_hash, conversation_id_hash, participants_hash 32 bytes each
manifest_version Hash rule version
message_count At least 1
conversation_started_at, conversation_ended_at End time must be at or after start
status CHECK allowing only the 8 states
transaction_hash 32 bytes, UNIQUE, nullable
block_number Non-negative, nullable
rejected_at Rejection time, nullable
expires_at 1 hour after creation. CHECK (expires_at > created_at)
created_at Creation time

evidence_participants

Composite primary key (request_id, address). signature is 65 bytes, and a CHECK requires signature and signed_at to be either both NULL or both set. There is an index for address lookups.

003_evidence_metadata.sql

Adds the evidence name and the IDs of the messages being evidenced. Both are off-chain metadata that never reach the chain. The contract accepts only contentHash, so renaming leaves the on-chain record and the signatures unaffected.

Column Table Description
name evidence_requests Evidence name. Nullable. CHECK (length BETWEEN 1 AND 100)
message_refs evidence_requests Array of XMTP message IDs (text[]). Carries no message body
saved_at evidence_participants Saved (favourited). Per-participant state, so one person saving does not affect anyone else's list

message_refs holds the same values delivered to participants in the evidence proposal control message. The detail screen uses these IDs to find the bodies in each participant's own local MLS DB.

004_evidence_metadata_integrity.sql

Closes the consistency gap 003 left behind. message_count is recorded on the contract and feeds the signed digest, while message_refs is what the screen uses to retrieve bodies. If the two disagree, the screen says "12 messages" while showing 3.

Constraint / index Content
evidence_requests_message_refs_match_count cardinality(message_refs) = message_count
evidence_requests_message_refs_not_empty cardinality(message_refs) > 0
evidence_requests_created_at_idx (created_at DESC). The collected view reads "saved first, then newest"

Rows predating 003 have a NULL message_refs and are exempt from both CHECKs.

005_evidence_archive.sql

evidence_archives

Client-side ciphertext of the evidenced plaintext (AES-256-GCM, iv 12 + body + tag 16). request_id is the primary key, so there is one row per evidence, written exactly once. A CHECK bounds the length between 29 bytes and 2 MB.

evidence_archive_keys

The wrapped content key per signing participant. The (request_id, address) primary key carries a composite foreign key to evidence_participants, which blocks rows for addresses that are not participants. A CHECK requires wrapped_key to be exactly 60 bytes (iv 12 + key 32 + tag 16). The application never creates rows for participants who did not sign.

006_evidence_content.sql

No code uses the objects this migration creates yet. They exist in the schema so they are documented here, but actual evidence-plaintext storage goes through 005's evidence_archives / evidence_archive_keys. Whether this is meant to replace 005 or serve a separate purpose is undecided.

evidence_contents

Column Description
request_id Primary key referencing evidence_requests. ON DELETE CASCADE
ciphertext Result of the client's AES-256-GCM lock. Between 1 byte and 1 MB
iv Exactly 12 bytes
algorithm Only 'AES-GCM-256' allowed

Exceeding 1 MB means a whole conversation was included, so the ceiling signals that the evidence range should be narrowed.

evidence_participants extension

Participants upload their share of the key when they sign. A participant who did not sign has no key and cannot open the body.

Column Description
wrapped_key Exactly 48 bytes (CEK 32 + GCM tag 16)
wrapped_key_iv Exactly 12 bytes

A CHECK requires both to be NULL together or set together. Unlike 005's evidence_archive_keys.wrapped_key, which is 60 bytes with the iv inline, this splits the iv into its own column.

Key derivation does not use the EIP-712 multi-signature (evidence_participants.signature). That value is stored here and is also public as on-chain calldata, so deriving a key from it would let the server decrypt. Derivation uses a separate phrase that is never transmitted.

Concurrency

Every state transition goes through PgEvidenceRepository.updateLocked. Inside a transaction it locks the row with SELECT ... FOR UPDATE, lets the aggregate decide the transition, then saves. If the aggregate rejects the transition, the whole transaction rolls back so no partial write remains.

Two participants signing simultaneously both succeed and the final state is READY. The same participant signing twice simultaneously succeeds once; the other fails with EVIDENCE_SIGNATURE_ALREADY_SUBMITTED.

Test isolation

Integration tests reset the schema, so only databases named umul_test or prefixed umul_test_ are permitted. Any other name aborts with UNSAFE_TEST_DATABASE, and even on abort every open connection pool is closed. The development database umul and the test database umul_test run in separate containers (5432 / 5433).