← Back to overview
Status

Status: architectural constraint for the greenfield recommendation  |  Recorded: 2026-07-16

Decision

Boja is TypeScript full stack. Business rules are shared across React Native and Web by default, with platform-specific code limited to rendering, navigation, device capabilities and platform lifecycle integration.

We want to preserve the best idea from TRW's app/src/client.js/: one cohesive, discoverable application-facing domain API containing entities, permissions, entitlements and operations. We do not want to preserve its MobX coupling or its role as a second mutable entity database.

With Zero, the correct division is:

Critical Constraint

The design must avoid copying Zero rows into MobX, Zustand, Redux, Jotai or class-owned maps. That creates dual sources of truth and reintroduces TRW's cache/event reconciliation burden.

What to Keep from TRW client.js

TRW's legacy client provides useful ergonomics:

Concrete examples include:

Architectural Boundaries Around client.js Lessons

Boja's application-facing domain API keeps these boundaries:

Zero already handles local normalized synced data, connected/short-glitch optimistic mutation and persistence. The message-only durable outbox is a separate command store, not an entity replica. Duplicating Zero rows into another store would force every Zero change to be mirrored and every optimistic rollback to be reconciled twice.

Proposed Package Architecture

apps/
  web/                       # React web shell
  native/                    # Expo / React Native shell

packages/
  contracts/                 # schemas, IDs, DTOs, discriminated unions
  domain/                    # pure product rules; no React, RN, DOM, DB or Zero
    permissions/
    entitlements/
    chat/
    courses/
    streams/
    servers/
    billing/
  zero-schema/               # Zero/Postgres schema and relationships
  zero-queries/              # named shared query definitions
  zero-mutators/             # shared optimistic mutation core
  app-runtime/               # small headless application facade over capabilities
  react-bindings/            # shared hooks/providers usable by native and web
  design-tokens/             # shared colors, themes, semantic tokens (no rendering code)
  ui-web/                    # web-only components (React DOM)
  ui-native/                 # native-only components (React Native)
  platform/                  # interfaces and native/web implementations

services/
  api/                       # TypeScript server query/mutate/auth endpoints
  workers/                   # outbox consumers, media, notification jobs

Avoid a generic shared/ dumping ground. Packages should declare what they own.

Layer 1: Contracts

Use plain, serializable and schema-validated records:

export type Channel = {
  id: ChannelId;
  serverId: ServerId;
  kind: "text" | "thread" | "announcement";
  name: string;
  parentChannelId: ChannelId | null;
};

export type Membership = {
  userId: UserId;
  serverId: ServerId;
  roleIds: readonly RoleId[];
  timedOutUntil: number | null;
};

Contracts are shared by clients and services, but they contain no framework behavior and no ambient runtime state.

Layer 2: Pure Domain Logic

Business rules should be deterministic functions over explicit inputs. They must run identically in native UI, web UI, optimistic mutations and server-authoritative mutations.

export type ChannelAccessInput = {
  actor: User;
  server: Server;
  membership: Membership | null;
  serverDefaultPermissions: readonly CapabilityEffect[];
  roles: readonly Role[];
  attributes: readonly UserAttribute[];
  channel: Channel;
  channelDefaultPermissions: PermissionOverride;
  channelRoleOverrides: readonly ChannelRoleOverride[];
  channelAttributeOverrides: readonly ChannelAttributeOverride[];
  moderation: ModerationState;
  ownerOrPrivileged: OwnerOrPrivilegedState;
  permissionConfig: PermissionConfiguration;
  entitlements: readonly Entitlement[];
  now: number;
};

export const calculateChannelCapabilities = (
  input: ChannelAccessInput,
): ChannelCapabilities => {
  // deterministic policy only
};

Rules in this package include:

Import Prohibition

Domain functions must not import React, Zero, Postgres, Better Auth, Expo, browser APIs, network clients or analytics.

One policy implementation, two trust levels

The client runs shared policy for immediate UX and optimistic behavior. The server runs the same policy with authoritative rows before committing.

