Schema Conventions
Database conventions for all modules. Follow these rigorously for consistency.
Core vs Extension — Dependency & Naming
⚠️ Superseded — see PROJECT_DECISIONS "Nursery-Only Focus (2026-05-13)". There is no core/extension split. Masters live in owning modules (item in Inventory, customer in CRM, vendor in Purchasing). The dependency direction and naming conventions below still apply between modules — modules access each other through services, not direct table queries — but the core-vs-extension framing is retired. The "plant test" placement rule is retired. The "extensible attribute mechanism" (typed table or constrained JSONB with definition registry) remains a valid convention within any module that needs it.
Per the Architecture Redesign (PROJECT_DECISIONS, ARCHITECTURE.md), all schemas belong to one of two layers. The dependency direction between them is enforced.
Layer membership
Core schemas (the Core & Platform Layer + shared services):
core— item, party (customer / vendor / contact), location, unit-of-measure, base pricingplatform— tenant, feature-flags, connection registryidentity— users, roles, permissions, sessions, 2FA, SSOpayments— PaymentsService over Stripeintegrations— unified external connectorsai— AIService over Bedrockfiles— Cloudflare R2 ownership (shared service)search— search abstraction (shared service)shared— reference data (countries, currencies, etc.)
Nursery vertical-extension schemas (nursery-specific only):
- The nursery plant taxonomy /
plant_profileschema (home TBD) production— propagation, crop lots, mother plants- Plant care attributes (home TBD; co-located with plant taxonomy)
customer_app— consumer plant-care app data- The multi-nursery consumer linkage (home in extension layer; specifics in revised "Global Tables" subsection below)
The remaining v1.0 schemas (inventory, pos, orders, purchasing, crm, reporting, multi_loc, admin, billing, audit, notifications, delivery, services) are nursery-facing modules that consume the Core & Platform Layer. They are not part of the vertical-extension layer themselves — they are tenant-facing modules being refactored onto core. Some columns/concepts within them will move (e.g., plant-specific fields in inventory move to plant_profile; cross-nursery customer linkage in crm moves to extension).
Dependency direction (enforced)
- Foreign keys: extension MAY have FKs to core. Core MUST NOT FK to extension.
- Service-layer calls: extension MAY call core services. Core MUST NOT call extension services.
- Imports / module includes: same rule — one-way only.
Placement rule — "the plant test"
For any new table, column, or service, ask: "Would this make sense for a business that has never heard of a plant?"
- Yes → core schema (
core,platform,identity,payments,integrations,ai,files,search,shared) - No → vertical-extension schema (nursery plant taxonomy,
production,customer_app, etc.)
If a column is ambiguous, the test failing on either side is dispositive: if it would NOT make sense without plants, it goes to extension.
Naming (unchanged)
- Snake_case singular table names, regardless of layer
- Schema-qualified references in all cross-schema FKs (e.g.,
plant_profile.item_id REFERENCES core.item(id)) - Core tables live in core schemas; nursery tables live in the nursery-extension schema
Naming
- Table names: singular, snake_case →
plant,variant,zone, NOTplants - Column names: snake_case →
created_at,tenant_id,plant_master_id - Foreign keys:
<referenced_table>_id→plant_id,zone_id - Junction tables:
<table1>_<table2>→plant_tag - Materialized views: prefix with
mv_→reporting.mv_top_sellers - Enums: stored as text with check constraints, not Postgres enums (easier to alter)
Required columns (every tenant-scoped table)
id— UUID, primary key (uuid_generate_v4() default)tenant_id— UUID, foreign key to platform.tenant, NOT NULLcreated_at— timestamptz, default now()updated_at— timestamptz, default now(); updated via trigger or appdeleted_at— timestamptz, nullable (soft delete pattern)
Multi-site columns (transactional tables only)
Transactional tables (those that represent operational/financial events) MUST include:
site_id— UUID, foreign key tomulti_loc.site, NOT NULL
Master data tables (customer, vendor, plant catalog, etc.) MUST NOT include site_id. Master data is tenant-wide.
For orders and fulfillment, use both:
site_id— site where the record was created (e.g., where reservation made)fulfillment_site_id— site where fulfillment will happen (may be same as site_id)
These columns are populated automatically:
- For single-site tenants: from tenant.primary_site_id at insert time
- For multi-site tenants: explicitly set by the application based on context
Required columns (lookup/reference tables — non-tenant)
id— UUID or smallint primary keycreated_at,updated_at— timestamps
Soft Delete
- NEVER hard-delete rows in tenant-scoped tables
- Set
deleted_at = now()instead - All queries filter
WHERE deleted_at IS NULL - Audit log captures the deletion
Status Fields
- Use
statuscolumn with check constraint - Common values: 'active', 'inactive', 'pending', 'archived'
- Module-specific statuses listed in module spec
Currency
- Store as ISO 4217 code + amount in smallest unit (cents)
- Columns:
amount_cents(bigint),currency_code(char(3)) - Never use float/numeric for money
Timestamps
- All stored UTC
- Tenant timezone stored on
platform.tenant.timezone - Display layer converts to tenant timezone
Row-Level Security (RLS)
- ENABLE RLS on every tenant-scoped table
- Policy:
USING (tenant_id = current_setting('app.current_tenant_id')::uuid) - Policy:
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid) - Application sets
SET LOCAL app.current_tenant_id = ?per request
Indexes
- Always index
tenant_id(most queries filter by it) - Always index FKs
- Composite indexes for common query patterns (document in module spec)
Foreign Keys Across Schemas
- Allowed and encouraged for referential integrity
- Use
ON DELETE RESTRICTfor critical relationships (e.g., variant → plant) - Use
ON DELETE SET NULLfor optional relationships - Document cross-schema FKs in MODULE_INDEX.md
Migrations
- One migration per logical change, not bundled
- Migration scope: one module at a time
- File naming:
YYYYMMDD_HHMM_<module>_<description>.sql - Reversible: include
-- DOWNsection
Global Tables (Tenant Scope Exceptions)
Cross-reference: PROJECT_DECISIONS.md → "Global Tables (Tenant Scope Exceptions)" (annotated as superseded) and "Architecture Redesign (Locked 2026-05-13)".
⚠️ Superseded (Nursery-Only Focus 2026-05-13): Customer is tenant-scoped, lives in
crmschema (nocore.customer). It carriestenant_id, is RLS-policed, and uses the standardSET LOCALcross-schema write pattern. The multi-nursery consumer linkage tables — plant library, garden plans, chat sessions — live in thecustomer_appmodule schema directly (no separate "extension layer"). They remain consumer-owned and cross-tenant; that property is unchanged. See PROJECT_DECISIONS "Nursery-Only Focus (2026-05-13)" — this is the current authority. The prior "Architecture Redesign" annotation that pointed at a separate extension layer is itself partially superseded.
Prior global tables — new homes
| Prior global table | New home | New scope |
|---|---|---|
crm.customer |
core.customer |
Tenant-scoped (carries tenant_id, RLS-policed) |
crm.nursery_customer (join table) |
Nursery vertical-extension layer (specific schema TBD) | Cross-tenant join — remains the "global, consumer-owned" exception, scoped to the extension layer |
customer_app.plant_library |
Vertical-extension layer (customer_app or nursery taxonomy schema) |
Cross-tenant, consumer-owned |
customer_app.garden_plan |
Vertical-extension layer | Cross-tenant, consumer-owned |
customer_app.chat_session |
Vertical-extension layer | Cross-tenant, consumer-owned |
The "global, consumer-owned" exception (retained, but only in the extension layer)
The vertical-extension layer may contain cross-tenant tables that represent a single consumer's identity spanning multiple nurseries (the multi-nursery linkage, plant library, garden plans, AI chat history). These tables:
- Do not carry
tenant_id - Are not RLS-policed at the row level
- Rely on API-layer access control (typically a join against the multi-nursery consumer linkage table)
This exception is scoped to the extension layer only. The core layer has no global tables — every core table is either tenant-scoped or a lookup/reference table.
What changed and why
- Before:
crm.customerwas global so that one consumer could shop at multiple nurseries. - After:
core.customeris tenant-scoped. Each nursery (tenant) owns its own customer rows. A separate nursery-extension table links cross-tenant consumer identities to the per-tenant customer rows. - Why: keeps core generic (a customer is just a tenant's customer), localizes nursery-specific cross-tenant linkage to the extension layer, and lets non-nursery deployments omit the extension entirely without inheriting an unwanted cross-tenant pattern.
Cross-Schema Writes
Decision (Locked v1.0, revised 2026-05-13 per Architecture Redesign): Hybrid pattern, scoped by layer.
Standard pattern — SET LOCAL + RLS
Applies to all writes within the core layer and between core layer schemas, and to cross-schema writes between tenant-scoped tables in nursery-facing modules.
- Application sets
SET LOCAL app.current_tenant_id = ?before every cross-schema insert. - RLS stays on for all tenant-scoped tables.
- This now includes writes touching
core.customer(which is tenant-scoped post-redesign — see "Global Tables (Tenant Scope Exceptions)" above).
SECURITY DEFINER pattern — scoped to the extension layer only
Applies only to cross-schema FKs that touch the remaining cross-tenant / consumer-owned tables that live in the nursery vertical-extension layer (the multi-nursery consumer linkage, plant library, garden plans, chat sessions).
- The function runs as a privileged role with RLS bypassed inside.
- The function enforces tenant isolation in code — validating
app.current_tenant_idagainst the consumer's tenant linkage via the multi-nursery join table. - Core schemas MUST NOT use this pattern; if a core schema needs SECURITY DEFINER to talk to extension tables, the dependency direction has been violated (core depending on extension).
Rationale
- Core tables are tenant-scoped by default, so RLS +
SET LOCALis sufficient and consistent everywhere. - The "consumer-owned across tenants" exception is real for the nursery plant-care app, but it's a vertical concern. Localizing SECURITY DEFINER to the extension layer means the pattern doesn't leak into core, and a non-nursery deployment never has to reason about it.
Soft Delete and Uniqueness Constraints
All unique constraints on tenant-scoped tables are partial unique indexes:
CREATE UNIQUE INDEX ... ON table (col) WHERE deleted_at IS NULL;
This applies to SKU, email, slug, code, and any other unique-by-tenant column. Standard B-tree unique constraints (UNIQUE (col) without a WHERE clause) are not used on soft-deleted tables — they would block re-creating a value after soft-delete.
Snapshot Storage
Decision (Locked v1.0): Daily deltas in Postgres + weekly full snapshots in R2.
- Daily deltas: stored in
audit.snapshot_delta, partitioned bytenant_id+ date. Retained for 90 days then aged out. - Weekly full snapshots: exported to Cloudflare R2 as Parquet, partitioned by
tenant_id+ week. 7-year retention. - Long-term record: R2 weekly fulls are the system of record beyond 90 days; Postgres deltas are working/short-term.
- Restore: latest R2 weekly full + forward-replay of Postgres deltas to the target date.
See ARCHITECTURE.md → "Snapshot Architecture" for the restore procedure outline.
Item-First Inventory
⚠️ Superseded — see PROJECT_DECISIONS "Nursery-Only Focus (2026-05-13)". Item model is nursery-first: item can carry plant-specific fields directly. No
plant_profileindirection. No separatecore.item. The extensible attribute mechanism for descriptive attributes (care attributes, reason codes, grades) remains a valid convention within any module that needs it — it is not retired, only the core/extension placement rule is.
Convention (added 2026-05-13 per Architecture Redesign):
The central inventory entity is core.item (Core & Platform Layer), not the nursery plant master. Plant becomes a profile in the vertical-extension layer that references core.item.
Rules
core.itemis the single inventory entity for every business — generic, no plant-specific columns.- Variants and SKUs are item-level constructs on
core.item. A nursery "1-gal lavender" and "3-gal lavender" are two item variants, not two children of a plant master. The same mechanism handles size/color/spec variants for any other vertical. plant_profile(vertical-extension) referencescore.itemwith a nullable one-to-one relationship: items that are plants have a profile; items that are hard goods (pots, soil, tools) do not. The presence/absence of aplant_profilerow defines "is-a-plant" — there is nois_plantboolean oncore.item.- Plant care attributes attach via the extension, not as columns on
core.item. USDA zone, sun exposure, water needs, mature size, bloom time, deer-resistant — all live in or off ofplant_profile, never on the generic item. - No reverse references:
core.itemMUST NOT carry any column that points toplant_profileor knows about plant taxonomy. The dependency arrow runs extension → core, never core → extension.
Extensible attribute mechanism
Descriptive nursery attributes that don't warrant a dedicated table — plant care attributes, reason codes (dead, damaged, theft, etc.), quality grades (A/B/C/Cull), customer types (retail, contractor, commercial, HOA), etc. — SHOULD use an extensible attribute mechanism so adding a new one is data, not a migration:
- Option A: typed attribute table (
entity_id,attribute_key,attribute_value,value_type) with a definition registry (attribute_definitiondescribing valid keys, types, constraints). - Option B: constrained JSONB column with a definition registry validating shape on insert/update.
Either approach is acceptable; pick one per use case based on query patterns (typed-table for filterable attributes, JSONB for sparse/heterogeneous attributes). Both apply inside the vertical-extension layer — core does not host extensible attributes for vertical concepts.
Relational concepts get real tables
Concepts that are relational (have their own lifecycle, FKs from many places, time-series, audit needs) get real extension tables, not extensible attributes:
- Crop lots, mother plants, propagation cycles → real tables in
production - Plant taxonomy (genus, species, cultivar, family) → real tables in the plant taxonomy schema
- Plant guarantee / warranty records → real tables
The extensible attribute mechanism is for descriptive, definitional data, not relational entities.
Migration note
This convention supersedes the prior Module 1 "plant-master + size variants" model. Table definitions are explicitly out of scope for this phase — the Module 1 spec will be refactored in a later phase to reflect: core.item as the central entity, plant_profile as the nursery extension, variants as item-level constructs, plant care attributes via the extensible attribute mechanism.