crm — Phase 5 (module #5, first product module)
13 tables, 193 columns (was 189 at 2026-07-06 lock; +2 from the 2026-07-08 Remediation Phase 3 pass — see customer.credit_limit_cents/customer.credit_terms below; +2 more from the same-day Remediation Phase 4 pass — see customer.payment_terms_id/customer.pii_vault_ref below; column count unchanged 2026-07-18 — customer gained UNIQUE(id, tenant_id), constraint-only, the prerequisite for billing.gift_card/store_credit_account's composite FKs, Phase 3 stored-value build, PROJECT_DECISIONS #71) — schema-locked, migrated, and seeded 2026-07-06. crm is the first of the 13 nursery-vertical product modules to actually build under the v2 pipeline (modules #1–4 — platform, identity, shared, multi_loc — were foundation/service-layer modules). Covers customers (individual/business), their contacts and addresses, staff/agent notes and follow-up tasks, duplicate-customer merge workflow, marketing/consent tracking, tax-exemption certificates, and customer segmentation (both agent-computed segments and human free-form tags). Tenant-scoped throughout (one mixed-scope exception: customer_segment_definition). Depends on platform (tenant ownership), shared (global address/country/region reference data, plus the new payment_terms_catalog), multi_loc (site attribution on interaction records), and identity (actor-attribution FKs on every table, from day 1 — crm is the first module built entirely after the autonomy-first backfill, so it never had a "pre-autonomy" shape to retrofit).
PROJECT_DECISIONS entry: #23 (build), #28 (2026-07-07 reopen — 1 live-bug fix + 2 erosion fixes, see below), #39 (2026-07-08 Remediation Phase 3, Item 8 — customer credit columns, see below), #40 (2026-07-08 Remediation Phase 4, Items 17b/18 — customer.payment_terms_id/customer.pii_vault_ref, see below). Design rationale entries DR-33 through DR-49 (cited inline below).
Global rules for this schema:
- Tenant-scoped — every table carries
tenant_id NOT NULLFK →platform.tenant, with RLS enabled and a permissive tenant-isolation policy (USING/WITH CHECKoncurrent_setting('app.current_tenant_id')::uuid), exceptcustomer_segment_definition, which is mixed-scope (see its own section). - Soft delete on master-data and mutable-state tables (
deleted_at timestamptz, nullable); append-only, no soft delete on event/audit tables (customer_note,customer_consent,customer_merge— noupdated_at, nodeleted_at). updated_atis trigger-maintained viaplatform.set_updated_at()on every table that has anupdated_atcolumn (verified live:set_updated_atfires onaddress,contact,customer,customer_group,customer_merge_candidate,customer_segment_definition,customer_segment_membership,customer_task,customer_tax_certificate).- Agent-as-actor attribution from day 1 —
created_by_actor_id/updated_by_actor_id/reviewed_by_actor_id/ role-specific actor FKs (e.g.proposed_by_actor_id,verified_by_actor_id,computed_by_actor_id,merged_by_actor_id,captured_by_actor_id,assigned_to_actor_id) all targetidentity.actor(the polymorphic root), neveridentity.identity_userdirectly — same patternmulti_loc.siteestablished in the 2026-07-06 autonomy-first backfill (PROJECT_DECISIONS #19), applied here natively rather than retrofitted. - Autonomy treatment varies deliberately by table — full (
automation_source+review_status+decision_provenance+ reviewer FKs), light (created_by_actor_idonly), or none (human/Vrida-curated catalogs). See each table's section and Design Notes for the specific reasoning per table (DR-38, DR-41, DR-42, DR-44, DR-46). - Global address shape (DR-37) —
addressandcustomer_tax_certificate's issuing-jurisdiction fields use flat address lines + natural-key FKs intoshared.country(iso_alpha2,char(2)) andshared.administrative_region(iso_3166_2,text), mirroringmulti_loc.site's precedent exactly, including the region/country consistency CHECK pattern. - Three DEFERRED forward-refs —
customer.consumer_id,customer.search_vector,customer_tax_certificate.document_refare all plain columns with no FK / not yet a generated column, because the modules they'd reference (consumer,search,files) do not exist in this v2 database. Verified live viapsql \dn— as of this build the only schemas present areidentity,multi_loc,platform,shared,crm. Also verified via filesystem that noconsumer.md/rewards.md/offers.md/search.md/files.mdexist underdocs/database/schema_docs/. Any doc describing these as "locked" or "FK — READY" (e.g. olderMODULE_INDEX.md/CROSS_MODULE_CONTRACTS.mdentries dated 2026-06-11/12) is stale v1/pre-pivot documentation — see DR-34.
Cross-Phase Foreign Keys (crm)
| Column | Target | Notes |
|---|---|---|
*.tenant_id (all 13 tables) |
platform.tenant |
Intra-tenancy, enforced. |
customer.customer_group_id |
crm.customer_group |
Nullable. Intra-schema. customer_group defined before customer in migration order so this FK resolves directly. |
customer.consumer_id |
(none — deferred) | Plain nullable UUID, no FK. DR-34: consumer schema does not exist in v2. |
contact.customer_id, address.customer_id, customer_note.customer_id, customer_task.customer_id, customer_merge_candidate.customer_a_id/customer_b_id, customer_merge.source_customer_id/target_customer_id, customer_consent.customer_id, customer_tax_certificate.customer_id, customer_segment_membership.customer_id, customer_tag_assignment.customer_id |
crm.customer |
Intra-schema, enforced. |
customer_merge.candidate_id |
crm.customer_merge_candidate |
Nullable — a merge need not originate from a logged candidate. |
customer_segment_membership.segment_definition_id |
crm.customer_segment_definition |
Real FK, not bare text — per resolved decision to add the catalog table. |
address.region_code, customer_tax_certificate.issuing_region_code |
shared.administrative_region.iso_3166_2 |
Nullable, ON DELETE SET NULL. Cross-schema, enforced. |
address.country_code |
shared.country.iso_alpha2 |
NOT NULL (was nullable — DR-48, 2026-07-07), ON DELETE RESTRICT (was SET NULL — a NOT NULL column can't absorb a deleted-country cascade). Cross-schema, enforced. |
customer_tax_certificate.issuing_country_code |
shared.country.iso_alpha2 |
NOT NULL, no default (DR-49, 2026-07-07 — the silent DEFAULT 'US' was dropped; callers must now state the country explicitly). No ON DELETE action set — verified live. |
customer_note.site_id, customer_task.site_id |
multi_loc.site |
Nullable. Cross-schema, enforced. Deliberate narrow exception to "master data doesn't carry site_id" (DR-39) — these are event/interaction records. |
*_actor_id FKs (all roles: created_by, updated_by, reviewed_by, proposed_by, verified_by, computed_by, merged_by, captured_by, assigned_to) |
identity.actor |
Nullable throughout. Cross-schema, enforced. |
customer_tax_certificate.document_ref |
(none — deferred) | Plain nullable text, no FK. DR-34: files schema does not exist in v2. |
customer.search_vector |
(not built at all this pass) | DR-34: search schema does not exist in v2; no generated tsvector column exists anywhere in v2 yet either (no Drizzle precedent to follow). |
customer.payment_terms_id |
shared.payment_terms_catalog |
Added 2026-07-08 (Remediation Phase 4, Item 17b). Nullable, cross-schema, enforced. Additive-interim alongside the unchanged credit_terms CHECK-enum — NOT a clean vocabulary subset (see customer table notes). |
customer.pii_vault_ref |
(none — vault service not yet built) | Added 2026-07-08 (Remediation Phase 4, Item 18). Plain nullable text, no FK — mirrors platform.tenant_profile.ein_ref. |
crm.customer_group
Tenant-defined customer segments used by both CRM and Pricing (pricing.price_list_assignment.customer_group_id, forward-looking — Pricing not yet built). Defined before customer in the same migration file specifically so customer.customer_group_id can FK it directly. A simple, human-curated catalog — deliberately carries no autonomy columns at all (no created_by_actor_id, no automation_source, nothing), on the same reasoning as customer_segment_definition's no-autonomy design (catalog definitions are human/Vrida-curated, never agent-written).
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycustomer_group_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.Soft delete:
deleted_at timestamptz, nullable.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
code |
text | NOT NULL | — | UNIQUE per tenant (partial-unique, see indexes) |
name |
text | NOT NULL | — | |
description |
text | nullable | — | |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
deleted_at |
timestamptz | nullable | — | Soft delete |
Indexes:
- PK on
id - Plain index on
tenant_id - Partial UNIQUE on (
tenant_id,code) WHEREdeleted_at IS NULL
crm.customer
The core entity of the module. Individual/business customers are modeled as one table, not two, distinguished by the customer_type discriminator — preserving v1's rationale rather than splitting into separate tables. Full autonomy treatment (actor attribution, automation_source, is_verified, review seam, decision_provenance) — the richest autonomy shape of any table in this module, since customer is the primary target of CRM's AI-enrichment and dedup capabilities.
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycustomer_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.Soft delete:
deleted_at timestamptz, nullable.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_number |
text | nullable | — | UNIQUE per tenant when set (partial-unique, see indexes) |
customer_type |
text | NOT NULL | — | CHECK IN (individual,business). Discriminator — one table, not two. |
display_name |
text | NOT NULL | — | |
first_name |
text | nullable | — | Required (with last_name) when customer_type = 'individual' — see table CHECK below |
last_name |
text | nullable | — | See table CHECK below |
company_name |
text | nullable | — | Required when customer_type = 'business' — see table CHECK below |
email |
text | nullable | — | Deliberately NOT unique (DR-33) |
email_normalized |
text | nullable | — | Lowercased/trimmed. Deliberately NOT unique (DR-33) — dedup is the explicit merge workflow, never a DB constraint |
phone |
text | nullable | — | |
customer_group_id |
UUID | nullable | — | FK → crm.customer_group |
consumer_id |
UUID | nullable | — | DEFERRED forward-ref, plain UUID, NO FK (DR-34) — the consumer schema doesn't exist in this v2 database (verified live) |
status |
text | NOT NULL | 'active' |
CHECK IN (active,inactive) |
tax_exempt |
boolean | NOT NULL | false |
|
source |
text | nullable | — | CHECK IN (walk_in,pos,import,online,app,manual) OR NULL |
marketing_opt_in |
boolean | NOT NULL | false |
Current-state cache; event history lives in customer_consent |
last_activity_at |
timestamptz | nullable | — | |
notes |
text | nullable | — | Freeform. (Structured, timestamped observations belong in customer_note instead.) |
attributes |
jsonb | nullable | '{}' |
Open extensibility bag |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
updated_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
is_verified |
boolean | NOT NULL | true |
Defaults true, not shared.plant's false (DR-36) — staff/POS/import-entered data is presumptively trustworthy; per-field trust for AI-written fields is tracked in decision_provenance instead |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | Field-level provenance — supports mixed per-field trust within one row (DR-36) |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
deleted_at |
timestamptz | nullable | — | Soft delete |
credit_limit_cents |
bigint | nullable | — | Added 2026-07-08 (Remediation Phase 3, Item 8). NULL = no credit extended (fail-closed). CHECK chk_customer_credit_limit_nonnegative |
credit_terms |
text | nullable | — | Added 2026-07-08 (Remediation Phase 3, Item 8). CHECK chk_customer_credit_terms IN (due_on_receipt,net_15,net_30,net_45,net_60,net_90) |
payment_terms_id |
UUID | nullable | — | Added 2026-07-08 (Remediation Phase 4, Item 17b). FK → shared.payment_terms_catalog. Additive-interim alongside the unchanged credit_terms CHECK-enum — NOT yet kept in sync (see disclosure below) |
pii_vault_ref |
text | nullable | — | Added 2026-07-08 (Remediation Phase 4, Item 18). Mirrors platform.tenant_profile.ein_ref's vault-reference pattern, enabling future crypto-shred GDPR/CCPA erasure |
Removed vs. v1, then partially reinstated (DR-35, corrected 2026-07-08 by Remediation Phase 3 Item 8): credit_terms/credit_limit_cents were dropped entirely at 2026-07-06 lock, on the stated rationale that credit-worthiness is Billing's domain. That rationale was itself found to be a bug, not a real decision: billing.ar_account's own column comment already claimed it "reads from crm.customer, never duplicated" — but crm.customer never actually had these columns to read from. Remediation Phase 3 (PROJECT_DECISIONS #39) restores both columns here, nullable, with the corrected ownership: crm.customer OWNS credit-worthiness (a relationship judgment about the customer, squarely crm's domain) and billing.ar_account READS credit_limit_cents/credit_terms from here, never duplicating them. tax_exempt_id remains dropped (already deprecated in v1, unaffected by this correction).
Remediation Phase 4, Item 17b (2026-07-08) — payment_terms_id, additive-interim, NOT a clean vocabulary subset: shared.payment_terms_catalog (10 seeded rows, e.g. cod, prepaid, net_7, net_15, net_30, net_45, net_60, net_90, 2_10_net_30, and one more — each row carries net_days/discount_percent/discount_days, letting 2_10_net_30 genuinely represent "2/10 net 30", which a bare CHECK-enum cannot) is added as a global (no tenant_id) reference table, with customer.payment_terms_id as a new, independently-nullable FK alongside the existing credit_terms CHECK-enum — the enum itself is completely unchanged. Disclosed explicitly: the two are NOT a clean subset of each other. The catalog's 4 codes cod/prepaid/net_7/2_10_net_30 have no credit_terms equivalent at all, so a future cutover to the catalog as sole source of truth requires a real mapping decision for those 4 codes, not a trivial 1:1 rename. This interim gap (the new FK column is not yet constrained to stay in sync with the legacy CHECK-enum column) is disclosed in the Drizzle TypeScript source directly, not just a migration-file comment.
Remediation Phase 4, Item 18 (2026-07-08) — pii_vault_ref, shares (does not duplicate) an existing OPEN_ITEMS dependency: mirrors platform.tenant_profile.ein_ref's vault-reference pattern — a nullable pointer to an out-of-band vault service, enabling future crypto-shred erasure (GDPR/CCPA "right to be forgotten") without storing the sensitive value itself in this table. This column shares the same vault-service-not-yet-built dependency already logged in OPEN_ITEMS for ein_ref — no new vault-service OPEN_ITEMS row was created for this column; the Drizzle comment explicitly cross-references the existing one instead of duplicating it.
Deferred, not built this pass: search_vector (Search-module FTS touch) — the search schema doesn't exist in v2 (verified live), and no v2-built table anywhere uses a generated tsvector column yet, so there's no Drizzle precedent to follow either (DR-34).
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_customer_type |
customer_type IN ('individual','business') |
chk_customer_status |
status IN ('active','inactive') |
chk_customer_source |
source IS NULL OR source IN ('walk_in','pos','import','online','app','manual') |
chk_customer_automation_source |
automation_source IN ('human','agent','system','seed') |
chk_customer_review_status |
review_status IN ('not_required','pending','approved','rejected') |
chk_customer_type_identity_fields |
(customer_type = 'individual' AND (first_name IS NOT NULL OR last_name IS NOT NULL)) OR (customer_type = 'business' AND company_name IS NOT NULL) |
chk_customer_credit_limit_nonnegative |
credit_limit_cents IS NULL OR credit_limit_cents >= 0 — added 2026-07-08 (Remediation Phase 3, Item 8) |
chk_customer_credit_terms |
credit_terms IS NULL OR credit_terms IN ('due_on_receipt','net_15','net_30','net_45','net_60','net_90') — added 2026-07-08 (Remediation Phase 3, Item 8) |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on (
tenant_id,display_name) — name search - Plain index on (
tenant_id,email_normalized) — email lookup - Plain index on (
tenant_id,phone) — phone lookup - Plain index on
customer_group_id - Partial index on
consumer_idWHERE NOT NULL - Partial index on
review_statusWHERE= 'pending'— review-queue query - Partial UNIQUE on (
tenant_id,customer_number) WHEREcustomer_number IS NOT NULL AND deleted_at IS NULL - Partial index on
payment_terms_idWHERE NOT NULL — added 2026-07-08 (Remediation Phase 4, Item 17b)
crm.contact
Additional named contacts per customer — e.g. a purchasing agent or site manager at a business-type customer. A subordinate table with a light autonomy touch only (created_by_actor_id, nothing else) — no distinct AI capability targets it this build pass.
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycontact_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.Soft delete:
deleted_at timestamptz, nullable.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
display_name |
text | NOT NULL | — | |
first_name |
text | nullable | — | |
last_name |
text | nullable | — | |
email |
text | nullable | — | |
email_normalized |
text | nullable | — | |
phone |
text | nullable | — | |
role_title |
text | nullable | — | e.g. "Purchasing Manager" |
is_primary |
boolean | NOT NULL | false |
At most one true per customer (partial-unique, see indexes) |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
deleted_at |
timestamptz | nullable | — | Soft delete |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on
customer_id - Partial UNIQUE on
customer_idWHEREis_primary = true AND deleted_at IS NULL
crm.address
Billing/shipping addresses for a customer. Uses the GLOBAL address shape (DR-37): address_line1/address_line2 + city + region_code (FK → shared.administrative_region.iso_3166_2) + country_code (FK → shared.country.iso_alpha2, char(2)) — mirroring multi_loc.site's exact precedent, including the region/country consistency CHECK (chk_address_region_country_match). region_code is nullable (not every address has a state/province); country_code is NOT NULL (DR-48, added 2026-07-07 — restores v1's NOT NULL country_id guarantee, which this table had originally shipped without; the crm/inventory/pricing erosion audit found an address with neither country_code nor region_code set passed chk_address_region_country_match trivially). Light autonomy touch only (created_by_actor_id).
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policyaddress_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.Soft delete:
deleted_at timestamptz, nullable.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
address_type |
text | NOT NULL | — | CHECK IN (billing,shipping,both) |
address_line1 |
text | NOT NULL | — | |
address_line2 |
text | nullable | — | |
city |
text | nullable | — | |
region_code |
text | nullable | — | FK → shared.administrative_region.iso_3166_2, ON DELETE SET NULL |
postal_code |
text | nullable | — | Free text — not zip-shaped |
country_code |
char(2) | NOT NULL (DR-48) | — | FK → shared.country.iso_alpha2, ON DELETE RESTRICT (was SET NULL) |
is_default |
boolean | NOT NULL | false |
At most one true per (customer_id, address_type) — partial-unique, see indexes |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_address_address_type |
address_type IN ('billing','shipping','both') |
chk_address_region_country_match |
region_code IS NULL OR (country_code IS NOT NULL AND left(region_code, 2) = country_code) |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on
customer_id - Plain index on (
customer_id,address_type) - Plain index on
country_code - Partial index on
region_codeWHERE NOT NULL - Partial UNIQUE on (
customer_id,address_type) WHEREis_default = true AND deleted_at IS NULL
crm.customer_note
Append-only staff/agent observation log against a customer — no updated_at, no deleted_at. Has automation_source + decision_provenance but explicitly NO review_status (DR-38) — logging a note is may-act-alone, additive, zero mutation risk; a formal review gate would be bureaucratic overhead. Has an optional site_id (FK → multi_loc.site) — a deliberate, narrow exception to "master data doesn't carry site_id" (DR-39), because this is an event/interaction record, not master data, and multi-site tenants need to know where an interaction happened.
Tenant-scoped, append-only.
tenant_id NOT NULLFK →platform.tenant. Noupdated_at, nodeleted_at. RLS enabled — permissive policycustomer_note_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
site_id |
UUID | nullable | — | FK → multi_loc.site. Deliberate exception to "master data doesn't carry site_id" (DR-39) |
note_type |
text | nullable | — | CHECK IN (call,email,visit,general) OR NULL |
body |
text | NOT NULL | — | |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
decision_provenance |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
No review_status — DR-38: a note is append-only and additive; dismissing/completing isn't applicable since there's nothing to act on. Deliberate exception, not an oversight.
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_note_note_type |
note_type IS NULL OR note_type IN ('call','email','visit','general') |
chk_customer_note_automation_source |
automation_source IN ('human','agent','system','seed') |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on (
customer_id,created_at) - Partial index on
site_idWHERE NOT NULL
crm.customer_task
Follow-up tasks against a customer — human- or agent-suggested. Has a status lifecycle (open/completed/dismissed) instead of a review_status seam (DR-38, folded per DR-40) — dismissing or completing the task is the review mechanism. Has an optional site_id too, same DR-39 exception as customer_note.
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycustomer_task_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.No soft delete — task lifecycle is fully captured by
status; verified live, nodeleted_atcolumn exists.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
site_id |
UUID | nullable | — | FK → multi_loc.site. Same DR-39 exception as customer_note.site_id |
title |
text | NOT NULL | — | |
description |
text | nullable | — | |
assigned_to_actor_id |
UUID | nullable | — | FK → identity.actor |
due_at |
timestamptz | nullable | — | |
status |
text | NOT NULL | 'open' |
CHECK IN (open,completed,dismissed). IS the review mechanism (DR-38/DR-40) |
completed_at |
timestamptz | nullable | — | |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
decision_provenance |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
No review_status — DR-38/DR-40: the task's own state machine (open/completed/dismissed) is its review mechanism; a parallel review_status column would be redundant.
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_task_status |
status IN ('open','completed','dismissed') |
chk_customer_task_automation_source |
automation_source IN ('human','agent','system','seed') |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on
customer_id - Partial index on
site_idWHERE NOT NULL - Partial index on (
assigned_to_actor_id,status) WHEREstatus = 'open'— "my open tasks" query - Partial index on
due_atWHEREstatus = 'open'— due-soon sweep
crm.customer_merge_candidate
A pending dedup proposal awaiting human review — the review gateway for merges. review_status DEFAULTs 'pending' (not 'not_required' like every other table in this module), since every row here exists because it needs review. Full autonomy treatment. Kept deliberately SEPARATE from customer_merge (DR-41) rather than unifying with a review_status column bolted onto the append-only audit table, because most candidates are REJECTED and never become a customer_merge row at all — genuinely different lifecycle (mutable pending→approved/rejected vs. permanent audit fact) and cardinality.
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycustomer_merge_candidate_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.Soft delete: none — verified live, no
deleted_atcolumn. Lifecycle is fully captured byreview_status.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_a_id |
UUID | NOT NULL | — | FK → crm.customer. CHECK <> customer_b_id (no self-match) |
customer_b_id |
UUID | NOT NULL | — | FK → crm.customer. CHECK <> customer_a_id (no self-match) |
match_basis |
text | nullable | — | CHECK IN (email,phone,name_fuzzy,address,manual) OR NULL |
confidence_score |
numeric(3,2) | nullable | — | |
proposed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'pending' |
CHECK IN (pending,approved,rejected). Defaults pending, not not_required — every row here needs review by construction |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_merge_candidate_self_match |
customer_a_id <> customer_b_id |
chk_customer_merge_candidate_match_basis |
match_basis IS NULL OR match_basis IN ('email','phone','name_fuzzy','address','manual') |
chk_customer_merge_candidate_review_status |
review_status IN ('pending','approved','rejected') — note: no not_required value here, unlike other review-seam tables, since a candidate always needs review |
chk_customer_merge_candidate_automation_source |
automation_source IN ('human','agent','system','seed') |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on
customer_a_id - Plain index on
customer_b_id - Partial index on
review_statusWHERE= 'pending'— review-queue query
crm.customer_merge
Append-only POST-EXECUTION merge audit record — no updated_at, no deleted_at. By the time a row exists here, review already happened on customer_merge_candidate (FK'd via candidate_id, nullable — a merge need not originate from a logged candidate). Per the D7 rule, merge execution is always needs-approval, never may-act-alone.
Tenant-scoped, append-only.
tenant_id NOT NULLFK →platform.tenant. Noupdated_at, nodeleted_at. RLS enabled — permissive policycustomer_merge_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
source_customer_id |
UUID | NOT NULL | — | FK → crm.customer. The record merged away. CHECK <> target_customer_id |
target_customer_id |
UUID | NOT NULL | — | FK → crm.customer. The surviving record. CHECK <> source_customer_id |
merged_by_actor_id |
UUID | nullable | — | FK → identity.actor |
candidate_id |
UUID | nullable | — | FK → crm.customer_merge_candidate. Nullable — merge need not originate from a logged candidate |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
reason |
text | nullable | — | |
metadata |
jsonb | nullable | — | Snapshot supporting reversal in principle (see OPEN_ITEMS — no enforcement built this pass) |
decision_provenance |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_merge_self_merge |
source_customer_id <> target_customer_id |
chk_customer_merge_automation_source |
automation_source IN ('human','agent','system','seed') |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on
source_customer_id - Plain index on
target_customer_id - Partial index on
candidate_idWHERE NOT NULL
crm.customer_consent
Append-only consent event log — no updated_at, no deleted_at. Current opt-in state is cached on customer.marketing_opt_in; this table is the historical record of each consent action. Gets NO review seam at all (DR-42, an explicit exception) — consent is a captured FACT (the customer's own opted-in/opted-out action), not an inference, so there is structurally nothing to review-gate. Per the D7 rule, consent must NEVER be AI-initiated.
Tenant-scoped, append-only.
tenant_id NOT NULLFK →platform.tenant. Noupdated_at, nodeleted_at. RLS enabled — permissive policycustomer_consent_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
consent_type |
text | NOT NULL | — | CHECK IN (marketing_email,sms,phone,privacy_terms,loyalty) |
channel |
text | nullable | — | CHECK IN (email,sms,phone,in_app,pos) OR NULL |
status |
text | NOT NULL | — | CHECK IN (opted_in,opted_out). No default — must be explicitly stated (verified live: no column default) |
source |
text | nullable | — | CHECK IN (pos,import,online,manual,unsubscribe_link,app) OR NULL |
captured_at |
timestamptz | NOT NULL | — | No default — the actual moment consent was captured, must be supplied explicitly (verified live: no column default) |
captured_by_actor_id |
UUID | nullable | — | FK → identity.actor. NEVER an agent by the D7 rule — consent must reflect the customer's own action, though this is app-enforced, not a DB CHECK |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
metadata |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
No review seam of any kind — DR-42: consent is a captured fact, never an inference; nothing to review-gate structurally.
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_consent_consent_type |
consent_type IN ('marketing_email','sms','phone','privacy_terms','loyalty') |
chk_customer_consent_channel |
channel IS NULL OR channel IN ('email','sms','phone','in_app','pos') |
chk_customer_consent_status |
status IN ('opted_in','opted_out') |
chk_customer_consent_source |
source IS NULL OR source IN ('pos','import','online','manual','unsubscribe_link','app') |
chk_customer_consent_automation_source |
automation_source IN ('human','agent','system','seed') |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on (
customer_id,consent_type,captured_at)
crm.customer_tax_certificate
Tax-exemption certificates held by a customer. Global jurisdiction fields (issuing_country_code + issuing_region_code) use the same DR-37 pattern as address. status DEFAULTs 'pending_review', not v1's 'active' (DR-43) — an unverified certificate (especially one a future OCR-extraction agent might draft from a photo) must never default to trusted. REUSES the existing status/verified_by_actor_id/verified_at columns as the review seam rather than adding a parallel review_status column set (DR-44) — v1 already built an equivalent gate, so a duplicate structure would be redundant.
LIVE BUG fixed 2026-07-07 (DR-49): the crm/inventory/pricing erosion audit found the DR-43 default above only suggested the review workflow — nothing enforced it. A row could be inserted with status='active' (meaning "this customer is currently tax-exempt") while verified_by_actor_id/verified_at/document_ref were all NULL, recording an unverified, nobody-signed-off exemption. chk_customer_tax_certificate_active_requires_verification now closes this: status='active' requires both verified_by_actor_id and verified_at non-null. Separately, issuing_country_code's DEFAULT 'US' (below) was dropped for the same reason — a silently-assumed country is the same class of bug as a silently-assumed verification.
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycustomer_tax_certificate_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.Soft delete:
deleted_at timestamptz, nullable.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
certificate_number |
text | nullable | — | |
certificate_type |
text | NOT NULL | — | CHECK IN (resale,agricultural,non_profit,government,other) |
issuing_country_code |
char(2) | NOT NULL | (none — dropped 2026-07-07, DR-49) | FK → shared.country.iso_alpha2. Was DEFAULT 'US'; the silent default was dropped so an omitted country is rejected (NOT NULL violation) rather than silently recorded as domestic |
issuing_region_code |
text | nullable | — | FK → shared.administrative_region.iso_3166_2, ON DELETE SET NULL |
valid_from |
date | NOT NULL | — | |
expires_at |
date | nullable | — | |
document_ref |
text | nullable | — | DEFERRED forward-ref, plain text, NO FK (DR-34) — the files schema doesn't exist in v2 |
status |
text | NOT NULL | 'pending_review' |
CHECK IN (active,expired,revoked,pending_review). Defaults pending_review, changed from v1's active (DR-43) |
verified_by_actor_id |
UUID | nullable | — | FK → identity.actor. Reused as the review seam (DR-44) — no separate reviewed_by_actor_id |
verified_at |
timestamptz | nullable | — | Reused as the review seam (DR-44) — no separate reviewed_at |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
decision_provenance |
jsonb | nullable | — | |
note |
text | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
deleted_at |
timestamptz | nullable | — | Soft delete |
Review seam (DR-44): this table reuses its existing status(pending_review)/verified_by_actor_id/verified_at columns as the review gate rather than adding a parallel review_status/reviewed_by_actor_id/reviewed_at set — v1 already built an equivalent gate.
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_tax_certificate_certificate_type |
certificate_type IN ('resale','agricultural','non_profit','government','other') |
chk_customer_tax_certificate_status |
status IN ('active','expired','revoked','pending_review') |
chk_customer_tax_certificate_region_country_match |
issuing_region_code IS NULL OR (issuing_country_code IS NOT NULL AND left(issuing_region_code, 2) = issuing_country_code) |
chk_customer_tax_certificate_automation_source |
automation_source IN ('human','agent','system','seed') |
chk_customer_tax_certificate_active_requires_verification |
status <> 'active' OR (verified_by_actor_id IS NOT NULL AND verified_at IS NOT NULL) — added 2026-07-07 (DR-49), closes the live bug described above |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on
customer_id - Plain index on (
certificate_type,status) - Partial index on
expires_atWHEREdeleted_at IS NULL - TWO partial UNIQUE indexes —
customer_tax_certificate_no_region_uniqueon (customer_id,certificate_type,issuing_country_code) WHEREissuing_region_code IS NULL AND deleted_at IS NULL AND status = 'active', andcustomer_tax_certificate_with_region_uniqueon (customer_id,certificate_type,issuing_country_code,issuing_region_code) WHEREissuing_region_code IS NOT NULL AND deleted_at IS NULL AND status = 'active'. Deliberately split this way to avoid the NULL-in-multi-column-unique trap (same bug class caught inidentity.agent_duty_grant's original design, PROJECT_DECISIONS #22) — a single unique index including the nullableissuing_region_codecolumn directly would fail to catch duplicates whenever the column is NULL, since Postgres treats NULL as distinct from NULL in uniqueness checks.
crm.customer_segment_definition
NEW table (not carried from v1) — catalog of segments. Mirrors identity.role's mixed-scope pattern exactly (DR-45): tenant_id is nullable (NULL = Vrida-shipped built-in segment such as vip/at_risk/seasonal/wholesale_like; populated = tenant-custom segment). Uses the same two-partial-unique-index shape identity.role uses to avoid the NULL-uniqueness trap. No autonomy columns at all — catalog definitions are human/Vrida-curated, never agent-written (same reasoning as customer_group).
Mixed-scope — tenant-scoped OR global, depending on
tenant_id.tenant_idnullable, FK →platform.tenant.RLS enabled — permissive policy
customer_segment_definition_tenant_isolationonauthenticated:USING/WITH CHECK ((tenant_id IS NULL) OR (tenant_id = current_setting('app.current_tenant_id')::uuid))— thetenant_id IS NULLbranch is load-bearing, same asidentity.role; omitting it would hide all built-in segments from every tenant.Soft delete:
deleted_at timestamptz, nullable.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | nullable | — | NULL = Vrida-shipped built-in segment; populated = tenant-custom. FK → platform.tenant |
code |
text | NOT NULL | — | e.g. vip, at_risk, seasonal, wholesale_like |
name |
text | NOT NULL | — | |
description |
text | nullable | — | |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
deleted_at |
timestamptz | nullable | — | Soft delete |
Indexes:
- PK on
id - Plain index on
tenant_id— covers the(tenant_id IS NULL OR tenant_id = $1)RLS disjunction query pattern - Partial UNIQUE on (
tenant_id,code) WHEREdeleted_at IS NULL—customer_segment_definition_tenant_id_code_unique, constrains custom tenant segments only - Partial UNIQUE on
codeWHEREtenant_id IS NULL AND deleted_at IS NULL—customer_segment_definition_builtin_code_unique, prevents duplicate built-in codes (NULLtenant_idrows don't collide under the standard partial unique — same NULL-uniqueness trapidentity.rolealready solved)
Not seeded this pass — no Vrida-default segment definitions (vip/at_risk/seasonal/wholesale_like) exist as rows yet; this is schema-only (see OPEN_ITEMS).
crm.customer_segment_membership
Customer × segment membership. segment_definition_id is a real FK into customer_segment_definition (not bare CHECK-less text), per the resolved decision to add the catalog table. Full autonomy treatment (confidence_score, computed_by_actor_id, automation_source, full review seam, decision_provenance) — this is the primary home for the D6/D7 agent-computed-segment capability (VIP/at-risk/seasonal/wholesale-like classification).
Uses a stored status column (active/expired), NOT a WHERE expires_at > now() partial-index predicate (DR-47) — Postgres requires index predicates to be IMMUTABLE, and now() is only STABLE, so such an index fails to create at all (ERROR: functions in index predicate must be marked IMMUTABLE, confirmed empirically before this migration was written). This matches the existing convention identity.role_assignment.ends_at/identity.agent_duty_grant.ends_at already use — expiry is checked on-read or by a service-layer sweep, never computed live inside a partial index.
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycustomer_segment_membership_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.No soft delete — verified live, no
deleted_atcolumn; lifecycle fully captured bystatus.
updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
segment_definition_id |
UUID | NOT NULL | — | FK → crm.customer_segment_definition. Real FK, not bare text |
confidence_score |
numeric(3,2) | nullable | — | |
status |
text | NOT NULL | 'active' |
CHECK IN (active,expired). Stored, not derived from expires_at at query time (DR-47) |
computed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | |
starts_at |
timestamptz | NOT NULL | now() |
|
expires_at |
timestamptz | nullable | — | NULL = no expiry. Never referenced inside an index predicate (DR-47) — checked on-read/by sweep only |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained via platform.set_updated_at() |
CHECK constraints (verified live):
| Name | Condition |
|---|---|
chk_customer_segment_membership_status |
status IN ('active','expired') |
chk_customer_segment_membership_automation_source |
automation_source IN ('human','agent','system','seed') |
chk_customer_segment_membership_review_status |
review_status IN ('not_required','pending','approved','rejected') |
Indexes:
- PK on
id - Plain index on
tenant_id - Partial index on
customer_idWHEREstatus = 'active' - Partial index on
expires_atWHEREexpires_at IS NOT NULL AND status = 'active'— expiry sweep candidate list (predicate references only stored columns, notnow()— DR-47 compliant) - Partial index on
review_statusWHERE= 'pending'— review-queue query - Partial UNIQUE on (
customer_id,segment_definition_id) WHEREstatus = 'active'— at most one active membership per customer per segment
crm.customer_tag_assignment
NEW table (not carried from v1) — human free-form labels (e.g. "Master Gardener", "Garden Club"). Deliberately SEPARATE from customer_segment_membership (DR-46, per the resolved decision to split tags and segments) — no catalog, no confidence, no review seam; the lightest-weight table in the module. tag_name (as-entered) + tag_name_normalized (lowercased/trimmed, dedup key) follows the same pattern as customer.email/email_normalized. Soft-delete (deleted_at), not append-only — removing a tag is a light, reversible staff action, unlike the append-only event tables in this module.
Tenant-scoped.
tenant_id NOT NULLFK →platform.tenant. RLS enabled — permissive policycustomer_tag_assignment_tenant_isolationonauthenticated,USING/WITH CHECKagainstcurrent_setting('app.current_tenant_id')::uuid.Soft delete:
deleted_at timestamptz, nullable.No
updated_at— verified live, noupdated_atcolumn; a tag assignment is either present or soft-deleted, never edited in place.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | FK → crm.customer |
tag_name |
text | NOT NULL | — | As-entered display value |
tag_name_normalized |
text | NOT NULL | — | Lowercased/trimmed — dedup key, same pattern as customer.email/email_normalized |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
created_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete — reversible staff action, not an audit event |
Indexes:
- PK on
id - Plain index on
tenant_id - Plain index on
customer_id - Partial UNIQUE on (
customer_id,tag_name_normalized) WHEREdeleted_at IS NULL— one active instance of a given tag per customer
crm — Design Patterns Summary
Column-count reconciliation (verified live via information_schema.columns, schema crm)
| Table | Cols |
|---|---|
customer_group |
10 |
customer |
36 |
contact |
15 |
address |
15 |
customer_note |
10 |
customer_task |
15 |
customer_merge_candidate |
15 |
customer_merge |
11 |
customer_consent |
12 |
customer_tax_certificate |
19 |
customer_segment_definition |
10 |
customer_segment_membership |
17 |
customer_tag_assignment |
8 |
| Total | 193 |
Remediation Phase 2 (2026-07-08)
Cross-cutting Remediation Plan Phase 2 (Item 6) changed the id column DEFAULT on crm's three append-only event/audit logs — customer_note, customer_consent, customer_merge — from gen_random_uuid() to platform.uuid_generate_v7(). This is a PK-generation-strategy change only: no column added, no column dropped, no type change. Reasoning: UUIDv7 is time-ordered, which keeps future time-range partitioning available on these append-only ledgers without a PK rewrite — a option that becomes impossible once rows have already landed on a random (time-unordered) UUIDv4 PK. crm.customer_tag_assignment was considered for the same change and explicitly excluded — it is soft-delete (carries deleted_at), not append-only, so it doesn't fit the rationale. No other crm table was touched. Column count is unaffected — still 189 (DEFAULT-only change). See PROJECT_DECISIONS #38 for the full cross-module record.
Remediation Phase 3 (2026-07-08)
Remediation Plan Phase 3, Item 8 added two nullable columns to crm.customer: credit_limit_cents (bigint, CHECK chk_customer_credit_limit_nonnegative — NULL or >= 0) and credit_terms (text, CHECK chk_customer_credit_terms IN due_on_receipt/net_15/net_30/net_45/net_60/net_90). This is a corrective fix, not a new feature: billing.ar_account's own column comment already stated it "reads from crm.customer, never duplicated," but crm.customer never actually carried these columns — DR-35's claim (2026-07-06 lock) that they were "REMOVED and reassigned to billing entirely" was itself the bug, not a real ownership decision. The corrected, now-enforced ownership: crm.customer owns credit-worthiness (a relationship judgment about the customer — squarely crm's domain), and billing.ar_account reads credit_limit_cents/credit_terms from here rather than duplicating them locally. Additive change against 60 live customer rows at build time; both columns nullable (NULL = no credit extended, fail-closed default). Service-layer enforcement — blocking a charge that would exceed the limit — is deferred to OPEN_ITEMS, not built this pass. crm is now 13 tables / 191 columns (up from 189). Migration: packages/db/migrations/20260708210000_phase3_item8_customer_credit_terms.sql. See PROJECT_DECISIONS #39.
Remediation Phase 4 (2026-07-08)
Remediation Plan Phase 4 (the final phase of the 4-phase plan, items 14–20 plus closing the Phase 3 anonymous-return decision) touched crm via 2 sub-items, both additive nullable columns on crm.customer — no new tables, no CHECKs, no destructive DDL:
- Item 17b — Enum→Catalog, additive interim:
customer.payment_terms_id(UUID, nullable) FKs to the newshared.payment_terms_catalog(10 seeded rows —cod,prepaid,net_7,net_15,net_30,net_45,net_60,net_90,2_10_net_30, plus one more — each carryingnet_days/discount_percent/discount_days, so2_10_net_30can genuinely represent "2/10 net 30", something a bare CHECK-enum cannot). This sits alongside, not in place of, the existingcustomer.credit_termsCHECK-enum from Phase 3 — that enum is completely unchanged. Disclosed explicitly, not glossed over: the two are NOT a clean vocabulary subset of each other. The catalog's 4 codescod/prepaid/net_7/2_10_net_30have nocredit_termsequivalent at all, so a future cutover to the catalog as sole source of truth needs a real mapping decision for those 4 codes — not a trivial 1:1 rename. This interim sync gap is disclosed durably in the Drizzle TypeScript source itself (crm/customer.ts), not just a migration-file SQL comment that could rot if migrations are ever squashed. - Item 18 — GDPR/Erasure:
customer.pii_vault_ref(text, nullable) mirrorsplatform.tenant_profile.ein_ref's vault-reference pattern — a pointer to an out-of-band vault service, enabling future crypto-shred erasure (the GDPR/CCPA "right to be forgotten") without this table ever storing the sensitive value directly. Shares, does not duplicate, the existingein_refvault-service-not-yet-built OPEN_ITEMS dependency — no new vault-service OPEN_ITEMS row was created for this column; the Drizzle comment explicitly cross-references the existing one.
Both columns nullable and additive against live data — zero backfill risk, zero DROP TABLE/DROP COLUMN anywhere in this phase (independently confirmed by adversarial verification). crm is now 13 tables / 193 columns (up from 191). Migrations: packages/db/migrations/20260709030000_phase4_item17_enum_to_catalog.sql (part b, customer.payment_terms_id) and packages/db/migrations/20260709040000_phase4_item18_gdpr_erasure.sql (customer.pii_vault_ref). Independently verified by 2 adversarial lenses, both CLEAN — 5 new regression tests added to crm-schema.spec.ts (section P). See PROJECT_DECISIONS #40.
First product module — foundation vs. product
crm is the first of the 13 nursery-vertical product modules to build under the v2 pipeline. Modules #1–4 (platform, identity, shared, multi_loc) were foundation/service-layer modules — tenancy, auth, global reference data, and physical locations, none of them customer-facing business domains. crm is the first module whose tables model an actual retail business concept (customers) rather than platform infrastructure.
Autonomy treatment varies deliberately by table, not uniformly
Unlike a blanket "every table gets the full autonomy shape" approach, crm applies three distinct tiers deliberately:
- Full (actor attribution +
automation_source+ review seam +decision_provenance):customer,customer_merge_candidate,customer_segment_membership. These are the tables where AI enrichment/dedup/segmentation actually write. - Light (
created_by_actor_idonly, noautomation_source/review seam):contact,address. Subordinate tables with no distinct AI capability targeting them this pass. - None (no autonomy columns whatsoever):
customer_group,customer_segment_definition. Human/Vrida-curated catalogs — never agent-written. - Append-only with
automation_source+decision_provenancebut explicitly no review seam:customer_note(DR-38 — additive, zero mutation risk),customer_task(DR-38/DR-40 — task status IS the review mechanism),customer_merge(post-execution audit; review already happened upstream on the candidate). - No review seam by design, not by omission:
customer_consent(DR-42 — a captured fact, never an inference). - Reuses existing domain columns as the review seam rather than adding a parallel set:
customer_tax_certificate(DR-44 —status/verified_by_actor_id/verified_atalready do the job).
Global address model, reused from multi_loc.site (DR-37)
address and customer_tax_certificate's issuing-jurisdiction fields both use flat address lines + natural-key FKs into shared.country/shared.administrative_region, replacing v1's US-only us_state/country_id shape — the same pattern multi_loc.site established at module #4, including the region_code/country_code consistency CHECK (left(region_code, 2) = country_code).
Three deferred forward-refs, all for the same reason (DR-34)
customer.consumer_id, customer.search_vector, and customer_tax_certificate.document_ref all target modules (consumer, search, files) that do not exist in this v2 database — verified live via psql \dn (only identity, multi_loc, platform, shared, crm exist) and via filesystem (no corresponding schema_docs/*.md files). Older docs (MODULE_INDEX.md, CROSS_MODULE_CONTRACTS.md) describing these as "locked" or "FK — READY" as of 2026-06-11/12 are stale v1/pre-pivot documentation predating the actual v2 rebuild sequence (platform → identity → shared → multi_loc → crm). consumer_id and document_ref are plain nullable columns with no FK; search_vector isn't built as a column at all this pass, since no v2 table anywhere yet uses a generated tsvector column to follow as precedent.
Segments vs. tags — two tables, not one (DR-46)
customer_segment_membership (computed/analytical: agent-assigned, confidence-scored, provenance-tracked, drawn from a defined catalog) and customer_tag_assignment (human free-form: no catalog, no confidence, no review, ad-hoc staff-typed text) are deliberately kept as separate tables. An earlier design draft proposed unifying them; this was reconsidered and rejected before build, per explicit resolution — different lifecycle, different authority, different provenance.
customer_segment_definition mirrors identity.role's mixed-scope pattern exactly (DR-45)
Nullable tenant_id (NULL = Vrida-shipped built-in, populated = tenant-custom), the same two-partial-unique-index shape, and the same mixed-scope RLS disjunction identity.role uses — a proven, already-audited pattern reused rather than invented fresh.
Two partial unique indexes beat one nullable-column unique index
customer_tax_certificate uses two partial unique indexes (split on issuing_region_code IS NULL vs. IS NOT NULL) instead of one unique index directly including the nullable column — avoiding the same NULL-uniqueness trap first caught in identity.agent_duty_grant's original design (PROJECT_DECISIONS #22). customer_segment_definition and identity.role use the analogous two-index split on tenant_id IS NULL vs. populated.
now() is not IMMUTABLE — stored status, not a live partial-index predicate (DR-47)
customer_segment_membership.status is a stored column, not derived via WHERE expires_at > now() in a partial index — Postgres requires index predicates to be IMMUTABLE, and now() is STABLE, so such an index fails to create (ERROR: functions in index predicate must be marked IMMUTABLE, confirmed empirically before writing this migration). Matches the existing convention identity.role_assignment.ends_at / identity.agent_duty_grant.ends_at already use — expiry is checked on-read or by a service-layer sweep, never live inside an index.
Agent-authority mapping — pure consumer of identity.agent_duty_grant
crm introduces no new authority mechanism. Every autonomous crm action would be gated by an active identity.agent_duty_grant row scoped to a crm-module identity.permission (module_code has no CHECK constraint, so crm-scoped permission codes require zero identity-module changes). crm is the first module where agent_duty_grant.spend_limit_cents is essentially always NULL — its autonomous actions are volume-bounded (quantity_limit: records/run, batch size), not money-bounded. Illustrative mapping (no permission rows seeded this pass):
| crm action | Illustrative permission code | authority_level |
Limit dimension |
|---|---|---|---|
| Enrich a customer field | crm:customer:enrich |
draft_only (or may_act_alone above confidence threshold for non-contact fields) |
quantity_limit |
| Compute a segment membership | crm:customer_segment:compute |
may_act_alone above threshold |
quantity_limit |
| Propose a merge candidate | crm:customer_merge_candidate:propose |
draft_only |
quantity_limit |
| Execute a merge | crm:customer_merge:execute |
needs_approval (always) |
N/A — gated by authority_level alone |
| Create a follow-up task | crm:customer_task:create |
draft_only |
quantity_limit |
D7 autonomy-boundary table (the load-bearing artifact of this design)
| Action | Authority | Notes |
|---|---|---|
Create a customer record from deterministic capture (walk-in form, POS attach) |
may-act-alone | Standard data entry, not an AI action |
| AI-enrich an EXISTING customer record | draft-only (needs-approval if touching email/phone) | Never auto-execute a change to how the customer is contacted |
Compute/update a customer_segment_membership row |
may-act-alone above confidence threshold; draft-only below | Segments are informational/targeting labels, lower blast radius than enrichment |
| Propose a duplicate-customer merge candidate | draft-only, always | Proposing costs nothing; a human reviews before any data moves |
| Execute a customer merge | needs-approval, always, never may-act-alone | Hard-to-fully-reverse; moves real transactional links |
Create a customer_task |
draft-only | Inert until a human acts |
Record a customer_note |
may-act-alone | Append-only, additive, zero mutation risk |
Record a customer_consent event |
never AI-initiated | Must reflect the customer's own action |
Verify/reject a customer_tax_certificate |
needs-approval, always | Compliance-bearing, already human-only in v1 |
| Anything touching credit_terms/credit_limit | N/A to crm | Moved to Billing, out of this module's D7 table entirely |
JSONB columns
customer.attributes, customer_merge.metadata, customer_consent.metadata, and decision_provenance on every table that carries the autonomy review seam (customer, customer_note, customer_task, customer_merge_candidate, customer_merge, customer_tax_certificate, customer_segment_membership).
Triggers
set_updated_at (via platform.set_updated_at()) fires BEFORE UPDATE on every table with an updated_at column: address, contact, customer, customer_group, customer_merge_candidate, customer_segment_definition, customer_segment_membership, customer_task, customer_tax_certificate. No crm-schema-specific triggers exist — same shared trigger function used across platform, identity, shared, and multi_loc.
Service layer
No CrmService exists yet — this pass is Drizzle schema + hand-written migration + tests against raw SQL only, matching the precedent set by shared and multi_loc (schema-first, service-layer-later).
Open items carried forward (see OPEN_ITEMS.md for full text)
- Merge-reversal 24h window is a service-layer requirement, not schema —
customer_merge.metadatasupports it in principle, but no enforcement exists yet. customer.consumer_idforward-ref deferred until theconsumermodule is designed/built.customer.search_vector(Search FTS touch) deferred until the Search module is designed/built.customer_tax_certificate.document_refforward-ref deferred until the Files module is built.CrmService(all service-layer methods) does not exist yet — schema-only this pass.- Vrida-default segment definitions (
vip/at_risk/seasonal/wholesale_like) are not seeded yet —customer_segment_definitionsupports them structurally but no rows exist. customer_merge_candidatehas no dedup protection — an exact-duplicate proposal or a mirrored(customer_a_id, customer_b_id)/(customer_b_id, customer_a_id)pair can both be inserted with no conflict (found in the post-build Section 4 audit).customer.tax_exempt/customer.marketing_opt_inmaintained-cache columns have no reconciliation trigger or documented sync formula (found in the post-build Section 4 audit).customer.tax_exempt_id's removal (DR-35) had never been cross-referenced from OPEN_ITEMS until the 2026-07-07 erosion audit — closed as recorded-not-an-action-item, not a real gap.customer.payment_terms_id(Remediation Phase 4, Item 17b) is not yet kept in sync withcustomer.credit_terms— an additive-interim gap, and the 2 vocabularies are not a clean subset of each other (4 catalog codes have nocredit_termsequivalent); a real mapping decision is needed before any eventual cutover.customer.pii_vault_ref(Remediation Phase 4, Item 18) has no vault service to point to yet — shares the existingein_refvault-service-not-yet-built OPEN_ITEMS dependency, not a new one.
Fixed 2026-07-07 (PROJECT_DECISIONS #28 — see DR-48/DR-49 above)
- LIVE BUG closed:
customer_tax_certificatecan no longer be'active'without a real verifier + timestamp (chk_customer_tax_certificate_active_requires_verification). - Erosion closed:
address.country_coderestored toNOT NULL(was silently nullable). - Erosion closed:
customer_tax_certificate.issuing_country_code's silentDEFAULT 'US'dropped — callers must now state the issuing country explicitly.