Client permission results are hints, never a security boundary. The server supplies trusted actor identity and loads authoritative membership, server defaults, rank-ordered roles, user attributes, channel overrides, moderation, typed configuration and entitlement rows.

Layer 3: Zero as the Entity Store

Zero replaces TRW's Users, Members, Channels, Messages and related MobX collections.

Use:

Zero queries run immediately against local data and then synchronize authoritative results. Named query definitions can be shared between client and server, while the server adds trusted permission filters.

Example shape:

export const channelQueries = {
  detail: defineQuery(channelIdSchema, ({ args: channelId }) =>
    zql.channel
      .where("id", channelId)
      .related("server")
      .related("overrides")
  ),

  recentMessages: defineQuery(messageWindowSchema, ({ args }) =>
    zql.message
      .where("channelId", args.channelId)
      .where("sortId", "<=", args.before ?? MAX_SORT_ID)
      .orderBy("sortId", "desc")
      .limit(args.limit)
  ),
};

The server version of channelQueries.detail and recentMessages must filter by authoritative access. Query arguments are not credentials.

Layer 4: Shared Mutators and Commands

Zero explicitly supports sharing mutator logic between client and server. Use that for deterministic validation and row changes, then extend the server execution with authoritative authorization and outbox writes.

export const sendMessageArgs = schema.object({
  id: messageIdSchema,
  channelId: channelIdSchema,
  content: messageContentSchema,
  replyToId: messageIdSchema.nullable(),
});

export const sendMessage = defineMutator(
  sendMessageArgs,
  async ({ tx, args, ctx }) => {
    // Shared deterministic checks and optimistic row write.
  },
);

Server execution additionally:

  1. Resolves Better Auth identity.
  2. Loads authoritative channel, membership, server defaults, rank-ordered roles, user attributes, channel overrides, moderation/configuration and entitlements.
  3. Runs the shared capability policy.
  4. Performs the transactional write.
  5. Writes durable side effects to an outbox.
Important

Do not perform email, push, analytics, moderation APIs or media processing in the client-shared mutator. Server-only side effects belong behind an outbox/worker boundary.

Layer 5: The Headless AppRuntime

A class is appropriate for lifecycle and capability ownership, but not as another reactive database.

export class AppRuntime {
  readonly actor: ActorSession;
  readonly chat: ChatCommands;
  readonly servers: ServerCommands;
  readonly courses: CourseCommands;
  readonly billing: BillingCommands;

  constructor(
    readonly zero: AppZero,
    capabilities: RuntimeCapabilities,
  ) {
    this.actor = capabilities.actor;
    this.chat = createChatCommands(zero, capabilities);
    this.servers = createServerCommands(zero, capabilities);
    this.courses = createCourseCommands(zero, capabilities);
    this.billing = createBillingCommands(zero, capabilities);
  }
}

The runtime may own

The runtime must not own

This keeps the loved appClient.chat.sendMessage(...) / appClient.permissions... discoverability without making the class a god store.

Entity Ergonomics Without Mutable Entity Classes

Preferred default: records + selectors

const messageAuthor = (
  message: Message,
  usersById: ReadonlyMap<UserId, User>,
) => usersById.get(message.authorId) ?? null;

For React, named queries should materialize the relationships needed by the screen, reducing manual lookup bags.

Rich views when justified

If relationship-heavy operations genuinely benefit from an object API, use short-lived immutable views over a query snapshot:

export class MessageView {
  constructor(
    readonly data: Message,
    private readonly graph: MessageGraphSnapshot,
  ) {}

  get author(): User | null {
    return this.graph.usersById.get(this.data.authorId) ?? null;
  }

  get canEdit(): boolean {
    return canEditMessage(this.graph.editPolicyInput(this.data));
  }
}

Rules:

Start with functions. Add view classes only when they measurably improve a relationship-heavy domain API.

React Binding Strategy

Create shared hooks in packages/react-bindings that work in both React DOM and React Native:

