shared — Phase 3 (module #3)
8 tables, 75 columns — LOCKED 2026-07-05, reopened 2026-07-08 for Remediation Phase 4 (2 new tables, +16 cols; see below), reopened again 2026-07-18 for Phase 2 (Nursery Vertical Extraction) — 4 tables (climate_zone, plant, plant_common_name, plant_climate_zone, 61 cols total) moved OUT to a new nursery_ref schema via ALTER TABLE ... SET SCHEMA (a nursery-specific botanical taxonomy never belonged in this vertical-neutral core — see PROJECT_DECISIONS.md #70 and SCHEMA_CONVENTIONS.md §21); shared also had write access REVOKEd from the authenticated role in the same reopen (Part C of the same entry) — see the "Write access" note below. Global, non-tenant reference data: currency, country, administrative subdivisions, language, locale, unit of measure, exchange rates, and a payment-terms catalog. Global from day 1 — structural reference data fully seeded, not deferred to a later phase.
Baseline correction (pre-existing, not a Phase 4 change): the "108 columns" figure long stated here for the original 10-table baseline was itself stale. The true pre-Phase-4 baseline was 120 columns, not 108 — see the "Remediation Phase 4" section below for the full accounting of how the 108 figure drifted and was corrected. (That section's own final tally — 136 columns / 12 tables — was the pre-Phase-2 count; see the Phase 2 addendum at the end of that same section, and the header line above, for the current count.)
Depends on: identity — added 2026-07-06 by the cross-cutting autonomy-first backfill (see PROJECT_DECISIONS.md #19), via new created_by_actor_id FKs on plant, plant_common_name, and plant_climate_zone to identity.actor. This dependency no longer applies: all 3 of those tables moved to nursery_ref in Phase 2 (2026-07-18, see PROJECT_DECISIONS.md #70). shared's remaining 8 tables depend on nothing outside this schema.
Global rules for this schema:
- NOT tenant-scoped — no
tenant_idon any table; no RLS applied. - Readable by all authenticated users —
app_userandservice_roleboth have SELECT. - Write access (corrected 2026-07-18, Phase 2 — see
PROJECT_DECISIONS.md#70):authenticatedhas been REVOKEd INSERT/UPDATE/DELETE on every table in this schema (both the original 8 and, previously before their move, the 4 that are now innursery_ref) — SELECT-only.ALTER DEFAULT PRIVILEGESwas also applied so any future table added to this schema inherits the same read-only-by-default posture automatically, closing — in reverse — the exactplatform.polymorphic_target_registryover-grant bug class Phase 1 Security Remediation found (PROJECT_DECISIONS.md#68). Live-reproduced via a genuine A/B, not just a post-hoc query: a scoped, rolled-backGRANT INSERTproved the pre-lockdown posture would have allowed the write; post-lockdown,INSERT INTO shared.currencyasauthenticatedcorrectly rejects with42501. Before this date, the statement below ("writable only viaservice_role...") was a documented application-level convention only, not yet DB-enforced — application code never wrote here in practice, but nothing at the database level actually prevented it from doing so. - Writable only via
service_roleand seed/AI-enrichment migrations — application code never INSERTs/UPDATEsshared.*rows at runtime, except the (now-relocated) AI→nursery_refseam — see the "Inbound" note below. - No
deleted_atanywhere — deactivation is viais_active = false, never soft delete. Reference rows have permanent identity (an ISO code is the same code forever, even when retired). - Natural-key PKs, with 2 deliberate UUID-surrogate exceptions (
exchange_rate,payment_terms_catalog, both added Remediation Phase 4) — the original exception this schema carried,plant(UUID PK, see PROJECT_DECISIONS #17), moved tonursery_refin Phase 2 (2026-07-18) and is no longer part of this schema's own accounting.
Superseded v1 note: shared previously existed in v1 (superseded) as a 7-table design (country, us_state, currency, unit_of_measure, usda_hardiness_zone, plant, plant_common_name) — see docs/old/schema/schema_modules/schema_shared.md. This v2 design reconciles with v1's country/currency/unit_of_measure/plant/plant_common_name column-level thinking, and deliberately expands us_state → administrative_region (ISO 3166-2, global) and usda_hardiness_zone → climate_zone (multi-system: USDA/RHS/AHS Heat/Australian/EU), plus adds language, locale, and plant_climate_zone as new tables. Note (2026-07-18): plant, plant_common_name, plant_climate_zone, and climate_zone — the v1-descended tables named in this paragraph — were subsequently moved out of shared entirely, to a new nursery_ref schema (Phase 2, PROJECT_DECISIONS.md #70). This v1-comparison paragraph is left as-is as a historical record of v1→v2 reconciliation, not a description of shared's current shape — see docs/database/schema_docs/nursery_ref.md for where these 4 tables live now.
Cross-Phase Foreign Keys (shared)
| Column | Target | Notes |
|---|---|---|
country.default_currency_code |
shared.currency |
Intra-schema, enforced at this module's migration. |
country.default_locale_code |
shared.locale |
Circular with locale.country_code — both nullable. FK constraint added via ALTER TABLE after shared.locale exists (two-pass migration). See DESIGN_RATIONALE. |
locale.language_code |
shared.language |
Intra-schema, enforced. |
locale.country_code |
shared.country |
Nullable, intra-schema, enforced. |
administrative_region.country_code |
shared.country |
Intra-schema, enforced. |
administrative_region.parent_region_code |
shared.administrative_region |
Self-FK, nullable, app-enforced cycle prevention beyond the DB self-loop CHECK (same pattern as identity.role.parent_role_id, DR-12). |
unit_of_measure.base_unit_code |
shared.unit_of_measure |
Self-FK, nullable. |
exchange_rate.from_currency_code / to_currency_code |
shared.currency |
Intra-schema, enforced. Added 2026-07-08, Remediation Phase 4 Item 16 (PROJECT_DECISIONS.md #40). |
Moved out 2026-07-18 (Phase 2): 4 rows previously listed here — plant_common_name.plant_id → shared.plant, plant_common_name.locale_code → shared.locale, plant_climate_zone.plant_id → shared.plant, and plant_climate_zone.min_zone_code/max_zone_code → shared.climate_zone — no longer belong in this table. Both the referencing and (for 3 of the 4) the referenced tables moved to nursery_ref together, so these are now purely intra-nursery_ref FKs (see nursery_ref.md). The one exception, plant_common_name.locale_code → shared.locale, is now a genuine cross-schema inbound FK (nursery_ref → shared) — see "Inbound" immediately below.
Inbound (not built here): later modules will reference shared tables — inventory.item → unit_of_measure; crm.customer → country/administrative_region; pricing → currency. One seam is already locked in CROSS_MODULE_CONTRACTS.md: consumer_address.country_code → shared.country (enforced FK). New as of Phase 2 (2026-07-18): nursery_ref.plant_common_name.locale_code → shared.locale.code — a foundation-to-foundation cross-schema FK, permitted under SCHEMA_CONVENTIONS.md §21 since both nursery_ref and shared are non-tenant-scoped foundation-layer schemas. Two seams that used to point at shared.plant now point at nursery_ref.plant instead, retargeted in the same Phase 2 migration: consumer_interest.interest_ref → nursery_ref.plant.slug (loose text ref, app-validated) and AI → nursery_ref — AIService writes nursery_ref.plant/nursery_ref.plant_common_name rows with data_source='ai_generated', is_verified=false via service_role (see PROJECT_DECISIONS.md #70's "every consumer of a moved object retargeted" note).
Inbound, now real (added 2026-07-08, Remediation Phase 4): crm.customer.payment_terms_id, purchasing.vendor.payment_terms_id, purchasing.purchase_order.payment_terms_id → shared.payment_terms_catalog.id (all nullable FKs). shared.exchange_rate has no direct FK consumer yet; instead it enables billing.ar_payment_application's new currency-agreement trigger (billing.validate_ar_payment_application_currency() — see schema_docs/billing.md), which cross-checks ar_payment/ar_charge/ar_account currency codes for agreement (does not join exchange_rate directly, but the table exists to support future cross-currency conversion once a real multi-currency flow is built).
shared.currency
ISO 4217 currency dictionary. Seeded at ~152 active currencies.
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at.RLS: not applied — reference data.
Access control: readable by all authenticated users. Writable only via
service_roleand seed migration.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
iso_code |
char(3) | NOT NULL | — | PK — natural key, e.g. 'USD' |
numeric_code |
char(3) | NOT NULL | — | UNIQUE — ISO 4217 numeric, e.g. '840' |
name |
text | NOT NULL | — | |
symbol |
text | NOT NULL | — | e.g. '$' |
decimal_places |
smallint | NOT NULL | 2 |
CHECK >= 0 |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at / updated_at |
timestamptz | NOT NULL | now() |
Indexes: PK on iso_code; UNIQUE on numeric_code.
shared.language
ISO 639 language dictionary. Seeded at ~94 major world languages (ISO 639-1 codes; not the full ~184-code standard — see PROJECT_DECISIONS for scope rationale).
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at.RLS: not applied — reference data.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
iso_639_1 |
char(2) | NOT NULL | — | PK — e.g. 'en'. Known limitation: languages without a 2-letter ISO 639-1 code (e.g. Hawaiian, which only has the 3-letter iso_639_3='haw') cannot be represented under this PK — see DESIGN_RATIONALE. |
iso_639_3 |
char(3) | nullable | — | UNIQUE — superset code |
name |
text | NOT NULL | — | English name |
native_name |
text | nullable | — | |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at / updated_at |
timestamptz | NOT NULL | now() |
Indexes: PK on iso_639_1; UNIQUE on iso_639_3 WHERE NOT NULL.
shared.country
ISO 3166-1 country dictionary. Seeded at 193 countries/territories.
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at.RLS: not applied — reference data.
Circular reference:
default_locale_code→shared.locale.code, andlocale.country_code→country.iso_alpha2. Both nullable. Seeded in two passes — see the migration and DESIGN_RATIONALE.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
iso_alpha2 |
char(2) | NOT NULL | — | PK — e.g. 'US' |
iso_alpha3 |
char(3) | NOT NULL | — | UNIQUE |
numeric_code |
char(3) | NOT NULL | — | UNIQUE |
name |
text | NOT NULL | — | |
official_name |
text | nullable | — | Not populated this pass |
phone_code |
text | nullable | — | E.164, e.g. '+1' |
default_currency_code |
char(3) | nullable | — | FK → shared.currency |
default_locale_code |
text | nullable | — | FK → shared.locale (circular, see above) |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at / updated_at |
timestamptz | NOT NULL | now() |
Indexes: PK on iso_alpha2; UNIQUE on iso_alpha3; UNIQUE on numeric_code; index on default_currency_code.
shared.locale
BCP 47 locale dictionary. Seeded at 36 locales (a practical set, not the full combinatorial space of country × language).
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at.RLS: not applied — reference data.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
code |
text | NOT NULL | — | PK — e.g. 'en-US' |
language_code |
char(2) | NOT NULL | — | FK → shared.language |
country_code |
char(2) | nullable | — | FK → shared.country (circular, see country above) |
name |
text | NOT NULL | — | e.g. 'English (United States)' |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at / updated_at |
timestamptz | NOT NULL | now() |
Indexes: PK on code; index on language_code; index on country_code WHERE NOT NULL.
shared.administrative_region
ISO 3166-2 subdivision dictionary (replaces v1's us_state). Seeded at 186 rows — comprehensive for US (56: 50 states + DC + 5 territories), Canada (13), Mexico (32); a representative subset for UK (4, simplified to constituent countries), Australia (8), Germany (16), France (13), Italy (20), Spain (17), Japan (7 of 47). Scaled down from the originally-discussed ~3-4K global target — see PROJECT_DECISIONS for the accuracy-over-volume rationale.
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at.RLS: not applied — reference data.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
iso_3166_2 |
text | NOT NULL | — | PK — full code, e.g. 'US-CA' |
country_code |
char(2) | NOT NULL | — | FK → shared.country |
code |
text | NOT NULL | — | region-only part, e.g. 'CA' |
name |
text | NOT NULL | — | |
region_type |
text | NOT NULL | — | CHECK IN (21 values: state,province,region,territory,department,municipality,district,governorate,oblast,emirate,county,parish,autonomous_republic,city,canton,prefecture,republic,capital_territory,special_administrative_region,administrative_area,country). Expanded from an initial 6-value proposal that failed the Section 4 audit (Item F) — the real ISO 3166-2 type diversity across countries required this larger list. |
parent_region_code |
text | nullable | — | Self-FK → shared.administrative_region. DB self-loop guard only; deeper cycle prevention is app-enforced. |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at / updated_at |
timestamptz | NOT NULL | now() |
Indexes: PK on iso_3166_2; UNIQUE on (country_code, code); index on country_code; index on parent_region_code WHERE NOT NULL.
shared.unit_of_measure
Global UoM vocabulary — the set of units that exist, not per-item conversion logic (that lives on inventory.item/package). Seeded at 31 rows: metric + imperial base units across 6 dimensions, plus nursery trade-size pot containers.
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at.RLS: not applied — reference data.
Conversion formula:
base_value = (raw_value + offset) * conversion_factor. Example: Fahrenheit → Celsius,offset = -32,conversion_factor = 5/9.Nursery pot sizes are nominal, not precise:
us_nursery_gal_1/_2/_3are horticultural trade-size names — a "#1 container" commonly holds ~2.5–3.5 actual quarts, not exactly one gallon, and varies by manufacturer.conversion_factoron these rows is a conventional approximation, not a measured constant.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
code |
text | NOT NULL | — | PK — e.g. 'each', 'lb', 'us_nursery_gal_1' |
name |
text | NOT NULL | — | |
symbol |
text | nullable | — | |
dimension |
text | NOT NULL | — | CHECK IN (length,volume,weight,temperature,area,count) |
system |
text | nullable | — | CHECK IN (metric,imperial) OR NULL — count-dimension units belong to neither |
base_unit_code |
text | nullable | — | Self-FK → shared.unit_of_measure |
conversion_factor |
numeric | nullable | — | See formula above |
offset |
numeric | nullable | — | Temperature only |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at / updated_at |
timestamptz | NOT NULL | now() |
Indexes: PK on code.
shared.exchange_rate
Currency-pair exchange rates by effective date. Added 2026-07-08, Remediation Phase 4 Item 16 (PROJECT_DECISIONS.md #40). 0 rows seeded — brand-new table, no data yet.
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at, no RLS — matches every other table in this schema with zero exception.RLS: not applied — reference data.
UUID surrogate PK, not a natural composite key — a deliberate departure from this table's own natural fit (the natural key would be
(from_currency_code, to_currency_code, effective_date)), but a codebase-wide grep confirmed zero composite-PK precedent anywhere in ~180 tables, so this table follows that universal single-column-PK convention instead of inventing ashared-local exception. The natural key is still fully enforced, just as a UNIQUE index rather than the PK.No direct FK consumer yet. Instead, this table enables
billing.ar_payment_application's new currency-agreement trigger (billing.validate_ar_payment_application_currency()), which validates that anar_payment's currency, its targetar_charge's currency, and (when both reference the same account) thear_account's own currency all agree. Seeschema_docs/billing.mdfor the trigger detail.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK — surrogate; see note above on why this table doesn't use a natural composite PK |
from_currency_code |
char(3) | NOT NULL | — | FK → shared.currency.iso_code |
to_currency_code |
char(3) | NOT NULL | — | FK → shared.currency.iso_code |
effective_date |
date | NOT NULL | — | |
rate |
numeric | NOT NULL | — | CHECK > 0 |
source |
text | nullable | — | Free-text provenance of the rate (e.g. a rate-provider name); not FK-constrained |
created_at |
timestamptz | NOT NULL | now() |
Indexes: PK on id; UNIQUE on (from_currency_code, to_currency_code, effective_date).
CHECK chk_exchange_rate_positive: rate > 0.
CHECK chk_exchange_rate_distinct_currencies: from_currency_code != to_currency_code — a currency cannot have an exchange rate against itself.
shared.payment_terms_catalog
Global payment-terms catalog — the enum→catalog additive interim step (Item 17b) for what was previously a bare CHECK-enum on consuming tables. Added 2026-07-08, Remediation Phase 4 (PROJECT_DECISIONS.md #40). Seeded at 10 rows.
NOT tenant-scoped — reference data. No
tenant_id, nodeleted_at, no RLS.RLS: not applied — reference data.
Deliberately pure global, no tenant-scoped variant. An earlier draft considered an optional tenant-scoped variant mirroring
identity.role's mixed-scope pattern, but this was dropped as unrequested scope creep — logged here, not silently dropped.Additive interim step, not yet synced with the legacy enum.
crm.customer.credit_terms(Phase 3's own CHECK-enum, 6 values) is not a clean subset of this catalog's 10 codes — 4 catalog codes (cod,prepaid,net_7,2_10_net_30) have nocredit_termsequivalent at all. The eventual cutover from the legacy enum to this catalog requires a real mapping decision for those 4 codes, not a trivial 1:1 rename. Logged toOPEN_ITEMS.md.Consumed by:
crm.customer.payment_terms_id,purchasing.vendor.payment_terms_id,purchasing.purchase_order.payment_terms_id(all nullable FKs, added in the same migration).
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
code |
text | NOT NULL | — | UNIQUE — e.g. 'net_30', '2_10_net_30' |
name |
text | NOT NULL | — | Display name, e.g. '2/10 Net 30' |
net_days |
integer | NOT NULL | — | CHECK >= 0 |
discount_percent |
numeric | nullable | — | Early-payment discount percent, e.g. 2.00. Paired with discount_days (see CHECK below) |
discount_days |
integer | nullable | — | Days within which the discount applies. Paired with discount_percent |
is_active |
boolean | NOT NULL | true |
|
created_at / updated_at |
timestamptz | NOT NULL | now() |
Indexes: PK on id; UNIQUE on code.
CHECK chk_payment_terms_catalog_net_days_nonneg: net_days >= 0.
CHECK chk_payment_terms_catalog_discount_pair: (discount_percent IS NULL) = (discount_days IS NULL) — a discount must be fully specified (both percent and days) or not specified at all, never half-set.
Seed rows (10): due_on_receipt, cod, prepaid, net_7, net_15, net_30, net_45, net_60, net_90, 2_10_net_30 (the only row with a discount: net_days=30, discount_percent=2.00, discount_days=10 — genuinely representing "2/10 net 30," a shape a bare CHECK-enum cannot express).
Shared — Design Patterns Summary
All 8 tables are NOT tenant-scoped, no RLS
Every table: readable by all authenticated users; writable only via service_role + seed migration. As of 2026-07-18 (Phase 2, PROJECT_DECISIONS.md #70) this is also DB-enforced via REVOKE, not just a code convention — see the "Write access" note in the Global rules above. The 2 tables added in Remediation Phase 4 (exchange_rate, payment_terms_catalog) conform to this same pattern with zero exception. (The AI-enrichment write path — previously plant/plant_common_name — moved to nursery_ref in the same Phase 2 reopen and no longer touches this schema at all.)
No deleted_at — deactivation via is_active
Most tables carry is_active boolean NOT NULL DEFAULT true; reference rows have permanent identity, and deactivation is a display/picker concern, never a delete. exchange_rate has no lifecycle concept at all (a rate row is either the current one for its pair/date or superseded by a later effective_date row — no toggle needed). payment_terms_catalog does carry is_active, matching the other lookup-style tables.
Natural-key PKs — now the norm with zero exception among the original reference tables
currency, country, language, locale, administrative_region, unit_of_measure all use their natural code as the PK (char/text, not UUID) — a deliberate deviation from the platform/identity UUID convention, scoped to this module's pure reference-code tables. exchange_rate and payment_terms_catalog (both added Remediation Phase 4) use a UUID surrogate PK instead — see each table's own section above for why. plant, this schema's original UUID-PK exception (botanical/taxonomic names are not permanently stable identifiers — PROJECT_DECISIONS #17) moved to nursery_ref in Phase 2 (2026-07-18) and is no longer part of this schema's own accounting — see nursery_ref.md.
Circular reference: country ⇄ locale
country.default_locale_code and locale.country_code reference each other, both nullable. Seeded in two passes (see migration): country rows first with default_locale_code = NULL, then locale rows, then an UPDATE closes the loop. The FK constraint on country.default_locale_code is added via a separate ALTER TABLE after shared.locale exists.
JSONB columns
None in this module.
Deferred: country_currency
Considered and explicitly not built this pass — country.default_currency_code (a single FK) covers the common case; a join table would only matter for genuinely dual-currency countries (Panama, Timor-Leste, etc.), which is out of scope for a US-market product today. See OPEN_ITEMS for the trigger to revisit.
Remediation Phase 4 (2026-07-08)
See PROJECT_DECISIONS.md #40 for the full cross-module entry. Two additive changes to this module: 2 new tables (+16 columns), plus a drive-by correction of a stale column-count figure that predates Phase 4 and is unrelated to it. Zero tables/columns dropped; nothing existing was altered.
The pre-existing 108 → 120 baseline correction (NOT a Phase 4 addition)
This doc previously stated the pre-Phase-4 baseline as 10 tables, 108 columns. That figure was stale, independent of anything Phase 4 built:
- The original 2026-07-05 lock was 10 tables / 101 columns.
- The 2026-07-06 autonomy-first backfill (
PROJECT_DECISIONS.md#19) addeddata_source,is_verified,created_by_actor_idtoplant_common_nameandplant_climate_zone— +7 columns (101 + 7 = 108). This is the arithmetic behind the "108" figure this doc has stated since. - However, a second, later change was never folded into that running total: the 2026-07-06 review-seam fix (
PROJECT_DECISIONS.md#21, "FIX 1") addedreview_status,review_reason,reviewed_by_actor_id,reviewed_atto all 3 plant tables (plant,plant_common_name,plant_climate_zone) — +4 columns × 3 tables = +12 columns. This addition is fully documented per-table above (see each table's column list, all dated "added 2026-07-06"), but the module-level header count was never updated to include it. - True pre-Phase-4 baseline: 108 + 12 = 120 columns, still 10 tables. This correction is disclosed transparently in
PROJECT_DECISIONS.md#40's own Docs section as a drive-by fix, the same way Phase 3 fixed an unrelated stale count found in passing — it is not part of Phase 4's own scope of work, just corrected in the same doc-update pass for accuracy.
Phase 4's own genuine additions: +2 tables, +16 columns (120 → 136)
shared.exchange_rate(Item 16) — 7 columns:id,from_currency_code,to_currency_code,effective_date,rate,source,created_at. Full detail in the table section above. Uses auuidsurrogate PK +UNIQUE(from_currency_code, to_currency_code, effective_date)rather than a natural composite PK — a deliberate departure from this module's otherwise natural-key-PK convention, justified by a codebase-wide grep confirming zero composite-PK precedent across ~180 tables. Pure global reference data, no direct FK consumer yet (enablesbilling.ar_payment_application's new currency-agreement trigger instead — seeschema_docs/billing.md).shared.payment_terms_catalog(Item 17b) — 9 columns:id,code,name,net_days,discount_percent,discount_days,is_active,created_at,updated_at. Full detail in the table section above. 10 seeded rows including2_10_net_30, which genuinely represents "2/10 net 30" (a bare CHECK-enum cannot). Deliberately pure global (notenant_id) — an earlier draft considered a tenant-scoped variant mirroringidentity.role's mixed-scope pattern, dropped as unrequested scope creep. Consumed bycrm.customer.payment_terms_id,purchasing.vendor.payment_terms_id,purchasing.purchase_order.payment_terms_id.
7 + 9 = 16 new columns. 120 + 16 = 136 columns, 10 + 2 = 12 tables — matched the header count at the top of this document at the time Phase 4 closed.
Post-Phase-4 addendum (2026-07-18, Phase 2): this 136-column/12-table tally is no longer current. Phase 2 (Nursery Vertical Extraction, PROJECT_DECISIONS.md #70) subsequently moved 4 tables / 61 columns (climate_zone, plant, plant_common_name, plant_climate_zone) out of this schema entirely, to a new nursery_ref schema. 136 − 61 = 75 columns, 12 − 4 = 8 tables — matching the header count at the top of this document today. This section is otherwise left unmodified as the historical record of Phase 4's own work.
Migrations: packages/db/migrations/20260709020000_phase4_item16_exchange_rates.sql (Item 16), packages/db/migrations/20260709030000_phase4_item17_enum_to_catalog.sql (Item 17, part b of a 4-part cross-module migration — parts a/c/d touch pos, tax, admin, not shared).
Tests: shared-schema.spec.ts gained 14 new tests (sections F–G) — exchange_rate's 3 CHECKs (positive rate, distinct currencies, unique pair+date) plus no-RLS/no-tenant_id confirmation; payment_terms_catalog seed spot-check plus no-RLS/no-tenant_id confirmation. 34/34 total for the file.
Independent verification: 2 separate adversarial lenses, both CLEAN — see PROJECT_DECISIONS.md #40 for the full findings (live-reproduced constraint behavior, confirmed zero destructive DDL, confirmed the interim enum→catalog sync gap is durably disclosed in Drizzle TypeScript source, not just migration comments).