Design philosophy
The structural choices that make SOMA coherent, and the reasons behind them.
Four principles shape every decision in the codebase. When a new feature doesn't fit, we re-examine the feature before we re-examine the principle.
One table per kind, not per noun
Books, workouts, people, projects, habits, notes — all rows in entities, discriminated by type. One jsonb properties column holds the per-type shape, validated by a Zod schema in @soma/core.
Why. The alternative — a books table, a workouts table, a people table — breeds ORM complexity and N-way joins the moment you want cross-domain queries ("events this week that involve Lena and mention my marathon training"). With one table + jsonb, that query is one SELECT with a GIN index on entity_ids.
How to apply. When someone proposes a new domain, ask: what does this add that can't be expressed as an entity type plus a Zod property schema? The answer is usually "nothing".
Tools own writes
Every DB mutation flows through a Mastra tool in @soma/tools. UIs and bots never touch Drizzle directly.
Why. Single write path = single place to validate, embed, trace, and reason about side effects. The memory_capture tool embeds the entity's name, writes the source row, and upserts the entity atomically. If three different call sites did this inline, one of them would drift.
How to apply. A server action that creates/updates an entity calls tools.memory_capture.execute(...). It never imports Drizzle tables.
RLS on every table
Every table ships with USING (user_id = auth.uid()) policies, enforced even on single-user MVP. The migrations in packages/db/rls/ are as mandatory as the Drizzle schemas in packages/db/src/schema/.
Why. The cost of adding RLS now is five minutes per table; the cost of adding it later is a security audit, a data migration, and a two-week regression burn. We already bet the system on multi-tenancy; we just haven't onboarded the second tenant yet.
How to apply. New table? Ship an ENABLE ROW LEVEL SECURITY + CREATE POLICY in the same PR as the Drizzle migration. No exceptions.
Zod in @soma/core is the source of truth
One schema → backend validation + TypeScript types + frontend form validation. @soma/core is imported by @soma/db, @soma/tools, apps/web, apps/bot — everywhere.
Why. Duplicated schemas drift. Drifted schemas are the source of silent data corruption.
How to apply. New field? Add it to the Zod schema. Everything downstream — Drizzle type inference, form validation, tool input schema — follows. Never z.object({...}) twice.