Appearance
ADR-0031 · Domain schemas for bounded contexts
Status: 🟢 Accepted · Decided: 2026-09-04 (product owner) · Supersedes: nothing · Amends: the FEATURE-SCOPED NAMING rule in CLAUDE.md (QRS-436), which it extends to the database layer rather than replaces.
Context
The owner raised naming while the schema is still small: "any tables, database schemas, Edge Functions, files, or other technical components that are not core/shared platform components and are specific to a particular domain should use an explicit domain-based naming convention", with the stated objective of an enterprise-grade backend rather than a generic one that becomes a nightmare to troubleshoot and maintain as it scales — and an explicit instruction not to over-prefix the core/shared components used across Consumer, Merchant and Enterprise.
The problem is real and it is specifically about flat namespaces. Three of them:
| Namespace | Why it hurts |
|---|---|
public tables | 62 tables in one alphabetical list. Nothing groups them, so ownership is invisible in Studio, in \dt, and in a migration diff |
| Edge Function names | One global list in config.toml and in the dashboard. No directories exist. The name appears in the URL and in every log line |
| Log filtering | Keyed by function name, so grouping is only as good as the naming |
The file tree is not one of them: apps/mobile/src/tiers/consumer/features/biodata/ already carries the scope, and CLAUDE.md's existing rule says a name scoped by its container must not repeat it.
The proposal that was evaluated, and why it was not adopted as stated
The suggestion was flat domain prefixes: consumer_biodata_fields, consumer_biodata_profiles, consumer_biodata_*. Three findings against it, in descending order of force.
1 · consumer_* is factually wrong on day one, not merely fragile
A marriage biodata is owned by a person, and personhood is not the consumer tier. handle_new_user's own comment states that primary_context is "PREFERENCE ONLY — never an authorization input. Authorization asks workspace_members", and CLAUDE.md's three-category principle is explicit that category is "the DEFAULT EXPERIENCE, never a permanent exclusion" and that a consumer who starts a business gains a membership, never a second account.
So a merchant can own a biodata for their sister today, with no schema change at all. A consumer_ prefix would assert an ownership the data model deliberately refuses. The design also already contains a marriage bureau — "a bureau that reads as a bureau" — a business managing biodatas on behalf of families.
chat is the same trap from the other side: conversations carries both workspace_id and consumer_user_id, and the design's instruction is that the two chat halves share one module. A consumer_chat_* prefix would be wrong immediately.
Therefore the axis is the BOUNDED CONTEXT, never the AUDIENCE. biodata, meetings, chat — not consumer, merchant, enterprise. Who uses a context is a property of it, not part of its name, and it is the property most likely to change.
2 · Flat prefixes land on the 63-byte identifier limit, measured
Postgres caps identifiers at 63 bytes and truncates silently rather than erroring, so two constraints sharing a 63-byte prefix collide under a name nobody wrote. Projected against the constraints the biodata tables actually need:
| Scheme | Longest auto-generated name | Headroom |
|---|---|---|
| Flat, no prefix | biodata_access_requests_share_id_requester_user_id_key (54) | 9 |
| Flat, domain prefix | consumer_biodata_access_requests_share_id_requester_user_id_key (63) | 0 |
| Schema per context | access_requests_share_id_requester_user_id_key (46) | 17 |
⚠ This repo already sits at 61 bytes for whatsapp_message_templates_waba_id_template_name_language_key, with no domain prefix at all. The headroom is not theoretical.
3 · A prefix is a fourth copy of one fact, and the only one that can rot silently
Ownership is already stated by the RLS policy, the migration that created the table, and the feature directory. A name prefix adds a fourth statement of the same fact and is the only one no gate can compare against reality. Duplicate-source-of-truth is this repo's most expensive recurring defect (QRS-249, QRS-284, QRS-287).
Decision
A bounded context that owns a table set gets its own Postgres schema. Core and shared objects stay in public.
D1 · The decidable line
publicholds what more than one product surface depends on. A domain schema holds what exactly one product surface owns.
Stated as a line a reader can apply rather than a judgement call, in the same spirit as QRS-436's "a name that crosses a module boundary must carry its feature scope".
Worked through:
| Objects | Home | Why |
|---|---|---|
users · workspaces · workspace_members · organizations · slugs · media · feature_grants · features · industries · platform_plans | public | every surface depends on them |
conversations · messages · conversation_states | public | two surfaces (merchant Messages, consumer Chats) share one module |
orders · payments · the settlement tables | public | merchant and buyer both |
setu_cards · setu_card_templates | public | the merchant edits it and every consumer surface reads the published card |
| marriage-biodata tables | biodata | one surface owns them |
| meetings tables | public | ⚠ AMENDED 2026-09-04 — see A1. This row read meetings / "one surface owns them", and the premise was false. |
D2 · The schema is the module; public is the published interface
RPCs stay in public. That is what PostgREST exposes (config.toml: schemas = ["public", "graphql_public"]) and what the client calls by bare name, so a domain schema costs the client nothing. Domain tables are consequently not REST-reachable at all — defence in depth acquired for free rather than designed for.
This is what makes the change nearly costless here, and it is a property of a decision already taken: supabase.from() is banned in app code, and a sweep of packages/data/src, apps/web/src and apps/mobile/src finds zero direct table references. The usual objection to Postgres schemas under Supabase — that every call site must add .schema('x') — does not apply, because no call site names a table.
D3 · A name inside a schema does not repeat the schema
biodata.fields, biodata.profiles, biodata.shares. Never biodata.biodata_fields. The schema is the container, and QRS-436's existing rule already exempts a name its container scopes.
This is also where the length headroom in finding 2 comes from, so it is not a cosmetic preference.
D4 · anon and authenticated get no USAGE on a domain schema
The only way in is a SECURITY DEFINER function in public. This is the part a prefix could never give us: REVOKE ALL ON SCHEMA is an enforceable boundary, whereas a prefix grants nothing. A whole context can be sealed in one statement, and a leaked table grant is still unreachable without schema USAGE.
D5 · search_path stays = public; domain tables are always schema-qualified
Definer functions keep SET search_path = public and reference domain tables explicitly (biodata.fields). Never widen search_path inside a SECURITY DEFINER function — that is a privilege-escalation vector, and a qualified name needs no search path anyway.
⚠ Qualification discipline is a known live hazard in this repo, not a hypothetical one: "a bare citext passes a full local db reset and FAILS on Dev". The same class of mistake now has more surface, and that is the real cost of this decision.
D6 · The context vocabulary is CLOSED
Opening one is an amendment to this ADR, not a migration-time choice. Otherwise biodata, marriage, consumer_biodata and bio all come to mean one thing, which is the outcome the owner asked to avoid.
Open contexts: biodata. ⚠ meetings was listed here until 2026-09-04 and is withdrawn — see A1. Everything else lives in public until this list is amended.
D7 · It governs NEW contexts; existing public objects stay put
Strangler-fig, as CLAUDE.md already prescribes. A 62-table sweep is not sanctioned by this ADR and was explicitly not requested. biodata_fields is the sole exception, because it shipped hours before this decision and moving it is one ALTER TABLE SET SCHEMA.
D8 · Edge Functions take a context prefix, because there a prefix is the only mechanism
biodata-manage, biodata-read, meetings-manage. The namespace is genuinely flat and global, the name is in the URL and every log line, and alphabetical grouping is the whole benefit.
⚠ This inverts the existing verb-noun convention (manage-item, manage-order, place-public-order). The twelve live functions are not renamed by this ADR — renaming a deployed function is a deploy plus a client change, and config.toml's own comment warns that a deployed-but-stale function "reads as a working feature". Accepted consequence: a mixed convention, with new context-owned functions grouped and the shared ones keeping verb-first names. Revisit only if the mixture actually confuses somebody.
Consequences
Gained
- Ownership visible in Studio's sidebar, in
\dn, and in every migration diff — the maintainability the owner asked for, structurally rather than by convention. - Ownership enforceable (D4), which a prefix cannot do.
- 17 bytes of identifier headroom instead of 0.
- Moving a table between contexts is
ALTER TABLE SET SCHEMA— one statement, and constraint and index names are untouched. Under prefixes it is a table rename plus every auto-named constraint and index. - Domain tables unreachable by REST.
Paid
- Qualification discipline, against a failure mode this repo has already hit (D5).
- Two gates need widening. ⚠ Measured rather than estimated:
check-sql-comments.jsandcheck-sql-grants.jscapture table names as([a-z0-9_."]+), which already matches a schema-qualified name, so the cost is smaller than first estimated — one fix-hint string hardcodespublic.andcheck:naming's normaliser strips only apublic.prefix. - A mixed world while
publicstill holds the shared 62. pgTAPassertions take the schema as their first argument, which they already do.
Explicitly rejected
| Option | Why not |
|---|---|
Flat domain prefixes (consumer_biodata_*) | 63/63 bytes, silent truncation; and consumer_ is factually wrong (finding 1) |
| Audience prefixes or one schema per audience | Audience is the property most likely to change, and chat spans two |
Leave everything in public | The flat 62-table list is the problem being solved |
| Rename the twelve live Edge Functions now | A deploy plus a client change, for grouping alone (D8) |
Verification
check:sql,check:naming,check:release,test:dbgreen after the first schema lands.- pgTAP asserts
anonandauthenticatedhold noUSAGEon a domain schema (D4), in both directions. biodata.fieldsread back on Dev afterSET SCHEMA, with its 60 rows and tier split intact.
A1 · Amendment, 2026-09-04 — meetings is withdrawn as a context
Status: applied with 20260904144500_v2_meetings_schema.sql (CR-26.0.1-135) · Raised by: measuring the design while writing that migration, one day after this ADR was accepted.
This ADR listed meetings as an open context and justified it in the worked-through table with "one surface owns them". That premise is false, and the ADR's own decidable line puts these tables in public.
What was measured
| Evidence | Where |
|---|---|
| A merchant Meetings screen exists in the design (round 35) | prototype/mobile-console/Meetings.dc.html |
| The shared logic lives on the merchant side | prototype/mobile-console/meetings-core.js |
| The consumer module imports it | meetings-mine.js: import * as CORE from '../mobile-console/meetings-core.js' |
| And says why | "Occurrence, recurrence, date and live/imminent maths are NOT re-implemented here… so a merchant's session and a personal meeting can never disagree about what 'today' or 'live now' means." |
Why that settles it against the original row
Two product surfaces sharing one module is verbatim the reason this same table gives for keeping chat in public:
|
conversations·messages·conversation_states|public| two surfaces (merchant Messages, consumer Chats) share one module |
So D1 — public holds what more than one product surface depends on; a domain schema holds what exactly one product surface owns — was applied correctly to chat and incorrectly to meetings, in adjacent rows of one table. Nothing about D1 changes; one row that contradicted it does.
What this does not change
biodatastands, and for the reason originally given: no merchant surface reads a biodata, and the consumer tier owns the whole of it. The measurement that overturned the meetings row is exactly the measurement that confirms this one.- D1 is unchanged. The line held; the row misapplied it.
- The Edge Function convention is unchanged. D8's context prefix (
biodata-manage) is about a flat global namespace and is independent of where the tables live, so a futuremeetings-manageremains correct even though its tables are inpublic. - Nothing is renamed. The tables ship as
meetings,meeting_occurrences,meeting_participants,meeting_invite_states,meeting_joins— feature-scoped names inpublic, which is what QRS-436 asks for there.
The lesson worth keeping
⚠ An ADR's worked example can contradict its own rule, and the rule is the part that was reasoned about. This row was written from a plan that predated the design measurement, in the same table as the counter-example that disproves it, and it survived review — including mine — because a table of classifications reads as settled rather than as a set of claims. The generalisable check: when a decision states a decidable line, re-derive every row from the line rather than reading the rows as the decision.