Skip to content

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:

NamespaceWhy it hurts
public tables62 tables in one alphabetical list. Nothing groups them, so ownership is invisible in Studio, in \dt, and in a migration diff
Edge Function namesOne global list in config.toml and in the dashboard. No directories exist. The name appears in the URL and in every log line
Log filteringKeyed 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:

SchemeLongest auto-generated nameHeadroom
Flat, no prefixbiodata_access_requests_share_id_requester_user_id_key (54)9
Flat, domain prefixconsumer_biodata_access_requests_share_id_requester_user_id_key (63)0
Schema per contextaccess_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 ​

public holds 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:

ObjectsHomeWhy
users · workspaces · workspace_members · organizations · slugs · media · feature_grants · features · industries · platform_planspublicevery surface depends on them
conversations · messages · conversation_statespublictwo surfaces (merchant Messages, consumer Chats) share one module
orders · payments · the settlement tablespublicmerchant and buyer both
setu_cards · setu_card_templatespublicthe merchant edits it and every consumer surface reads the published card
marriage-biodata tablesbiodataone surface owns them
meetings tablespublic⚠ 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.js and check-sql-grants.js capture 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 hardcodes public. and check:naming's normaliser strips only a public. prefix.
  • A mixed world while public still holds the shared 62.
  • pgTAP assertions take the schema as their first argument, which they already do.

Explicitly rejected

OptionWhy not
Flat domain prefixes (consumer_biodata_*)63/63 bytes, silent truncation; and consumer_ is factually wrong (finding 1)
Audience prefixes or one schema per audienceAudience is the property most likely to change, and chat spans two
Leave everything in publicThe flat 62-table list is the problem being solved
Rename the twelve live Edge Functions nowA deploy plus a client change, for grouping alone (D8)

Verification ​

  • check:sql, check:naming, check:release, test:db green after the first schema lands.
  • pgTAP asserts anon and authenticated hold no USAGE on a domain schema (D4), in both directions.
  • biodata.fields read back on Dev after SET 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 ​

EvidenceWhere
A merchant Meetings screen exists in the design (round 35)prototype/mobile-console/Meetings.dc.html
The shared logic lives on the merchant sideprototype/mobile-console/meetings-core.js
The consumer module imports itmeetings-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 ​

  • biodata stands, 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 future meetings-manage remains correct even though its tables are in public.
  • Nothing is renamed. The tables ship as meetings, meeting_occurrences, meeting_participants, meeting_invite_states, meeting_joins — feature-scoped names in public, 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.