Matching app โ technical spec (v4)
An AI-powered matching app. A server-side AI agent automatically pairs users and creates conversations. Users cannot initiate matches. The webapp covers messaging and profile editing. Agent management is deferred to the next spec iteration.
Deferred topics
The following areas are intentionally out of scope for this spec iteration.
| Topic | Deferred to | Attaches to |
|---|---|---|
Agent management โ hiring/firing agents, agent_instances lifecycle | Agent management spec | users table, matches.metadata |
| Matchmaking process โ how candidates are discovered, agent-to-agent evaluation flow | Matchmaking spec | conversations.type, balance_entries.activity_report_id |
| Matching service process boundary โ separate process vs in-process worker | Matchmaking spec | Architecture diagram, POST /internal/matches |
| No-match decision rules โ recorded against agent instance pair, not user pair | Agent management spec | agent_instances table, matches table |
1. Infrastructure decisions
- Database: PostgreSQL, accessed via
asyncpgwith SQLAlchemy 2.0 async sessions - Cache / pub-sub: Redis
- Object storage: S3-compatible (AWS S3 or Cloudflare R2)
- Payments: Stripe
- API server: Python 3.12+, FastAPI, served by Uvicorn (dev) / Gunicorn + Uvicorn workers (prod)
- WebSockets: FastAPI native WebSocket support (Starlette)
- Background jobs:
arq(async Redis Queue) - LLM integration: Anthropic or OpenAI Python SDK (both async-native)
- Client: React SPA
Async discipline: all code running on the event loop must be async-native. Blocking calls must be offloaded via asyncio.run_in_executor or pushed to background workers. A blocked event loop stalls all active WebSocket connections.
2. High-level features
Authentication
- Email + password registration and login
- Google and Facebook OAuth login
- JWT access tokens (15-min expiry) + httpOnly refresh tokens (30-day)
- Refresh token rotation with replay detection
- Rate limiting on all auth endpoints
- Update password (email+password accounts only)
- Delete account
User profile
- Display name, avatar (uploaded image), bio, timezone, status (emoji + text)
- Presence: Online / Away / Offline via heartbeat
- Other users' profiles are only visible to matched users
Matching (server-side only)
- AI agent running on the server decides pairings
- On match, server atomically creates a peer conversation, an agent conversation for A, and an agent conversation for B
- Agent sends an automatic intro message to each agent conversation on creation
- Agent conversations are strictly private per user
Messaging
- Users can only enter conversations created by the server
- Send, edit, delete messages (plain text + basic markdown)
- File and image attachments
- Emoji reactions
- Threaded replies (one level deep)
- Typing indicators, unread counts, mark as read
Notifications
- In-app unread badge counts
- Browser push notifications (Web Push API, opt-in)
- Email notification on new match
- Per-conversation mute
3. Architecture overview
Browser (React SPA)
โ
โโโ REST API (HTTPS) โ CRUD, auth, file uploads
โโโ WebSocket (WSS) โ real-time events
โ
API Server (Python / FastAPI)
โโโ Auth middleware (JWT)
โโโ REST handlers
โโโ WebSocket hub
โโโ Job queue producer
โโโ billing/ โ internal module; sole owner of balance mutations
โ โ
PostgreSQL Redis Matching service (boundary TBD)
(primary store) (presence, โโโ AI pairing logic
pub/sub, โโโ Creates matches via POST /internal/matches
unread,
rate limiting)
โ
Background workers (arq)
โโโ Agent intro message generation (LLM โ inserts message)
โโโ Agent read-loop (reads peer DM, maintains context)
โโโ Email / push notification delivery
4. Auth design
Token strategy
- Access token: signed JWT, 15-min expiry, contains
{ user_id, role, jti } - Refresh token: opaque random token (32 bytes, hex-encoded), stored hashed in DB, returned as
HttpOnly; Secure; SameSite=Strictcookie, 30-day expiry - Refresh token rotation: every
/auth/refreshcall issues a new token and invalidates the old one - Replay detection: if an already-used refresh token is presented, the entire token family is immediately invalidated
Password storage
- Hashed with argon2id (preferred) or bcrypt (cost factor โฅ 12)
- Never stored or logged in plaintext
Auth endpoint rate limiting
| Endpoint | Limit |
|---|---|
/auth/login | 10 attempts per IP per 15 minutes |
/auth/register | 5 attempts per IP per hour |
/auth/refresh | 30 attempts per IP per minute |
5. API contracts
Base URL: https://api.yuiva.ai/v1
Auth header: Authorization: Bearer <access_token> โ required on all endpoints except /auth/*.
Error format:
{
"error": {
"code": "CONVERSATION_NOT_FOUND",
"message": "Conversation conv_xyz does not exist",
"status": 404
}
}
5.1 Auth
POST /auth/register
// Request
{ "email": "user@example.com", "password": "...", "display_name": "Jane Doe" }
// Response 201
{
"user": { "id": "usr_...", "email": "...", "display_name": "Jane Doe", "role": "user" },
"access_token": "eyJ..."
// refresh_token set as HttpOnly cookie
}
POST /auth/login
// Request
{ "email": "...", "password": "..." }
// Response 200
{
"user": { "id": "...", "email": "...", "display_name": "...", "role": "user" },
"access_token": "eyJ..."
}
GET /auth/oauth/:provider
// provider: "google" | "facebook"
// Redirects browser to OAuth provider's consent screen
POST /auth/refresh
// No body โ reads HttpOnly refresh_token cookie
// Response 200
{ "access_token": "eyJ..." }
// New refresh_token set as HttpOnly cookie
POST /auth/logout
// Invalidates current refresh token โ Response 204
PATCH /auth/password
// Email+password accounts only
{ "current_password": "...", "new_password": "..." }
// Response 204 โ side effect: invalidates all refresh tokens
DELETE /auth/account
{ "password": "..." } // OAuth-only accounts omit this field
// Response 204 โ soft-deletes user row, hard-invalidates all sessions
5.2 Users / profile
GET /users/me
{
"id": "usr_...",
"email": "jane@example.com",
"display_name": "Jane Doe",
"avatar_url": "https://cdn.yuiva.ai/avatars/...",
"bio": "Product designer based in NYC",
"timezone": "America/New_York",
"status": { "emoji": "๐ฏ", "text": "Focused", "expires_at": null },
"presence": "online",
"role": "user"
}
PATCH /users/me
// Request (any subset)
{
"display_name": "Jane Smith",
"bio": "...",
"timezone": "America/Los_Angeles",
"status": { "emoji": "๐ด", "text": "On vacation", "expires_at": "2025-09-01T00:00:00Z" }
}
// Response 200 โ updated user object
GET /users/:user_id
Returns public profile. Requesting user must share an active match. Returns 403 if no match exists โ deliberately indistinguishable from 404 to avoid leaking user existence.
5.3 Conversations
Users cannot create conversations โ only the server creates them on match.
GET /conversations
{
"conversations": [{
"id": "conv_...",
"type": "peer", // "peer" | "agent"
"match_id": "match_...",
"participants": [{ "id": "usr_...", "display_name": "Bob Lee", "presence": "online", "role": "user" }],
"last_message": { "id": "msg_...", "user_id": "usr_...", "content": "Hey, got a sec?", "created_at": "..." },
"unread_count": 2,
"muted": false,
"created_at": "..."
}]
}
PATCH /conversations/:conversation_id
// Mute/unmute only
{ "muted": true }
// Response 200 โ updated conversation object
5.4 Messages
GET /conversations/:conversation_id/messages
// Query params: before=<message_id>, limit=50 (max 100)
{
"messages": [{
"id": "msg_...",
"conversation_id": "conv_...",
"user_id": "usr_...",
"content": "Hello!",
"content_type": "markdown",
"attachments": [{ "id": "att_...", "filename": "photo.jpg", "content_type": "image/jpeg", "url": "...", "size_bytes": 204800 }],
"reactions": { "๐": ["usr_abc"] },
"thread_count": 0,
"edited_at": null,
"created_at": "..."
}],
"has_more": true
}
POST /conversations/:conversation_id/messages
{
"content": "Help me write an icebreaker",
"content_type": "markdown",
"thread_parent_id": null,
"attachment_ids": ["att_..."]
}
// Response 201 โ message object
// 403 if not a participant or agent-authored
PATCH /messages/:message_id
{ "content": "Updated content" }
// Response 200 โ 403 if authored by agent or another user
DELETE /messages/:message_id
// Response 204 (soft delete)
GET /messages/:message_id/thread
{
"parent": { /* message object */ },
"replies": [ /* array of message objects, asc */ ]
}
5.5 Reactions
PUT /messages/:message_id/reactions/:emoji โ 200 { "reactions": {...} }
DELETE /messages/:message_id/reactions/:emoji โ 200 { "reactions": {...} }
5.6 File uploads
// POST /uploads โ Multipart form: file, conversation_id โ max 25 MB
// Response 201
{
"id": "att_...",
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size_bytes": 204800,
"url": "https://cdn.yuiva.ai/..."
}
5.7 Mark as read
// POST /conversations/:conversation_id/read
{ "last_read_message_id": "msg_..." }
// Response 204 โ pushes unread.updated WebSocket event to all user tabs
5.8 Matches
GET /matches โ { "matches": [{ "id": "match_...", "matched_with": {...}, "peer_conversation_id": "...", "agent_conversation_id": "...", "created_at": "..." }] }
GET /matches/:id โ single match object
5.9 Notification preferences
GET /users/me/notification-preferences โ { "push_enabled": true, "email_on_match": true }
PATCH /users/me/notification-preferences โ { "push_enabled": false }
POST /users/me/push-subscriptions โ 201
DELETE /users/me/push-subscriptions โ 204
5.10 Internal API (server โ server)
Protected by shared secret or mTLS โ no user JWT.
// POST /internal/matches
{
"user_a_id": "usr_...",
"user_b_id": "usr_...",
"match_metadata": { "compatibility_score": 0.91, "shared_interests": ["hiking", "design"] }
}
// Response 201
{
"match_id": "match_...",
"peer_conversation_id": "conv_...",
"agent_conversation_id_a": "conv_...",
"agent_conversation_id_b": "conv_..."
}
// Single DB transaction: create peer conv โ add participants โ create agent convs โ insert match row
// After commit: enqueue agent intro jobs, push match.created WebSocket event
6. WebSocket protocol
Connection: wss://api.yuiva.ai/ws?token=<access_token>. One connection per client tab. Server sends hello immediately on connect.
Client โ Server
{ "type": "subscribe", "conversation_id": "conv_..." }
{ "type": "unsubscribe", "conversation_id": "conv_..." }
{ "type": "heartbeat" }
{ "type": "typing_start", "conversation_id": "conv_..." }
{ "type": "typing_stop", "conversation_id": "conv_..." }
{ "type": "mark_read", "conversation_id": "conv_...", "last_read_message_id": "msg_..." }
Server โ Client
{ "type": "hello", "user_id": "usr_...", "server_time": "..." }
{ "type": "match.created",
"match": { "id": "match_...", "matched_with": {...}, "peer_conversation_id": "...", "agent_conversation_id": "..." } }
{ "type": "message.created", "conversation_id": "conv_...", "message": { ... } }
{ "type": "message.updated", "conversation_id": "conv_...", "message": { ... } }
{ "type": "message.deleted", "conversation_id": "conv_...", "message_id": "msg_..." }
{ "type": "reaction.updated", "conversation_id": "conv_...", "message_id": "msg_...", "reactions": {...} }
{ "type": "typing", "conversation_id": "conv_...", "user_id": "usr_...", "is_typing": true }
{ "type": "presence.changed", "user_id": "usr_...", "presence": "away" }
{ "type": "unread.updated", "conversation_id": "conv_...", "unread_count": 0 }
7. Database schema
Design notes
users.rolereplacesis_agent. Values:'user' | 'agent' | 'admin'. The Matchmaker AI bot is a user row withrole = 'agent'.conversations.typestays on the conversation, not the user. Describes the conversation's purpose and behavior.conversation_participantsis a junction table carrying per-user-per-conversation state (last_read_message_id,muted). Unread count is derived and cached in Redis.
users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
password_hash TEXT,
display_name TEXT NOT NULL,
avatar_url TEXT,
bio TEXT,
timezone TEXT DEFAULT 'UTC',
status_emoji TEXT,
status_text TEXT,
status_expires_at TIMESTAMPTZ,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('user', 'agent', 'admin')),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
oauth_accounts
CREATE TABLE oauth_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL CHECK (provider IN ('google', 'facebook')),
provider_uid TEXT NOT NULL,
email TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (provider, provider_uid)
);
refresh_tokens
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family_id UUID NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
conversations
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL CHECK (type IN ('peer', 'agent', 'agent_sync')),
match_id UUID,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
conversation_participants
CREATE TABLE conversation_participants (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_message_id UUID REFERENCES messages(id),
muted BOOLEAN NOT NULL DEFAULT FALSE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (conversation_id, user_id)
);
matches
CREATE TABLE matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_a_id UUID NOT NULL REFERENCES users(id),
user_b_id UUID NOT NULL REFERENCES users(id),
peer_conversation_id UUID NOT NULL REFERENCES conversations(id),
agent_conversation_a_id UUID NOT NULL REFERENCES conversations(id),
agent_conversation_b_id UUID NOT NULL REFERENCES conversations(id),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
messages
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
thread_parent_id UUID REFERENCES messages(id),
content TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'markdown' CHECK (content_type IN ('plain', 'markdown')),
reactions JSONB NOT NULL DEFAULT '{}',
thread_count INT NOT NULL DEFAULT 0,
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_messages_conv_time ON messages(conversation_id, created_at DESC) WHERE deleted_at IS NULL;
8. Redis usage
| Key pattern | TTL | Purpose |
|---|---|---|
presence:{user_id} | 45s | Refreshed every 30s by heartbeat |
typing:{conv_id}:{user_id} | 5s | Auto-expires if stop event is missed |
unread:{user_id}:{conv_id} | โ | Cached unread count for badge reads |
rate:auth:{ip} | 15min / 1hr | Auth endpoint rate limiting |
rate:msg:{user_id} | 60s | Message send rate limiting |
Pub/sub channels: conv:{conversation_id} (message, reaction, typing) ยท user:{user_id} (match.created, presence, unread.updated)
9. Open questions
- Unmatching โ can users unmatch? Does the peer conversation and both agent conversations close?
- Multiple simultaneous matches โ can a user be in several active matches at once, or one at a time?
- OAuth account linking โ if a user registers with email then logs in with Google using the same email, do we auto-link or prompt?
- Message retention โ retain indefinitely or enforce a rolling deletion window?
- Matching service process boundary โ separate process or in-process
arqworker?
10. In-app currency (CMD) and payments
CMD is the in-app currency users purchase with real money and spend on agent actions. Every meaningful agent action deducts CMD and writes an activity report.
| Table | Purpose |
|---|---|
balances | Current CMD balance per user |
balance_entries | Immutable append-only ledger of every balance movement |
transactions | Financial events with Stripe context and status lifecycle |
transactions balance_entries balances
(financial event) โโโโบ (ledger effect) โโโโบ (current state)
Stripe charge pending [no entry yet] [unchanged]
Stripe charge succeeds credit +500 CMD balance: 1500
Agent action fires debit -10 CMD balance: 1490
10.2 Stripe integration
| Stripe object | Purpose |
|---|---|
Customer | One per user. Stored as stripe_customer_id. |
PaymentMethod | User's saved card. We store the Stripe ID only โ no card numbers. |
PaymentIntent | One per CMD purchase. Handles charge, retries, SCA/3DS. |
SetupIntent | Used to save a card without an immediate charge. |
10.4 Database schema
balances
CREATE TABLE balances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'cmd' CHECK (type IN ('cmd')),
amount INT NOT NULL DEFAULT 0 CHECK (amount >= 0),
lifetime_credited INT NOT NULL DEFAULT 0,
lifetime_debited INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, type)
);
transactions
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
type TEXT NOT NULL CHECK (type IN ('purchase', 'auto_reload', 'refund', 'adjustment')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed')),
cmd_amount INT NOT NULL CHECK (cmd_amount > 0),
real_amount_cents INT,
currency TEXT DEFAULT 'usd',
provider TEXT CHECK (provider IN ('stripe')),
provider_payment_intent_id TEXT UNIQUE,
provider_event_id TEXT UNIQUE,
client_idempotency_key TEXT,
is_auto_reload BOOLEAN NOT NULL DEFAULT FALSE,
payment_method_id UUID REFERENCES payment_methods(id),
failed_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (user_id, client_idempotency_key)
);
11. ACID guarantees, atomicity, and idempotency
CMD deduction (atomic pattern)
BEGIN;
SELECT id, amount FROM balances WHERE user_id = $user_id AND type = 'cmd' FOR UPDATE;
-- If amount < cost: ROLLBACK, block agent action
UPDATE balances SET amount = amount - $cost, lifetime_debited = lifetime_debited + $cost, updated_at = NOW()
WHERE user_id = $user_id AND type = 'cmd';
INSERT INTO balance_entries (balance_id, user_id, operation, amount, balance_before, balance_after, description)
VALUES ($balance_id, $user_id, 'debit', $cost, $amount, $amount - $cost, $description);
COMMIT;
-- After commit: check threshold, enqueue auto-reload if needed
Race condition inventory
| Scenario | Risk | Mitigation |
|---|---|---|
| Two agent actions fire simultaneously for the same user | Both pass balance check, balance goes negative | SELECT FOR UPDATE serializes deductions |
| Client taps "Buy CMD" twice | Two Stripe charges | client_idempotency_key UNIQUE per user |
| Server crashes after inserting transaction, before calling Stripe | Dangling pending transaction | Recovery job detects stale pending transactions |
| Stripe retries webhook after our 5xx | Duplicate credit | provider_event_id UNIQUE constraint |
| Two concurrent agent actions both trigger auto-reload | Two Stripe charges for the same reload | ON CONFLICT DO NOTHING on auto_reload transaction insert |
12. State machine definitions
12.1 transactions.status
โโโโโโโโโโโ
(created)โ pending โ
โโโโโโฌโโโโโ
โ
โโโโโโโโโโโดโโโโโโโโโโโ
โผ โผ
โโโโโโโโโโโโโ โโโโโโโโโโ
โ completed โ โ failed โ
โโโโโโโโโโโโโ โโโโโโโโโโ
Forbidden: completed โ pending, completed โ failed, failed โ pending, failed โ completed.
12.2 conversations.status
โโโโโโโโโโ
โ active โ
โโโโโฌโโโโโ
โ match decision or unmatch
โผ
โโโโโโโโโโ
โ closed โ (terminal โ cannot reopen)
โโโโโโโโโโ
Closed conversations are read-only. POST /conversations/:id/messages returns 409 Conflict.
12.3 users.deleted_at
null โ <timestamp> (terminal)
Cascading: refresh_tokens hard-deleted, push_subscriptions hard-deleted,
cmd_auto_reload.enabled = false
12.6 Summary
| Table | Column | States | Reversible? |
|---|---|---|---|
transactions | status | pending โ completed | failed | No |
refresh_tokens | used_at | null โ <timestamp> | No |
cmd_auto_reload | enabled | false โ true | Yes (user-controlled) |
users | deleted_at | null โ <timestamp> | No |
conversations | status | active โ closed | No |