A summary of how explAIn is built — the stack, the message lifecycle, the data model, and the edge functions. Full schema detail lives in docs/architecture.md in the main repo.

Stack at a glance

LayerChoiceWhy
ClientExpo + Expo Router + React Native + NativeWindSingle codebase for iOS, Android, and Web
StateZustand + TanStack QueryLightweight; TanStack handles server cache and optimistic updates
BackendSupabase (Auth, Postgres, Realtime, Edge Functions)One vendor for auth, DB, realtime, and serverless
LLM routingVercel AI SDK v5 (@ai-sdk/deepseek in v0.1; Anthropic/OpenAI/Google scaffolded)Unified streaming plus a per-call usage object that feeds the token ledger
AuthSupabase Auth — email magic-linkNo password to manage in v0.1; OAuth deferred
Build / CITurborepo + GitHub ActionsStandard across the owner’s repos

Message lifecycle

[A types]                 -> draft (client-local only)
   v debounce 600ms
[Advisor-A streams]       -> advisor_suggestion (visible to A only)
   v A picks: original | suggestion | edited blend
[A sends]                 -> POST /functions/advise (logs call) -> POST /functions/mediate
                            persists messages row, status='pending_mediator'
[Mediator streams]        -> mediator_output + mediator_action (visible to BOTH)
                            status -> 'delivered' (or 'blocked' for Judge)
[Realtime broadcast]      -> B receives the full row
[B's Advisor-B]           -> reads incoming, optionally pre-drafts a reply

The advisor runs privately during typing; the mediator runs after send and streams to both parties simultaneously. This split is the architectural form of the transparency contract — the advisor’s stream is owner-only, the mediator’s stream is shared.

Data model (key tables)

  • conversationspersona_default, chronicle_mode (shared | per_user), judge_charter (only used for Judge).
  • conversation_participants — composite PK on (conversation_id, user_id), a role of active | observer, with a cap trigger enforcing up to 3 active and up to 10 observers per conversation.
  • messages — the central ledger. Carries original_draft, advisor_suggestion, sent_text, mediator_persona, mediator_output, mediator_action (passthrough | annotate | narrate | external in v0.1), mediator_reasoning, final_rendered, status, plus per-message token/cost columns. sender_id is nullable for DM-injected messages.
  • conversation_events — drives the “logged as event” requirement (e.g. persona_switch, charter_update, participant_joined).
  • llm_calls — one row per advisor or mediator invocation; provider, model, tokens, cost, latency. The basis for the future paid quota and usage analytics.
  • chronicles — Chronicler persona only; chapter summaries with a message range and optional owner_id for per-user mode.

Message provenance

Not every messages row is mediator-authored. The kind column disambiguates:

  • kind='user' rows carry the full transparency ledger; the mediator actually ran.
  • kind='system' rows (membership notices, proposal resolutions) are structural and borrow mediator_action='passthrough'.
  • kind='external_quote' rows (a friend’s response to a cross-chat @-tag) use mediator_action='external' and sender_id=null; the mediator never sees that text. Preferring 'external' over 'passthrough' keeps mediator decisions and structural insertions distinct in any audit query.

Observers are observer-free by construction

Observers can read freely but can never write to messages — both RLS and the edge-function gate deny it. Because every LLM-call edge function builds its prompt from messages.sender_id (or from the writer’s own draft for the advisor), and observers can never produce a messages row, their identities are excluded from every prompt by construction rather than by an explicit filter. Formatter-contract unit tests pin the “look up by sender_id, do not enumerate the roster” property so a future refactor cannot regress it.

Edge functions

  • /functions/advise — streams an advisor suggestion (SSE), records to llm_calls.
  • /functions/mediate — runs after send, routes to the active persona’s behavior module, streams to both participants, updates the messages row.
  • /functions/chronicle-tick — cron- or count-triggered; generates the next Chronicler chapter.
  • /functions/dm-tick — cron- or pacing-triggered; injects a DM narrative message.

Realtime channels

  • conversation:{id}:messages — table changes for the conversation.
  • conversation:{id}:presence — online and typing state.
  • message:{id}:mediator — token-by-token mediator streaming to both parties.
  • user:{id}:advisor:{draft_id} — owner-only advisor stream.
  • user:{id}:pending_notifications — drives the in-app notification banner.

The provider abstraction (packages/llm)

A single entry point — streamLLM({ provider, model, messages, onUsage }) — wraps Vercel AI SDK v5’s streamText for each adapter. The onUsage callback fires on completion with { tokensIn, tokensOut, costUsd }, destined for the llm_calls ledger. Provider keys come from one getProviderKey(provider, userId?) helper: server env in hosted mode, a user_keys table in BYO mode. No framework imports beyond the AI SDK and Zod, so the package lifts cleanly into the future open-source variant.

Token accounting

Every LLM call writes one llm_calls row. The daily aggregate per user powers a soft dogfood cap (env-configurable in v0.1), the future paid quota, and analytics such as advisor-acceptance rate and mediator latency.