export const useChannelCapabilities = (channelId: ChannelId) => {
  const [data, result] = useQuery(channelQueries.accessContext({ channelId }));
  const now = usePolicyClock();

  return {
    capabilities: data
      ? calculateChannelCapabilities({ ...data, now })
      : null,
    result,
  };
};

Hooks may compose Zero queries and pure domain logic. They should not duplicate the policy itself.

Use local component state or a minimal shared ephemeral store only for UI concerns such as: open composer draft UI, selected tab, modal state, local list anchor/measurement state, temporary recording state.

Durable drafts and local presentation state that must survive restart belong in explicitly owned local persistent tables. The only durable queued-action exception is the app-owned message-send outbox; it is not a generic command queue or an arbitrary UI store.

Native/Web Sharing Target

Share by default

Keep platform-specific

Key Constraint

Rendering is never shared between native and web. Sharing stops at tokens, theming, component semantics and logic.

Permission and Entitlement Architecture

Define one code-generated canonical capability registry rather than scattered booleans. The registry generates the namespaced TypeScript union, fixtures, admin labels and policy documentation:

export type Capability =
  | "channel.view"
  | "message.send"
  | "message.moderate"
  | "thread.create"
  | "stream.start"
  | "stream.join"
  | "course.manage"
  | "server.billing.manage";

export type CapabilityDecision = {
  allowed: boolean;
  reason:
    | "allowed"
    | "not-member"
    | "role-denied"
    | "channel-denied"
    | "missing-entitlement"
    | "timed-out"
    | "server-suspended";
};

The evaluation engine is one pure-TypeScript calculator. Defaults and overrides are human-readable capability_key + effect rows, where effect is allow or deny. It evaluates: server.default_permissions → server roles in rank order → applicable user attributes → channel default → channel roles in rank order → channel attributes → timeout mask (ALLOW_IN_TIMEOUT) → bounded owner/privileged bypass. Entitlements and hard quotas gate the result afterward. A bitset may be compiled as a derived performance cache, with the rows and manifest remaining authoritative.

Roles remain the primary in-server grant mechanism, and a role reorder changing the result is intentional. User attributes are a first-class cross-server input for subscription tiers, staff and other platform-wide facts. Free-tier IDs and attribute ordering come from typed data-driven configuration rather than magic constants.

The calculator accepts every record and configuration value explicitly and returns the effective capability set, explainable decisions and an optional server-only trace. It must not import a global singleton. Unknown manifest keys fail closed on the server. Channels belong to one server in v1. Property tests cover every allow/deny layer, cross-server attributes, timeout masks, bounded bypass, entitlement-after-permission ordering and role permutations; admin UI warns that reordering roles may change access.

Semantic Distinction

Permissions answer may this actor perform this action on this resource? Entitlements answer has this account/server purchased or been granted this product capability? Keep those concepts separate, then compose them in capability policy.

Failure Modes to Guard Against

  1. Dual stores: Zero rows copied into another reactive entity map.
  2. God runtime: every feature, event and UI concern placed on one class.
  3. Ambient policy: permission functions read global runtime state instead of explicit inputs.
  4. Client authority: shared logic mistaken for server authorization.
  5. Framework contamination: domain packages import React, Zero or platform APIs.
  6. Stale rich objects: long-lived entity classes retain old row snapshots.
  7. Over-sharing: native UX degraded to preserve identical web components.
  8. Server/client drift: separate permission or validation implementations.
  9. Query-as-credential: trusting client-supplied server/channel/user IDs without authenticated context.
  10. Side effects in optimistic mutators: notifications or external calls execute twice or offline.

Architecture Acceptance Tests

Final Architectural Statement

Build Boja's cohesive, typed application API: preserve a cohesive, typed and discoverable application API; preserve centralized entities, permissions and entitlement semantics; move all deterministic meaning into shared pure TypeScript modules; let Zero be the only persisted reactive entity store; use a small AppRuntime class only for lifecycle and commands; use React bindings for reactivity rather than putting observability into domain objects; run the same policy and mutation core on native, web and server, while keeping server identity and data authoritative.