crm — Module Spec
1. Purpose
crm owns the tenant's record of buyers — customers, contacts, addresses, groups, notes, consent, dedup/merge lifecycle, tax exemption certificates, segments, tags, and follow-up tasks. It is the first PRODUCT module built in the v2 pipeline: modules #1–4 (platform, identity, shared, multi_loc) were foundation/service-layer modules that established the tenant/actor/global-reference/site substrate; crm is the first of the 13 nursery-vertical product modules to actually go through design → build.
2. Ownership
Owns — 13 tables:
customer— core buyer entity (individual/business via discriminator)contact— additional named contacts per customeraddress— billing/shipping addressescustomer_group— tenant-defined segments shared with Pricingcustomer_note— append-only staff/agent observation logcustomer_task— follow-up taskscustomer_merge_candidate— pending dedup proposal awaiting reviewcustomer_merge— append-only post-execution merge auditcustomer_consent— append-only consent event logcustomer_tax_certificate— tax exemption certificatescustomer_segment_definition— catalog of segments (built-in + tenant-custom)customer_segment_membership— customer × segment membershipcustomer_tag_assignment— human free-form labels
Does NOT own:
- Accounts-receivable balances / A/R lifecycle → Billing (
billing.ar_accountand related tables). Credit-worthiness itself (credit_limit_cents/credit_terms) is owned bycrm.customer— restored 2026-07-08 by Remediation Phase 3, Item 8 (PROJECT_DECISIONS #39), correcting DR-35's original removal (see §4/§9 DR-51).billing.ar_accountreads these two columns fromcrm.customer, never duplicates them. - Payment-terms catalog governance → Shared (
shared.payment_terms_catalog, added 2026-07-08 by Remediation Phase 4, Item 17b).crm.customer.payment_terms_idis a consuming FK, additive-interim alongsidecredit_terms— see §4/§9 DR-52. - Loyalty → Rewards. Verified live: the
rewardsschema does not exist in this v2 database as of this build, despiteMODULE_INDEX.md/CROSS_MODULE_CONTRACTS.mddescribing it as "locked" — that documentation is stale v1/pre-pivot content (DR-34). - Discount/promo → Offers. Same caveat: verified not built in v2 yet;
MODULE_INDEX.md's "locked" status foroffersis stale. - Marketing campaign send mechanics (actual email/SMS dispatch) → Notifications.
- Cross-tenant consumer identity → Consumer (not yet built in v2 — verified live via
psql \dn, theconsumerschema does not exist).crmholds only the one deferred bridge column,customer.consumer_id(plain nullable UUID, no FK — DR-34).
3. Layer & Dependencies
Tenant-scoped operational layer, first product-tier module.
Depends on:
platform—platform.tenantis the FK target for every tenant-scopedcrmtable;platform.set_updated_at()trigger.shared(module #3) — natural-key FKsshared.country.iso_alpha2andshared.administrative_region.iso_3166_2for global address shape onaddressandcustomer_tax_certificate's issuing-jurisdiction fields; plus (added 2026-07-08, Remediation Phase 4, Item 17b)shared.payment_terms_catalogas the FK target forcustomer.payment_terms_id.multi_loc(module #4) —multi_loc.siteFK oncustomer_note.site_idandcustomer_task.site_id(nullable, narrow exception — see §8).identity(module #2) — new relative toMODULE_INDEX.md's stale pre-pivot dependency list. Needed for actor-attribution FKs (created_by_actor_id,updated_by_actor_id,reviewed_by_actor_id,verified_by_actor_id,proposed_by_actor_id,merged_by_actor_id,captured_by_actor_id,computed_by_actor_id,assigned_to_actor_id, all →identity.actor) and, prospectively,identity.agent_duty_grantfor autonomy enforcement (schema-only consumer this pass — see §7).
Depended on by: none yet (first product module). Future modules expected to reference crm.customer include Billing (charge accounts, invoices), Rewards (loyalty enrollment), Offers (customer-targeted discounts), Notifications (campaign recipient targeting), and POS/e-commerce order capture.
4. Tables
13 tables, 193 columns (was 189 at 2026-07-06 lock; +2 from the 2026-07-08 Remediation Phase 3 pass; +2 more from the same-day Remediation Phase 4 pass — see customer below), locked 2026-07-06. See packages/db/migrations/20260706060000_crm_module.sql for the full DDL, plus packages/db/migrations/20260708210000_phase3_item8_customer_credit_terms.sql for the Phase 3 addition, and packages/db/migrations/20260709030000_phase4_item17_enum_to_catalog.sql (part b) / packages/db/migrations/20260709040000_phase4_item18_gdpr_erasure.sql for the Phase 4 additions. Drizzle schema files: packages/db/src/schema/crm/{_schema,customer,interaction,merge,consent_tax,segment,index}.ts.
| Table | Cols | PK style | Notes |
|---|---|---|---|
customer |
36 | UUID | Core entity, tenant-scoped, RLS enabled, soft-delete |
contact |
15 | UUID | Subordinate to customer, soft-delete |
address |
15 | UUID | Subordinate to customer, soft-delete |
customer_group |
10 | UUID | Tenant-scoped catalog, soft-delete |
customer_note |
10 | UUID | Append-only (no updated_at/deleted_at) |
customer_merge |
11 | UUID | Append-only post-execution audit (no updated_at/deleted_at) |
customer_merge_candidate |
15 | UUID | Mutable pending→approved/rejected lifecycle, no soft-delete |
customer_consent |
12 | UUID | Append-only event log (no updated_at/deleted_at) |
customer_tax_certificate |
19 | UUID | Tenant-scoped, RLS enabled, soft-delete |
customer_segment_definition |
10 | UUID | Mixed-scope (NULL tenant_id = Vrida built-in), soft-delete |
customer_segment_membership |
17 | UUID | Tenant-scoped, RLS enabled, no soft-delete (status-driven) |
customer_tag_assignment |
8 | UUID | Tenant-scoped, soft-delete |
customer_task |
15 | UUID | Tenant-scoped, RLS enabled, no soft-delete (status-driven) |
| Total | 193 |
customer_group is defined before customer in the same migration file so customer.customer_group_id can FK it directly.
customer (36 cols)
Core entity, individual/business via customer_type discriminator — one table, not two, preserving v1's rationale. Full autonomy treatment: created_by_actor_id/updated_by_actor_id (→ identity.actor), automation_source (CHECK human/agent/system/seed, default 'human'), is_verified (default true), review_status/review_reason/reviewed_by_actor_id/reviewed_at (review seam, default 'not_required'), decision_provenance (jsonb).
Key columns: customer_number (nullable, unique per tenant when present), customer_type (CHECK individual/business), display_name/first_name/last_name/company_name, email/email_normalized (deliberately not unique — DR-33), phone, customer_group_id (FK → customer_group), consumer_id (deferred forward-ref, plain UUID, no FK — DR-34), status (CHECK active/inactive), tax_exempt (boolean), source (CHECK walk_in/pos/import/online/app/manual), marketing_opt_in (boolean), last_activity_at, notes, attributes (jsonb), credit_limit_cents (nullable bigint, CHECK chk_customer_credit_limit_nonnegative — NULL or >= 0; added 2026-07-08, Remediation Phase 3 Item 8), credit_terms (nullable text, CHECK chk_customer_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), payment_terms_id (nullable UUID, FK → shared.payment_terms_catalog; added 2026-07-08, Remediation Phase 4 Item 17b), pii_vault_ref (nullable text; added 2026-07-08, Remediation Phase 4 Item 18).
CHECK chk_customer_type_identity_fields enforces individual customers have first_name or last_name, business customers have company_name. tax_exempt_id dropped (already deprecated in v1). search_vector (Search-module FTS touch) deferred — the search schema does not exist in v2.
credit_terms/credit_limit_cents were removed entirely at 2026-07-06 lock (DR-35) on the stated rationale that credit-worthiness belonged wholly to Billing. That rationale was itself a bug: billing.ar_account's column comment already claimed it "reads from crm.customer, never duplicated," but crm.customer never actually had these columns. Remediation Phase 3, Item 8 (2026-07-08, PROJECT_DECISIONS #39) restores both columns here, nullable (NULL = no credit extended, fail-closed), with corrected ownership: crm.customer owns credit-worthiness (a relationship judgment about the customer), billing.ar_account reads it. 60 live customer rows existed at the time of this additive change. Service-layer enforcement of the limit (blocking an over-limit charge) is deferred to OPEN_ITEMS — not built this pass. See DR-51 in §9.
payment_terms_id/pii_vault_ref were added same-day by Remediation Phase 4, Items 17b and 18 (2026-07-08, PROJECT_DECISIONS #40). payment_terms_id FKs to the new shared.payment_terms_catalog (10 seeded rows) as an additive-interim column sitting alongside — not replacing — the existing credit_terms CHECK-enum; the two are explicitly NOT a clean vocabulary subset of each other (the catalog's cod/prepaid/net_7/2_10_net_30 codes have no credit_terms equivalent), so a future cutover needs a real mapping decision, not a trivial rename. pii_vault_ref mirrors platform.tenant_profile.ein_ref's vault-reference pattern for future crypto-shred GDPR/CCPA erasure, and deliberately shares (does not duplicate) ein_ref's existing vault-service-not-yet-built OPEN_ITEMS dependency. Both columns nullable and additive, zero backfill risk. See DR-52 in §9.
contact (15 cols)
Additional named contacts per customer. Light autonomy touch only (created_by_actor_id) — subordinate table, no distinct AI capability targets it this pass. display_name, first_name/last_name, email/email_normalized, phone, role_title, is_primary (partial-unique per customer WHERE is_primary=true AND deleted_at IS NULL).
address (15 cols)
Billing/shipping addresses. Global address shape (DR-37): address_line1 (NOT NULL) / address_line2 + city + postal_code (free text) + region_code (FK → shared.administrative_region.iso_3166_2, nullable) + country_code (FK → shared.country.iso_alpha2, char(2), nullable) — mirrors multi_loc.site's exact precedent including the region/country consistency CHECK (chk_address_region_country_match: region_code IS NULL OR (country_code IS NOT NULL AND left(region_code,2) = country_code)). Both nullable, matching multi_loc.site's own nullability, not v1's NOT NULL country_id. address_type (CHECK billing/shipping/both), is_default (partial-unique per customer+type WHERE is_default=true AND deleted_at IS NULL). Light autonomy touch only (created_by_actor_id).
customer_group (10 cols)
Tenant-defined segments used by both crm and Pricing (pricing.price_list_assignment.customer_group_id). code/name/description, is_active, sort_order. Unique per tenant on (tenant_id, code) WHERE deleted_at IS NULL. No autonomy columns — human-only catalog by design, same reasoning class as DR-38 (see §6).
customer_note (10 cols)
Append-only staff/agent observation log — no updated_at, no deleted_at. note_type (CHECK call/email/visit/general, nullable), body (NOT NULL), site_id (nullable FK → multi_loc.site — DR-39). Has automation_source + decision_provenance but explicitly no review_status — may-act-alone, additive, zero mutation risk (DR-38).
customer_task (15 cols)
Follow-up tasks, human- or agent-suggested. title/description, assigned_to_actor_id (→ identity.actor), due_at, status (CHECK open/completed/dismissed, default 'open'), completed_at, site_id (nullable FK → multi_loc.site — DR-39). No review_status seam — dismissing/completing IS the review mechanism (DR-38/DR-40).
customer_merge_candidate (15 cols)
Pending dedup proposal awaiting review — the review gateway for merges. customer_a_id/customer_b_id (both FK → customer, CHECK customer_a_id <> customer_b_id), match_basis (CHECK email/phone/name_fuzzy/address/manual), confidence_score (numeric(3,2)), proposed_by_actor_id. 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 (review_reason, reviewed_by_actor_id, reviewed_at, decision_provenance).
customer_merge (11 cols)
Append-only POST-EXECUTION merge audit — no updated_at, no deleted_at. source_customer_id/target_customer_id (both FK → customer, CHECK not equal), merged_by_actor_id, candidate_id (nullable FK → customer_merge_candidate — by the time a row exists here, review already happened on the candidate), reason, metadata (jsonb — merge snapshot, supports reversal in principle), decision_provenance.
customer_consent (12 cols)
Append-only consent event log — no updated_at, no deleted_at. consent_type (CHECK marketing_email/sms/phone/privacy_terms/loyalty), channel (CHECK email/sms/phone/in_app/pos, nullable), status (CHECK opted_in/opted_out), source (CHECK pos/import/online/manual/unsubscribe_link/app), captured_at (NOT NULL), captured_by_actor_id, metadata (jsonb). Current state cached on customer.marketing_opt_in. No review seam at all — explicit exception (DR-42).
customer_tax_certificate (19 cols)
Tax exemption certificates. certificate_number, certificate_type (CHECK resale/agricultural/non_profit/government/other), global jurisdiction fields issuing_country_code (FK → shared.country.iso_alpha2, char(2), NOT NULL default 'US') + issuing_region_code (FK → shared.administrative_region.iso_3166_2, nullable, ON DELETE SET NULL), same region/country consistency CHECK pattern as address (chk_customer_tax_certificate_region_country_match). valid_from (NOT NULL), expires_at, document_ref (deferred forward-ref to Files module — not yet built in v2). status (CHECK active/expired/revoked/pending_review, defaults 'pending_review', not v1's 'active' — DR-43). Reuses existing status/verified_by_actor_id/verified_at columns as the review seam rather than adding a parallel review_status column (DR-44). Two partial unique indexes — customer_tax_certificate_no_region_unique (WHERE issuing_region_code IS NULL) and customer_tax_certificate_with_region_unique (WHERE issuing_region_code IS NOT NULL), both further scoped AND deleted_at IS NULL AND status = 'active' — deliberately avoid the NULL-in-multi-column-unique trap, the same bug class caught in identity.agent_duty_grant's original design (PROJECT_DECISIONS #22).
customer_segment_definition (10 cols) — NEW
Catalog of segments. Mirrors identity.role's mixed-scope pattern exactly (DR-45): tenant_id nullable (NULL = Vrida-shipped built-in segment like vip/at_risk/seasonal/wholesale_like; populated = tenant-custom). code/name/description, is_active, sort_order. Same two-partial-unique-index shape as identity.role — customer_segment_definition_tenant_id_code_unique on (tenant_id, code) WHERE deleted_at IS NULL and customer_segment_definition_builtin_code_unique on (code) WHERE tenant_id IS NULL AND deleted_at IS NULL. Mixed-scope RLS disjunction (tenant_id IS NULL OR tenant_id = current_setting(...)), matching identity.role. No autonomy columns — catalog definitions are human/Vrida-curated, never agent-written.
customer_segment_membership (17 cols)
Customer × segment membership. segment_definition_id is a real FK into customer_segment_definition (not bare CHECK-less text). Full autonomy treatment: confidence_score (numeric(3,2)), computed_by_actor_id, automation_source, full review seam (review_status default 'not_required', review_reason, reviewed_by_actor_id, reviewed_at), decision_provenance. 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 STABLE, confirmed empirically (ERROR: functions in index predicate must be marked IMMUTABLE) before this migration was written. starts_at/expires_at. This is the primary home for the D6/D7 agent-computed-segment capability (VIP/at-risk/seasonal/wholesale-like).
customer_tag_assignment (8 cols) — NEW
Human free-form labels (e.g. "Master Gardener", "Garden Club"). Deliberately separate from customer_segment_membership (DR-46) — no catalog, no confidence, no review seam, the lightest table in the module. tag_name (as-entered) + tag_name_normalized (lowercased/trimmed dedup key, same pattern as email/email_normalized), created_by_actor_id. Unique per customer on (customer_id, tag_name_normalized) WHERE deleted_at IS NULL. Soft-delete (deleted_at), not append-only — removing a tag is a light, reversible staff action.
Net column delta vs. v1: crm is a NET-NEW schema in v2 — no direct v1 module-spec baseline to diff against column-by-column (v1's equivalent was spread across crm-adjacent tables with different scope boundaries, notably including credit/charge-account and loyalty columns since split out to Billing/Rewards).
5. Capabilities
- Customer record management — create/update/soft-delete
customerrows (individual or business); enforcecustomer_type-conditional identity fields; per-tenantcustomer_numberuniqueness when present. - Contact management — additional named contacts per customer; enforce at most one
is_primarycontact per customer. - Address management — billing/shipping addresses with global country/region shape; enforce at most one default address per customer per address type; region/country consistency.
- Customer grouping — tenant-defined
customer_groupcatalog, shared with Pricing. - Interaction logging — append-only
customer_noteobservations, optionally site-attributed. - Follow-up tasking —
customer_tasklifecycle (open/completed/dismissed), optionally site-attributed and actor-assigned. - Dedup / merge lifecycle — propose a
customer_merge_candidate(review-gated by default), review it, and on approval execute acustomer_merge(append-only audit, snapshot inmetadatafor reversal-in-principle). - Consent tracking — append-only
customer_consentevent log acrossmarketing_email/sms/phone/privacy_terms/loyalty, cached tocustomer.marketing_opt_in. - Tax exemption certificate management — capture, track validity/expiry, and verify/reject
customer_tax_certificaterows, defaulting topending_reviewuntil a human (or future OCR-extraction agent under review) confirms. - Segmentation — catalog-driven
customer_segment_definition(built-in + tenant-custom) and computedcustomer_segment_membership(confidence-scored, provenance-tracked, review-gated). - Tagging — ad-hoc human free-form
customer_tag_assignmentlabels, independent of the segment catalog.
No AI write path is implemented this pass — schema-only build (Drizzle + migration + tests), no CrmService yet. See §6 and Deferred/Future Items.
6. Service Contract — CrmService
Not built this pass — no CrmService exists yet; schema + migration + tests only, matching the shared and multi_loc modules' own precedent. Downstream code queries crm.* directly via Drizzle for now. A CrmService abstraction (customer CRUD, merge workflow orchestration, consent capture, tax-cert verification, segment computation) is deferred to whenever it's built.
7. Part D — AI Capability Discovery Walk
This module received both halves of the AI Capability Plane pass in the same build: the schema-translation half (agent-as-actor FKs, automation_source, review seams, decision_provenance — applied per-table above) and the capability-discovery half (AI_CAPABILITY_PLANE.md's Part D walk), run against the proposed crm design before the schema was finalized.
Applies: D1 (capture targets — new customer/contact/address/tax-cert/consent/note/task), D3 (maintenance — dedup/segment staleness is central to crm's autonomy story), D4 (error-prevention — merge conflicts, expired-cert blocking), D5 (negative-space — business customer with no contact, customer with no address), D6 (decision-support — churn/VIP/seasonal classification), D7 (autonomy boundary — the load-bearing question, see table below), D8 (evidence sources — OCR'd tax certs, imported CSVs, parsed email signatures), D9 (reconciliation pairs — tax_exempt ↔ certs, marketing_opt_in ↔ consent), D11 (adversarial — fake tax-exemption claims, consent-spoofing, merge-based data laundering, segment gaming), D14 (lifecycle — cert expiry, segment membership lapse, task open/closed), D15 (capture modality — staff form primary, photo capture for tax certs).
Ruled out, honestly: D2 (routing — no smart status placement beyond what's explicit), D10 covered by D7/merge-reversal open question, D12 (offline — POS-side capture is existing POS behavior, not new to crm; crm's own admin-UI tables require connectivity), D13 (channel sync — crm is not e-commerce-catalog-facing, ruled out). B2/B7/B8/B9/B12/B14/B15/B16/B17-B20 all ruled out with stated reasons — the short version: campaigns/loyalty/credit belong to other modules; bulk import already exists as a cross-cutting AI-module capability crm just consumes.
D7 — Full per-action autonomy-boundary table
| 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 |
Set/update customer.credit_limit_cents/credit_terms |
needs-approval | Financial-boundary action (C8-adjacent); credit-worthiness is crm's domain (restored 2026-07-08, Remediation Phase 3 Item 8) but extending/raising credit is not a may-act-alone decision |
8. Agent Authority Mapping
crm introduces no new authority mechanism — it is a pure consumer of identity.agent_duty_grant (PROJECT_DECISIONS #22, built 2026-07-06). For each autonomous crm action, the enforcing service method (not yet built — schema-only this pass) would check whether the acting agent holds an active agent_duty_grant row for the relevant identity.permission (module_code='crm' — confirmed live that identity.permission.module_code has no CHECK constraint, so crm-scoped permission codes need zero identity-module changes):
| crm action | Illustrative permission code | authority_level needed |
Limit dimension |
|---|---|---|---|
| Enrich a customer field | crm:customer:enrich |
draft_only (or may_act_alone above confidence threshold for non-contact fields) |
quantity_limit (records/run); spend_limit_cents typically NULL |
| Compute a segment membership | crm:customer_segment:compute |
may_act_alone above threshold |
quantity_limit (batch size) |
| 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 |
crm is the first module where agent_duty_grant.spend_limit_cents is essentially always NULL — its autonomous actions are volume-bounded (quantity_limit), not money-bounded. Useful precedent for future non-transactional modules.
9. Design Rationale (DR-33 through DR-52)
- DR-33 —
emailstays deliberately non-unique: real-world duplicates happen legitimately (a walk-in customer created at POS may later create an online account with the same email; a hard unique would block creation and break offline POS, which can't check uniqueness against the server). Dedup is the explicitcustomer_merge_candidate→customer_mergeworkflow, never a DB constraint. - DR-34 —
customer.consumer_idis a deferred forward-ref (plain nullable UUID, no FK). Verified live (viapsql \dnon the local Supabase instance) that theconsumerschema does not exist in this v2 database — onlyidentity,multi_loc,platform,shared,crm(as of this build) do. Also verified via filesystem (find docs/database/schema_docs -iname "*.md") that noconsumer.md/rewards.md/offers.mdexist.docs/modules/MODULE_INDEX.mdanddocs/modules/CROSS_MODULE_CONTRACTS.mddescribingconsumer/rewards/offersas "locked" (2026-06-11/12) andcrm.customer.consumer_id → consumer.consumeras "FK — READY" is stale v1/pre-pivot documentation, predating the actual v2 rebuild (which started with platform → identity → shared → multi_loc). The same reasoning applies tosearch_vector(deferred — thesearchschema also doesn't exist in v2) andcustomer_tax_certificate.document_ref(deferred — thefilesschema also doesn't exist in v2). - DR-35 —
credit_terms/credit_limit_cents(v1 columns oncustomer) were removed entirely at 2026-07-06 lock.MODULE_INDEX.mdalready assigned "customer credit" to the not-yet-built Billing module; v1's own schema doc had already splitcharge_accountout to Billing but left these two columns behind as an incomplete migration — corrected here. Superseded 2026-07-08 by DR-51: this removal turned out to rest on a bug, not a real decision — see below. - DR-36 —
customer.is_verifieddefaultstrue— deviates fromshared.plant'sfalsedefault. Reasoning: staff/POS/import-entered data is presumptively trustworthy; an AI enrichment agent's own written fields are the exception that should start unverified, tracked per-field indecision_provenance(row-levelis_verifiedcan't express mixed per-field trust — same documented workaroundmulti_loc.site.decision_provenancealready established forclimate_zone_code/measurement_system). - DR-37 — Global addresses:
crm.addressandcrm.customer_tax_certificate's issuing-jurisdiction fields both use flat address lines + natural-key FKs intoshared.country(iso_alpha2,char(2)) /shared.administrative_region(iso_3166_2,text), replacing v1's US-onlyus_state/country_idshape. Mirrorsmulti_loc.site's exact precedent, including the region/country consistency CHECK pattern:region_code IS NULL OR (country_code IS NOT NULL AND left(region_code, 2) = country_code). - DR-38 (customer_note/customer_task no review_status) — D7 (autonomy boundary) classifies logging an observation note or suggesting a follow-up task as low-blast-radius, additive actions — a note is append-only and a task is inert until acted on (dismissing/completing IS the review mechanism for a task). A formal
review_statusgate would be bureaucratic overhead disproportionate to the risk. Deliberate exception, not an oversight. - DR-39 (site_id on customer_note/customer_task) — a narrow, deliberate exception to "master data doesn't carry site_id" (
SCHEMA_CONVENTIONS.md) — these are event/interaction records, not master data, and multi-site tenants have a real need to know which location an interaction happened at or a task should be handled from. - DR-40 — folded into DR-38 (task's own state machine,
open/completed/dismissed, IS its review mechanism). - DR-41 —
customer_merge_candidateis kept separate fromcustomer_merge(not unified with areview_statuscolumn bolted onto the append-only audit table) because most candidates are REJECTED and never become acustomer_mergerow at all — genuinely different lifecycle (mutable pending→approved/rejected vs. permanent audit fact) and cardinality (most candidates never produce a merge). Consolidation-pass-justified as two tables, not one. - DR-42 —
customer_consentgets no review seam at all. Consent is a captured FACT (the customer's own opted-in/opted-out action), not an inference — there is nothing to review-gate structurally. D7 rule: consent must never be AI-initiated (an AI may never infer/record a consent event on a customer's behalf). - DR-43 —
customer_tax_certificate.statusdefaults'pending_review', changed from v1's'active'default — an unverified certificate (especially one a future OCR-extraction agent might draft from a photo) must never default to trusted status. - DR-44 —
customer_tax_certificatereuses its existingstatus(pending_review)/verified_by_actor_id/verified_atcolumns as the review seam, rather than adding a parallelreview_status/reviewed_by_actor_id/reviewed_atcolumn set — v1 already built an equivalent gate; adding a duplicate would be redundant structure. - DR-45 —
customer_segment_definitionmirrorsidentity.role's mixed-scope pattern exactly: nullabletenant_id(NULL = Vrida-shipped built-in, populated = tenant-custom), the same two-partial-unique-index shape, and the same mixed-scope RLS disjunction. This is a proven, already-audited pattern being reused, not invented fresh. - DR-46 — Tags (
customer_tag_assignment) and segments (customer_segment_membership) are deliberately separate tables, per explicit resolution — segments are computed/analytical (agent-assigned, confidence-scored, provenance-tracked, drawn from a defined catalog) while tags are human free-form labels (no catalog, no confidence, no review, ad-hoc staff-typed text). Different lifecycle, different authority, different provenance — kept as two tables, not unified (an earlier design draft had proposed unifying them; this was reconsidered and rejected before build per explicit resolution). - DR-47 —
customer_segment_membershipuses a storedstatuscolumn (active/expired), not a partial index predicate referencingnow()(e.g.WHERE expires_at > now()). Postgres requires index predicates to be IMMUTABLE;now()is STABLE, not IMMUTABLE, so such an index fails to create at all — confirmed empirically (ERROR: functions in index predicate must be marked IMMUTABLE) before this migration was written, avoiding a real build failure. Fixed to match the existing conventionidentity.role_assignment.ends_at/identity.agent_duty_grant.ends_atalready use: checked on-read or by a service-layer sweep, never computed live inside a partial index. - DR-48 (added 2026-07-07, crm reopen) —
address.country_codechanged from nullable toNOT NULL, restoring v1'scountry_id NOT NULLguarantee. Found by the 2026-07-07 crm/inventory/pricing erosion audit's adversarial pass:chk_address_region_country_matchonly fires whenregion_code IS NOT NULL, so an address with neither column set passed silently, leaving any downstream tax/shipping logic keyed on country operating on NULL. The FK'sON DELETEbehavior was tightened fromSET NULLtoRESTRICTin the same migration — a NOT NULL column cannot silently absorb a deleted-country cascade, so the failure now surfaces at country-deletion time (rare, deliberate) rather than as a surprise on an unrelated later write. 0 existing rows confirmed before applying; independently verified viapg_constraint.confdeltype='r'and live INSERT reproduction. See PROJECT_DECISIONS #28. - DR-49 (added 2026-07-07, crm reopen) —
customer_tax_certificategainedchk_customer_tax_certificate_active_requires_verification(status <> 'active' OR (verified_by_actor_id IS NOT NULL AND verified_at IS NOT NULL)) and lostissuing_country_code'sDEFAULT 'US'. Found by the same audit:statuscould be set'active'— "this customer is currently tax-exempt" — with both verifier fields NULL, since DR-43'spending_reviewdefault only suggested the review workflow rather than enforcing it (a live bug, same class aspos's gift-card silent-acceptance issue, entry #27). Separately, the silent'US'default meant an omitted issuing country was recorded as domestic rather than surfaced — a real misclassification risk on a compliance-bearing document; the column staysNOT NULL, callers must now state the country explicitly. Both are permanent fixes (unlikepos's temporary stored-value CHECKs) since the columns they enforce already have real FK targets. Independently re-verified live (active+no-verifier rejected; active+both-set succeeds; non-active unaffected; omitted-country rejected; explicit non-US country honored). See PROJECT_DECISIONS #28. - DR-50 (added 2026-07-08, Remediation Plan Phase 2, Item 6) —
customer_note,customer_consent, andcustomer_merge(the module's three append-only event/audit tables) had theiridDEFAULT changed fromgen_random_uuid()toplatform.uuid_generate_v7(). PK-generation-strategy change only, no column/table count impact. Rationale: UUIDv7 is time-ordered, preserving the option of future time-range partitioning on these append-only ledgers without a PK rewrite — impossible once data has landed on a random UUIDv4 PK.customer_tag_assignmentwas considered and explicitly excluded — it's soft-delete (deleted_at), not append-only. See PROJECT_DECISIONS #38. - DR-51 (added 2026-07-08, Remediation Plan Phase 3, Item 8) —
customer.credit_limit_cents(bigint, nullable, CHECKchk_customer_credit_limit_nonnegative) andcustomer.credit_terms(text, nullable, CHECKchk_customer_credit_termsINdue_on_receipt/net_15/net_30/net_45/net_60/net_90) are added back tocrm.customer, correcting DR-35. The correction: DR-35's stated rationale — that credit-worthiness "moved to Billing entirely" — was found to be a live documentation bug, not a real decision.billing.ar_account's own column comment already read "reads from crm.customer, never duplicated," butcrm.customernever actually carried these columns for it to read. The governing ownership principle is now explicit and enforced:crm.customerowns credit-worthiness (a relationship judgment about the customer — squarelycrm's domain, same class of judgment astax_exempt/customer_group_id), whilebilling.ar_accountreadscredit_limit_cents/credit_termsfrom here and never duplicates them locally. Both columns nullable (NULL = no credit extended, fail-closed) — additive against 60 live rows at build time, zero backfill risk. Service-layer enforcement (blocking a charge that would exceedcredit_limit_cents) is deferred to OPEN_ITEMS, not built this pass. Independently verified with zero BLOCKERs. See PROJECT_DECISIONS #39. - DR-52 (added 2026-07-08, Remediation Plan Phase 4, Item 17b) —
customer.payment_terms_id(UUID, nullable, FK →shared.payment_terms_catalog) is added as an additive-interim column, sitting alongside — not replacing — the existingcredit_termsCHECK-enum from Phase 3/DR-51, which is left completely unchanged.shared.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) carriesnet_days/discount_percent/discount_daysper row, letting2_10_net_30genuinely represent "2/10 net 30" — something a bare CHECK-enum structurally cannot express. Disclosed explicitly, not glossed over: the two vocabularies are NOT a clean subset of each other. The catalog'scod/prepaid/net_7/2_10_net_30codes have nocredit_termsequivalent 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 sync gap is disclosed durably in the Drizzle TypeScript source (crm/customer.ts), not just a migration-file SQL comment. See PROJECT_DECISIONS #40. - DR-53 (added 2026-07-08, Remediation Plan Phase 4, Item 18) —
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 rather than restating it. Additive, nullable, zero backfill risk. See PROJECT_DECISIONS #40.
10. Deferred / Future Items
All items tracked in docs/open-items/OPEN_ITEMS.md. Summary for context:
| Item | Status | Trigger |
|---|---|---|
| Merge-reversal 24h window | open | customer_merge.metadata snapshot supports reversal in principle, but no CrmService method implements it, and v1's "24h reversal window" policy isn't enforced by anything in this schema (a business rule, not a DB constraint). Trigger: when CrmService is built. |
customer.consumer_id forward-ref |
deferred | Deferred until the consumer module is actually designed/built under the v2 pipeline (verified: doesn't exist in v2 today). Trigger: when consumer is built. |
customer.search_vector (Search FTS touch) |
deferred | The search module doesn't exist in v2 either (verified live), and no v2-built table anywhere uses a generated tsvector column yet (no Drizzle precedent to follow, unlike the consumer/agent_duty_grant precedents this session already established for other deferrals). Trigger: when the Search module is actually designed/built in v2. |
customer_tax_certificate.document_ref forward-ref |
deferred | The files module doesn't exist in v2 either. Trigger: when Files module is built. |
crm service-layer methods entirely |
open | No CrmService exists yet; this build is schema-only (Drizzle + migration + tests against raw SQL, no NestJS service). Trigger: when CrmService is built (parallels the exact same OPEN_ITEMS pattern used for agent_duty_grant's missing service methods, PROJECT_DECISIONS #22). |
| Vrida-default segment definitions not seeded | open | customer_segment_definition supports built-in (NULL tenant_id) segments like vip/at_risk/seasonal/wholesale_like, but none are actually seeded in this pass (schema-only). Trigger: when CrmService is built, seed the Vrida-default segment catalog. |
| Credit-limit service-layer enforcement | open | customer.credit_limit_cents/customer.credit_terms (Remediation Phase 3, Item 8, 2026-07-08, DR-51) are schema-only — nothing yet blocks a charge-account tender from exceeding the customer's credit limit. Trigger: when CrmService/BillingService (or the charge-account tender path) is built. |
payment_terms_id/credit_terms vocabulary sync |
open | customer.payment_terms_id (Remediation Phase 4, Item 17b, 2026-07-08, DR-52) is not yet kept in sync with customer.credit_terms, and the two vocabularies are not a clean subset of each other (4 catalog codes have no credit_terms equivalent). Trigger: before any cutover to the catalog as sole source of truth — requires a real mapping decision for cod/prepaid/net_7/2_10_net_30. |
pii_vault_ref vault service |
open | customer.pii_vault_ref (Remediation Phase 4, Item 18, 2026-07-08, DR-53) has no vault service to point to yet — shares the existing platform.tenant_profile.ein_ref vault-service-not-yet-built OPEN_ITEMS dependency, not a new one. Trigger: when the vault-encryption service is built. |
11. Cross-Module Seams
Seams are cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated here). Key relationships:
- crm → shared:
address.country_code/address.region_codeandcustomer_tax_certificate.issuing_country_code/issuing_region_codeFK toshared.country.iso_alpha2/shared.administrative_region.iso_3166_2. Both nullable exceptissuing_country_code(NOT NULL, default'US'). Added 2026-07-08 (Remediation Phase 4, Item 17b):customer.payment_terms_idFKs to the newshared.payment_terms_catalog— nullable, additive-interim alongsidecustomer.credit_terms's unchanged CHECK-enum, explicitly NOT a clean vocabulary subset (see DR-52). - crm → multi_loc:
customer_note.site_idandcustomer_task.site_id— nullable FK →multi_loc.site, a deliberate narrow exception to "master data doesn't carry site_id" since these are event/interaction records, not master data (DR-39). - crm → identity: every actor-attribution column (
created_by_actor_id,updated_by_actor_id,reviewed_by_actor_id,verified_by_actor_id,proposed_by_actor_id,merged_by_actor_id,captured_by_actor_id,computed_by_actor_id,assigned_to_actor_id) FKs toidentity.actor. Prospective consumer ofidentity.agent_duty_grantfor autonomy enforcement onceCrmServiceexists (§8). - crm → consumer (DEFERRED):
customer.consumer_idis a plain nullable UUID with no FK — the cross-tenant consumer-identity bridge is deferred until theconsumermodule is designed/built in v2 (DR-34). Not wired this pass. - crm → Billing (BUILT 2026-07-07, seam corrected 2026-07-08): charge accounts and accounts-receivable lifecycle (
ar_account/ar_charge/ar_payment/ar_adjustment) are Billing's ownership. Credit-worthiness (customer.credit_limit_cents/customer.credit_terms) iscrm's ownership — these columns were removed at 2026-07-06 lock (DR-35) on the mistaken premise that Billing owned them outright, then restored 2026-07-08 by Remediation Phase 3, Item 8 (DR-51) oncebilling.ar_account's own column comment was found to already assume — incorrectly — thatcrm.customerhad them. The corrected, now-live seam:billing.ar_accountreadscrm.customer.credit_limit_cents/credit_terms, never duplicates them. See PROJECT_DECISIONS #39. - crm → Rewards (NOT BUILT): loyalty program membership/points are out of
crm's ownership. Verified live that therewardsschema does not exist in v2 —MODULE_INDEX.md/CROSS_MODULE_CONTRACTS.mdmarking it "locked" is stale v1/pre-pivot documentation. Ownership boundary only; no seam exists yet. - crm → Offers (NOT BUILT): discount/promo definitions and customer-targeted offers are out of
crm's ownership. Same stale-documentation caveat as Rewards — verified not built in v2. Ownership boundary only; no seam exists yet. - crm → Notifications (NOT BUILT): marketing campaign send mechanics (actual email/SMS dispatch) are out of
crm's ownership —crmonly trackscustomer_consent/customer.marketing_opt_inas the targeting/eligibility source of truth; dispatch itself belongs to Notifications. Ownership boundary only; no seam exists yet. - crm → Pricing:
customer_group_idis a real, already-existing cross-module FK target —pricing.price_list_assignment.customer_group_id → crm.customer_group.id(Pricing not yet built in this v2 pass; recorded for when it is).