Boja: Architecture Recommendation
The executive recommendation, architectural invariants, and 5-stage delivery plan.
Date: 2026-07-16 | Status: architecture decision; Zero viability gates pending | Product shape: Discord-like chat + Skool-like courses/community + live rooms, with independent multi-server administration, billing and feature gating
1. Executive Recommendation
Build Boja as a new TypeScript monorepo on PostgreSQL, with:
- Two separate frontends: Expo / React Native for native and React DOM for web. Rendering code is strictly not shared; design tokens (colors first), theming, component semantics/variant vocabulary and business logic ARE shared.
- UI/theming: DTCG-format semantic tokens in
@app/design-tokens(DaisyUI-style vocabulary:base-100/200/300,primary+primary-content, accent, semantic status colors), compiled by Terrazzo → Tailwind v4@theme/[data-theme]CSS for web and a typedthemes.tsfor native. Web components: shadcn-style owned components on Base UI primitives. Native styling: Unistyles 3 (typed TS themes, zero-re-render runtime switching,updateThemefor per-server branding). - Rocicorp Zero for query-driven partial synchronization, with production-readiness proved through mandatory viability gates.
- Better Auth for identity, sessions, OAuth, passkeys and service JWTs.
- A shared pure-TypeScript domain core for permissions, entitlements, validation, course rules, message rules and capability decisions.
- A small headless
AppRuntimefacade for discoverable commands and process lifecycle — the spiritual successor to TRW'sclient.js, but not a mutable entity store. - PostgreSQL as the source of truth for product data.
- Shared-schema tenancy: every tenant-owned table carries
server_id. - Private Cloudflare R2 + Images/Workers/CDN for the files data/delivery plane.
- Cloudflare Stream for broadcast/VOD and self-hosted LiveKit self-hosted for interactive rooms, with LiveKit Egress sending RTMPS into Stream for recorded broadcasts.
- NMI + value.io billing under the custom TSYS deal, implemented as an in-repo service with an immutable double-entry ledger.
- pg-boss for durable jobs, pending one Bun runtime/load spike; Graphile Worker is the fallback.
- A transactional outbox for push, email, search indexing, moderation, webhook delivery and other side effects.
- Threads as channels and first-class bots backed by service principals, installation-scoped capability rows and webhook-first events.
- Elysia/OpenAPI from day one, with MCP and CLI clients generated from the same contracts.
- self-hosted production infrastructure, with Cloudflare enterprise services as first-class components.
Boja uses Rocicorp Zero for query-driven partial sync over PostgreSQL. Offline clients read cached state, resume instantly, and durably queue message sends through the message-only outbox.
Sync architecture
- Query-driven partial sync (best fit for unbounded history and jump-to-message).
- Instant reads from local data with automatic server hydration of missing windows.
- Shared TypeScript queries and mutators.
- Permission-filtered synced queries with server authority.
Zero accepts optimistic writes during its short connecting grace period and rejects new writes in disconnected, error and needs-auth. Message sends go through an app-owned durable outbox with stable nonce/dedupe receipts, including sends initiated while fully offline. Automatic retry lasts seven days, after which the entry becomes an editable unsent draft. All other commands require connectivity.
Product stance on "offline-first"
Boja's offline contract has three parts:
- Offline-readable: previously synced servers, channels, messages, courses and composer state render immediately without a network round trip.
- Instant resume: reconnect starts from durable Zero state and catches up without clearing valid local data or showing a blocking loader.
- Durable message sends: the message-only outbox persists and retries complete send commands with authoritative server receipts. Every other domain command requires connectivity, including lesson completion and rewards.
2. What TRW Teaches Us
TRW has solved almost every product feature, but its accumulated architecture shows what a greenfield system must avoid.
Chat primitives worth preserving
- ULID identifiers and a separate ordered
sort_idcursor. - Bidirectional
before,afterand two-sidednearbymessage queries. - Synthetic cursor fallback for deleted or non-message anchors.
- Explicit landing intents:
latest,last-read,selected-message. - Present-tail awareness: realtime appends only join a window that actually reaches present.
- Durable-shaped outbox state machine with retries, terminal/retryable classification and nonce dedupe.
- Threads implemented as channel-like conversations.
- Read markers represented as per-user/per-channel cursors.
- Attachments as first-class entities with blurhashes and generated variants.
- Warm channel retention through React
<Activity/>and per-channel runtime state.
Platform primitives worth preserving
- One central capability manifest and permission calculator.
- Layered role and channel allow/deny overrides.
- Course completion rewards that grant roles and unlock channels/courses.
- Stream chat implemented as an ordinary channel.
- Declarative billing-to-access rules.
- Server-authoritative event visibility.
- CDC/outbox ownership of side effects rather than ad-hoc handler emits.
What TRW makes expensive — The state sandwich
TRW chat currently crosses:
TanStack infinite cache
→ MobX entity maps
→ Jotai cache mirror
→ derived atoms
→ imperative ChatViewRuntime
→ React virtualized list
A message page is written into more than one state system. Resets must clear multiple layers in the correct order. The rewrite must have one persisted reactive product store: the sync engine's local database.
Hand-built partial sync
TRW already contains many sync-engine components: ready snapshots, replay cursors, a bounded event history, Redis stream replay, client entity maps, cache hydration, per-channel subscriptions, and recovery modes. The problem is binary validity: replay succeeds or caches are invalidated.
3. Target System Architecture
┌──────────────────────────┐ ┌──────────────────────────┐
│ Expo / React Native │ │ React DOM web app │
│ native components │ │ owned Base UI components │
└────────────┬─────────────┘ └────────────┬─────────────┘
└──────── shared contracts/domain/tokens ────────┘
│
Zero store + message-only durable local outbox
│
┌─────────────────────────────────▼─────────────────────────────────┐
│ Elysia application/API plane self-hosted │
│ Better Auth identity/session │ Zero query/mutate │ OpenAPI/admin │
│ shared pure-TS policy │ files control plane │ billing service│
│ generated MCP/CLI │ bojastack │ search/history │
└──────────────┬───────────────────┬───────────────────┬────────────┘
│ │ │
┌─────────▼─────────┐ ┌──────▼────────┐ ┌──────▼────────────┐
│ PostgreSQL self-hosted │ │ pg-boss/self-hosted infrastructure │ │ Ephemeral WS plane│
│ source of truth │ │ workers │ │ presence + typing │
│ server_id tenancy │ │ Graphile f/b │ │ Redis/Valkey TTL │
└─────────┬─────────┘ └──────┬────────┘ └───────────────────┘
│ outbox │ scan/process/deliver
┌────────▼─────────────────────▼──────────────────────────────┐
│ Cloudflare enterprise plane │
│ private R2 │ Images/Workers/CDN │ Stream broadcast + VOD │
└────────────────────────────────────────────────────────────┘
Interactive media: clients ↔ self-hosted LiveKit self-hosted;
LiveKit Egress → RTMPS → Cloudflare Stream for broadcast/recording.
Architectural invariants
- PostgreSQL is authoritative for product state.
- Zero is the only app-wide persisted reactive entity store; do not build a second replay/entity sync protocol.
- React components do not implement product authorization.
- Shared domain modules contain no React, DOM, RN, Zero, DB or network imports.
- Client capability decisions improve UX; server decisions enforce security.
- External billing is never called on login, sync-read or mutation authorization paths.
- Side effects are emitted transactionally through an outbox.
- Every retryable client command carries a stable idempotency key.
- Cold history is bounded and queryable; no client syncs an entire channel's lifetime.
- Ordinary network reconnect never clears valid local data. Destructive reset is an explicit, instrumented recovery path and it never deletes app-owned drafts or outbox storage.
4. TypeScript Monorepo and Shared Business Logic
Proposed structure
apps/
web/
native/
packages/
contracts/ # schemas, IDs, DTOs, errors
domain/
permissions/
entitlements/
chat/
servers/
courses/
streams/
billing/
zero-schema/ # Zero/Postgres schema and relationships
zero-queries/ # named shared query definitions
zero-mutators/ # shared optimistic mutation core
app-runtime/
react-bindings/
design-tokens/ # shared tokens/contracts; no rendering
ui-web/ # React DOM components
ui-native/ # React Native components
platform/
services/
api/
workers/
media/
The spiritual successor to client.js
TRW's big client is valuable because it gives developers a coherent vocabulary. Preserve that ergonomics:
app.chat.sendMessage(...)
app.chat.retryMessage(...)
app.servers.createChannel(...)
app.courses.completeLesson(...)
app.streams.join(...)
app.billing.openPortal(...)
But do not preserve class-owned entity maps or observability.
export class AppRuntime {
readonly chat: ChatCommands;
readonly servers: ServerCommands;
readonly courses: CourseCommands;
readonly streams: StreamCommands;
readonly billing: BillingCommands;
constructor(
readonly sync: AppSyncClient,
readonly platform: PlatformCapabilities,
readonly actor: ActorSession,
) {
this.chat = createChatCommands(sync, platform);
this.servers = createServerCommands(sync);
this.courses = createCourseCommands(sync);
this.streams = createStreamCommands(sync, platform);
this.billing = createBillingCommands(platform);
}
}
AppRuntime owns lifecycle and capabilities. It does not own Map<MessageId, Message>.
Pure domain policy
export const calculateChannelCapabilities = (
input: ChannelCapabilityInput,
): CapabilityDecisionSet => {
// deterministic, explicit inputs
};
The same function runs: in Web, in React Native, during optimistic mutation prediction, in the authoritative server mutation, and in fixture/property tests. The server supplies trusted identity and authoritative rows. The client result is never a security boundary.
5. Data Model
Use normalized Postgres tables. Every tenant-owned table carries server_id unless it is global or belongs through an unambiguous parent.
Identity and tenancy
users
accounts / sessions # Better Auth-owned or mapped
servers
server_memberships # PK (server_id, user_id)
roles # server-owned rows
membership_roles # PK (server_id, user_id, role_id)
channels
channel_role_overrides
channel_member_overrides # optional; add only if product requires
Chat
conversations/channels
messages
message_revisions # optional audit/edit history
message_reactions # PK (message_id, user_id, emoji)
thread_subscriptions
channel_read_states # PK (channel_id, user_id)
message_mentions # optional normalized mention index
attachments
message_attachments
message_send_receipts # server idempotency/application receipts
Message identity and ordering
Use client-generated UUIDv7 or ULID IDs and a stable ordered cursor. Keep id and sort_id conceptually separate even if they start equal.
Required unique constraint:
UNIQUE (channel_id, client_nonce)
This is non-negotiable. Client retries must not duplicate sends.
Replies and threads
- Use scalar
reply_to_message_id, not an array where only the first value matters. - A thread is a conversation/channel with
parent_channel_idandroot_message_id. - Thread permissions inherit from the parent, then apply explicit thread-level constraints.
- Thread subscriptions control notification preference, not read access.
Read state
One row per (user_id, channel_id):
last_read_sort_id
last_seen_sort_id
last_mention_sort_id or mention counters
updated_at
No per-user JSON map and no sharding workaround. Local updates should be immediate and coalesced naturally by the sync/write path.
Courses
course_spaces
course_sections
courses
course_modules
lessons
lesson_assets
course_enrollments
lesson_progress
course_progress
course_prerequisites
completion_rewards
content_versions / drafts
Keep TRW's strongest loop:
lesson/course completion
→ completion reward
→ role or entitlement grant
→ channel/course access changes
Model rewards as typed rows, not arbitrary JSON side effects.
Billing and entitlements
billing_events
payment_intents / provider_transactions
billing_subscriptions / subscription_items
ledger_accounts / journal_entries / postings
refunds / disputes
payouts / affiliate_commissions / reserve_movements
products
plans
plan_entitlements
entitlements / entitlement_grants
usage_counters / quota_periods
Separate: Permission (may this actor perform this action?), Entitlement (has this server/user acquired this product capability?), Feature rollout (should this code path be enabled for this cohort?). Do not collapse these into one flag system.
Live rooms
live_rooms
live_room_sessions
live_room_participants
live_room_roles
live_room_recordings
live_room_events
A live room references its ordinary chat channel. Provider IDs remain behind an adapter and never leak into domain field names.
6. Permission and Entitlement System
Capability manifest
export type Capability =
| "server.manage"
| "channel.view"
| "channel.manage"
| "message.send"
| "message.moderate"
| "thread.create"
| "course.view"
| "course.manage"
| "stream.join"
| "stream.start"
| "server.billing.manage";
Permission authority is stored as human-readable capability_key + effect rows, with effect equal to allow or deny. Roles remain the primary in-server grant mechanism and are applied in rank order.
Evaluation order
- Validate global account state.
- Validate server membership.
- Start from
server.default_permissions. - Apply each assigned server-role capability/effect set in rank order. Reordering roles can intentionally change the result; admin UI must warn before such changes.
- Apply applicable user-attribute capability/effect rows in configured order.
- Apply channel
default_permissions, then channel role overrides in rank order, then channel attribute overrides. - Apply the timeout/moderation mask (
ALLOW_IN_TIMEOUT) after ordinary grants. - Apply the narrowly defined owner/privileged bypass. It never bypasses account disable, ban, server suspension, legal hold, or capabilities excluded by typed bypass policy.
- Apply required product entitlements and quotas after permission grants; permissions cannot manufacture a paid entitlement.
- Return the result, user-safe reason and a structured server-only explanation trace.
export type CapabilityDecision = {
allowed: boolean;
reason:
| "allowed"
| "not-member"
| "role-denied"
| "channel-denied"
| "missing-entitlement"
| "timed-out"
| "server-suspended";
};
Sync visibility
Permission enforcement happens twice: query definitions only sync rows the actor may currently read; mutators independently authorize every write against authoritative rows. When access is revoked, the sync engine must remove inaccessible rows from the local replica. The spike must explicitly test this; merely hiding them in UI is not sufficient.
7. Chat Architecture: The Hardest Feature
7.1 Message-window model
The local database may contain several disjoint windows for a channel: recent live tail, a window around the first unread, a window around a selected permalink, and older windows loaded by scrolling. The UI window is a query over stored rows, not the query cache itself.
Coverage is derived from explicit Zero query facts:
Zero-exposed facts
query identity and arguments
query completion/status
returned rows and boundary cursors
App-owned local facts
navigation intent
persisted anchor message/cursor and pixel offset
transient coverage derived from the retained query result
7.2 Query primitives
recent(channelId, limit)
before(channelId, cursor, limit)
after(channelId, cursor, limit)
around(channelId, messageId or sortId, before, after)
byIds(messageIds)
threadRoot(threadId)
The around operation must preserve TRW's two-sided stitching behavior and support a missing/deleted hot anchor through a sort cursor. Archived rows are fetched through an authorized read-only history API into a distinct transient HistoryWindow; they are never promoted into fake Zero rows.
7.3 Landing intents
type ChannelEntryIntent =
| { type: "present" }
| { type: "first-unread"; sortId: SortId }
| { type: "selected-message"; messageId: MessageId };
Do not bury this choice in component effects. Route/navigation code computes the intent; the message feature executes it.
7.4 Present-tail semantics
- If viewing present, new rows append and optional follow-mode keeps the user pinned.
- If viewing history, new rows update unread/new-message affordances without changing the historical list anchor.
- "Return to present" switches query/window intent; it does not destroy channel state.
7.5 Virtualization and anchoring
Use platform-appropriate list implementations behind a shared feature interface. Share: item grouping, window/anchor model, entry intent, message rendering policy, follow-present state machine. Keep platform-specific: measurement, scroll commands, native keyboard handling, browser DOM behavior.
Required measures: reserve attachment dimensions from metadata before media loads; reserve embed space or animate controlled changes; restore using (anchorMessageId, offsetWithinItem), not raw scroll pixels.
7.6 React <Activity/>
Retain a bounded warm-channel pool, but use it only to preserve expensive presentation state: scroll anchor, text selection, composer focus, native/web list measurements. Do not keep channels alive because data would otherwise disappear. Data lives in the local sync database.
7.7 Sends and offline outbox
Every send includes:
message_id # client-generated
client_nonce # stable across retries
channel_id
content
reply_to_id
attachment_ids
created_at_client
States:
draft → queued → sending → acknowledged
↘ retrying
↘ rejected
For message send:
- Write the complete, schema-versioned command to encrypted local durable storage before invoking Zero.
- Render a local pending projection.
- On reconnect, submit through the authoritative Zero mutator. Never acknowledge or delete the outbox entry because the optimistic mutation resolved locally.
- In the same PostgreSQL transaction as the message, insert a
message_send_receiptsrow with unique(channel_id, client_nonce). - Acknowledge only after observing the authoritative receipt or canonical message. Keep acknowledged entries through a short compaction window.
- After any crash, retry. If the server already committed, the uniqueness conflict returns the existing canonical message as success.
- Show
queued on this devicewhile pending. Retry automatically for seven days, then convert the command to an editable unsent draft.
7.8 Attachments
Attachments are uploaded separately before the message command becomes sendable. Draft stores a stable local asset reference; upload waits for connectivity; message remains waiting-for-attachments; once uploads resolve to server attachment IDs, enqueue/send the message. Do not embed large blobs in the sync database.
7.9 Search
Search is a server feature, not a reason to sync all history. PostgreSQL full-text may be enough initially. Results return message IDs and context metadata. Selecting a hot result invokes Zero around(...); selecting an archived result opens an authorized read-only HistoryWindow.
8. Zero Viability Gates and Operating Requirements
Rocicorp Zero has been generally available and fully supported since March 2026; 1.0 is the first stable release. The following delivery risks are mandatory viability gates:
- React Native lifecycle and storage: choose and crash-test the native
kvStoreadapter across process death, suspension, upgrade, corruption and account switching. - Operational maturity: prove zero-cache topology, failover, replica rebuild, schema/publication migration, rolling upgrades, monitoring and disaster recovery under production-like load.
- Dataset headroom: measure the published replica and client working sets; enforce a physical hot/archive boundary rather than assuming query limits shrink the server replica.
- Query fan-out and history UX: prove hot tails, distant jumps, archive transitions, revocation and virtual-list anchors at seeded scale.
- Authentication: refresh Better Auth credentials without recreating same-user Zero storage, and meet bounded revocation targets.
- Write durability: prove the app-owned message-outbox crash matrix and authoritative receipt/idempotency protocol.
PostgreSQL foundation
Boja is a greenfield PostgreSQL system. Reasons: memberships, roles, channel overrides, entitlements, reactions and course progression are relational; transactions and uniqueness constraints are central to chat correctness; logical replication supplies Zero's authoritative change stream; Better Auth has a natural Postgres path; shared-schema server_id tenancy supports consistent migrations, authorization and cross-server users.
9. Authentication
Better Auth is the selected identity/session infrastructure, subject to mandatory security, upgrade and physical-device mobile integration release gates.
Better Auth owns
- User identity and linked social/provider accounts.
- Credential verification, sessions and revocation.
- OAuth state/PKCE and native SecureStore session cache.
- Optional passkeys, 2FA, SSO and SCIM.
- Short-lived JWT/JWKS for native sync and service authentication.
Application owns
- Servers and memberships.
- Invitations, owners and all membership lifecycle.
- Product roles, channel permissions, entitlements.
- Course enrollment, stream access, moderation and audit events.
- Sync visibility.
Better Auth Organization plugin rows are not canonical for product servers, memberships, invitations, owners or roles. App tables own those records and their lifecycle.
Startup behavior
- Open cached Better Auth identity and local sync database concurrently.
- Render the last navigation state immediately.
- Revalidate auth in the background.
- Refresh sync credentials without recreating the local database for the same user.
- On account switch, isolate by user ID and apply an explicit local-data deletion policy.
Never wrap the application in a global sessionPending spinner.
Security posture
- Use HttpOnly secure cookies on web.
- Use SecureStore/Keychain-backed native session storage.
- Do not store browser bearer tokens in
localStorage. - Keep service JWTs short-lived and rotate keys.
- Pin and continuously update all Better Auth packages and scoped plugins.
- Defer SSO/SCIM/OAuth-provider plugins until required.
10. Billing, Feature and Server Gating
Billing service boundary
Billing runs on NMI + value.io with the custom TSYS processor deal through an in-repo service. The service owns tokenized payment methods, payment/refund/dispute state machines, subscriptions, the versioned hu2-baseline dunning policy, 27-day account-updater eligibility, seller payouts, affiliate commissions/reserves, verified billing events, reconciliation and the immutable double-entry ledger. Boja is the default merchant of record.
Flow
NMI / value.io callback or reconciled billing source event
→ signature/authenticity verification
→ idempotent billing_events insert (source event id)
→ payment/commercial state + balanced ledger posting
→ subscription projection + transactional outbox
→ entitlement grants/revocations
→ PostgreSQL commit
→ Zero updates affected clients
No login or message query calls NMI/value.io.
Typed entitlement examples
type EntitlementKey =
| "server.max-members"
| "server.max-channels"
| "course.premium"
| "stream.host"
| "storage.bytes"
| "custom-branding";
Independent server billing
Support both: (1) platform subscription paid by the server owner, and (2) member access products sold by a server/community. These are different ledgers and entitlement flows. Do not force both into one subscription_status field.
11. Courses and Learning Community
Course catalog and progress are locally persisted and instant on reopen. Text and metadata remain available offline when previously synced. Video is streamed/downloaded according to product policy. Lesson completion and rewards are online-authoritative.
Courses may: require a role or entitlement, award roles or entitlements, unlock channels, trigger certificates, schedule cohort/live-room sessions. All of these are typed domain effects processed transactionally or through the outbox — not arbitrary client-side automation.
12. Live Streams and Calls
Use one product abstraction: Live Room. Room modes may include interactive call, stage/webinar, one-to-many broadcast, and audio room.
Use self-hosted LiveKit self-hosted for native and web SDKs, low-latency interactive rooms, participant permissions, and recording/egress options. Cloudflare Stream owns recorded broadcast and VOD. For broadcast mode, LiveKit Egress composites the room and sends RTMPS to Stream; the audience watches Stream HLS/DASH while speakers remain in LiveKit.
Sync only: room metadata, schedule and status, hosts and access policy, recording metadata, and chat channel relationship. Do not sync media packets, viewer heartbeats or high-frequency presence through the durable product database.
13. Instant-Start and Resumability Contract
Cold launch with local data
- App shell and last server/channel visible from local state without waiting for network.
- Cached messages visible immediately.
- Cached auth identity visible immediately.
- A subtle reconnecting indicator is allowed; a full-screen spinner is not.
Reconnect
- Ordinary network reconnect does not destroy valid Zero data.
- No forced navigation reset.
- New/changed rows apply incrementally.
- Removed permissions remove inaccessible rows.
- Pending message sends resume automatically.
- Historical scroll anchor stays stable while background catch-up occurs.
Schema incompatibility, client-store corruption, account switch, publication change or an engine-requested reset may invoke an explicit instrumented destructive recovery path. That path never deletes the separate app-owned drafts or message outbox.
Fresh install hydration priorities
- Auth/session.
- Server list and active server metadata.
- Active channel tail and unread state.
- Remaining navigation metadata.
- Media and secondary course content.
14. Delivery Stages
Stage 0 — Architecture spike
Do not build the full product before proving the hard part. Run the following narrow vertical slice as Zero viability gates:
- Better Auth login on web, iOS and Android.
- Elysia OpenAPI contract generation plus one shared Zero/REST command handler.
bojastackadmin-API shell with redaction andaccess explain.- Two servers, memberships and channel permissions.
- Channel list, recent message tail, send, optimistic acknowledgement and idempotency.
- Read marker, scroll older, jump around a message not present locally.
- Reconnect after short and long offline periods, including a message intentionally sent while already offline.
- Remove membership while device is offline, then reconnect.
- Cold app launch in airplane mode.
Seed enough data to exercise millions-row query behavior without syncing it all.
Required benchmark evidence
- Time to locally rendered shell.
- Time to cached channel view.
- Time to authoritative reconnect.
- Bytes and rows synced.
- Local storage growth.
- Scroll/jump latency.
- CPU/memory on mid-range mobile hardware.
- Permission revocation behavior.
- Offline message-send behavior.
- Operational complexity and failure recovery.
Stage 1 — Chat foundation
- Identity/session, servers, memberships, roles and channels.
- Capability calculator.
- Recent message sync, send/edit/delete/reactions.
- Replies and threads, first-class bot identity/service principals.
- Read state, mentions, attachments.
- Search + around-message navigation.
- Native/web message-list performance.
- Push notifications.
Stage 2 — Platform foundation
- Independent server administration.
- Billing projections and entitlements.
- Feature/server gates.
- Course hierarchy, progression, completion rewards, content authoring/versioning.
- Moderation and audit logs.
- Generated MCP and CLI clients over the versioned OpenAPI application layer.
Stage 3 — Live rooms
- Scheduled rooms.
- Self-hosted LiveKit interactive calls/stages self-hosted.
- LiveKit Egress to Cloudflare Stream for broadcast/recording.
- Recording lifecycle, room chat and moderation, presence/viewer counts.
Stage 4 — Scale and hardening
- Tenant partitioning/sharding strategy.
- Large-server fan-out benchmarks.
- Abuse and spam controls.
- Retention/export/deletion.
- Multi-region evaluation.
- Enterprise identity only when demanded.
15. Acceptance Criteria
Architecture
- No MobX.
- No second app-wide copy of sync-managed entities.
- Shared domain package has no framework imports.
- Native and web invoke the same policy and command modules.
- Server uses the same deterministic policy with authoritative data.
- Every durable message-send command and server-side outbox effect is idempotent.
Chat
- Recent channel opens from local data without a blocking loader.
- Distant jump does not destroy unrelated local message windows.
- History and present-tail modes do not contaminate each other.
- Prepends preserve visual anchor.
- Late media dimensions do not cause uncontrolled scroll jumps.
- Offline/queued bubbles have explicit, durable states.
- Server rejection is visible and recoverable.
Authorization
- Removing membership prevents future writes.
- Revoked data disappears from the local replica on reconnect.
- Channel role changes update access without full app reset.
- Billing outages do not call external systems on read/write paths.
- Entitlement changes propagate as ordinary authoritative data.
Resumability
- Cold offline launch renders cached state.
- Ordinary network reconnect never clears valid data; destructive recovery is explicit and instrumented.
- Destructive Zero recovery never deletes app-owned drafts or message-outbox records.
- Account switching cannot expose the previous user's local rows.
- Sync schema upgrade behavior is tested before release.
16. Major Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Zero has no general disconnected writes | Keep connectivity-required commands online-only; use the narrow app-owned write-ahead outbox for message sends |
| Zero maturity/operational risk | Production-like spike, pinned versions, failure drills, Zero-specific packages, Postgres authority, explicit export and documented recovery/migration boundaries |
| Permission-filtered sync leaks revoked rows | Explicit revocation tests; inspect local DB after access removal |
| Shared logic becomes a god package | Domain-owned packages, explicit inputs, no framework dependencies |
AppRuntime becomes MobX Client 2.0 | Ban entity maps and observability in runtime; lifecycle/commands only |
| Universal UI harms native quality | Keep ui-web and ui-native separate; share only tokens, contracts, semantic vocabulary and domain behavior |
| Chat virtualization remains janky | Dedicated performance milestone and real-device test corpus before feature expansion |
| Better Auth plugin churn/security | Minimal plugin surface, lockfile audits, aggressive patching, independent auth tests |
| Billing internals leak into authorization paths | Internal idempotent subscription and entitlement projections |
| Syncable dataset grows without bound | Measure both client working sets and the full published zero-cache replica; enforce a physical hot/archive publication boundary and authorized read-only history API |
17. Final Decision
Recommended stack to take into the spike
Language/runtime: TypeScript + Bun-compatible server runtime
Monorepo: Bun workspaces
Infrastructure: self-hosted production cluster + Cloudflare enterprise plane; other SaaS prototype-only except the tax API
Database/tenancy: PostgreSQL source of truth; shared schema with server_id
Client: Two frontends — Expo/React Native (native) + React DOM (web)
UI/theming: @app/design-tokens (DTCG + Terrazzo) → Tailwind v4/DaisyUI-vocab themes (web) + Unistyles 3 typed themes (native)
Sync: Rocicorp Zero
Offline: Cached reads + instant resume + durable message-send outbox only
Authentication: Better Auth for identity/session only; Boja owns servers, memberships, invitations and roles
Authorization: Shared pure-TS layered calculator over capability_key/effect rows; optional derived bitset cache
API/surfaces: Elysia OpenAPI; threads-as-channels; webhook-first service-principal bots; generated MCP/CLI; bojastack
Ephemeral realtime: Bun-compatible WebSocket gateway + Redis/Valkey TTL for presence/typing
Billing: In-repo NMI + value.io service; double-entry ledger; payouts/affiliates/reserves
Tax: Typed adapter to Anrok/Avalara candidate, vendor pending finance review
Files: Private R2 + Cloudflare Images/Workers/CDN + thin TS control plane
Video/live: Cloudflare Stream broadcast/VOD + self-hosted LiveKit self-hosted + Egress→Stream handoff
Background work: Transactional outbox + pg-boss pending Bun spike; Graphile Worker fallback
Search: Postgres initially; dedicated engine when justified
Recommendation confidence
- High confidence: TypeScript full stack, Postgres, app-owned authorization, selected Better Auth, shared pure domain core, one Zero-owned reactive store, transactional server outbox, normalized tenancy and chat schema.
- High confidence: Do not port TRW's MobX/entity-cache architecture.
- High confidence: Preserve TRW's cursor/nearby/outbox/entry-intent/thread/read-state lessons.
- Delivery confidence gated: Zero RN lifecycle, operations, published-dataset headroom, auth refresh and message-outbox crash safety must pass the viability gates.
Zero owns synced reactive data; the app-owned outbox owns pending message-send commands. Shared domain modules own meaning. AppRuntime owns commands and lifecycle. React owns rendering. PostgreSQL and the server own authority.
18. Research Documents
- 01-trw-chat-spec.md — TRW message model, pagination, jump, scrolling, outbox and lessons.
- 02-trw-platform-spec.md — realtime, permissions, courses, streams, auth and billing.
- 03-sync-engine-landscape.md — sync-engine landscape and historical alternatives.
- 04-better-auth-evaluation.md — Better Auth fit, boundaries and security caveats.
- 05-shared-typescript-domain-architecture.md — shared business-logic and
AppRuntimedesign. - 06-ui-theming-frameworks.md — DTCG/Terrazzo token pipeline and separate web/native implementations.