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 customer
  • address — billing/shipping addresses
  • customer_group — tenant-defined segments shared with Pricing
  • customer_note — append-only staff/agent observation log
  • customer_task — follow-up tasks
  • customer_merge_candidate — pending dedup proposal awaiting review
  • customer_merge — append-only post-execution merge audit
  • customer_consent — append-only consent event log
  • customer_tax_certificate — tax exemption certificates
  • customer_segment_definition — catalog of segments (built-in + tenant-custom)
  • customer_segment_membership — customer × segment membership
  • customer_tag_assignment — human free-form labels

Does NOT own:

  • Accounts-receivable balances / A/R lifecycle → Billing (billing.ar_account and related tables). Credit-worthiness itself (credit_limit_cents/credit_terms) is owned by crm.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_account reads these two columns from crm.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_id is a consuming FK, additive-interim alongside credit_terms — see §4/§9 DR-52.
  • Loyalty → Rewards. Verified live: the rewards schema does not exist in this v2 database as of this build, despite MODULE_INDEX.md/CROSS_MODULE_CONTRACTS.md describing 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 for offers is 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, the consumer schema does not exist). crm holds 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:

  • platformplatform.tenant is the FK target for every tenant-scoped crm table; platform.set_updated_at() trigger.
  • shared (module #3) — natural-key FKs shared.country.iso_alpha2 and shared.administrative_region.iso_3166_2 for global address shape on address and customer_tax_certificate's issuing-jurisdiction fields; plus (added 2026-07-08, Remediation Phase 4, Item 17b) shared.payment_terms_catalog as the FK target for customer.payment_terms_id.
  • multi_loc (module #4) — multi_loc.site FK on customer_note.site_id and customer_task.site_id (nullable, narrow exception — see §8).
  • identity (module #2) — new relative to MODULE_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_grant for 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.

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.rolecustomer_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 customer rows (individual or business); enforce customer_type-conditional identity fields; per-tenant customer_number uniqueness when present.
  • Contact management — additional named contacts per customer; enforce at most one is_primary contact 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_group catalog, shared with Pricing.
  • Interaction logging — append-only customer_note observations, optionally site-attributed.
  • Follow-up taskingcustomer_task lifecycle (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 a customer_merge (append-only audit, snapshot in metadata for reversal-in-principle).
  • Consent tracking — append-only customer_consent event log across marketing_email/sms/phone/privacy_terms/loyalty, cached to customer.marketing_opt_in.
  • Tax exemption certificate management — capture, track validity/expiry, and verify/reject customer_tax_certificate rows, defaulting to pending_review until a human (or future OCR-extraction agent under review) confirms.
  • Segmentation — catalog-driven customer_segment_definition (built-in + tenant-custom) and computed customer_segment_membership (confidence-scored, provenance-tracked, review-gated).
  • Tagging — ad-hoc human free-form customer_tag_assignment labels, 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-33email stays 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 explicit customer_merge_candidatecustomer_merge workflow, never a DB constraint.
  • DR-34customer.consumer_id is a deferred forward-ref (plain nullable UUID, no FK). Verified live (via psql \dn on the local Supabase instance) that the consumer schema does not exist in this v2 database — only identity, multi_loc, platform, shared, crm (as of this build) do. Also verified via filesystem (find docs/database/schema_docs -iname "*.md") that no consumer.md/rewards.md/offers.md exist. docs/modules/MODULE_INDEX.md and docs/modules/CROSS_MODULE_CONTRACTS.md describing consumer/rewards/offers as "locked" (2026-06-11/12) and crm.customer.consumer_id → consumer.consumer as "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 to search_vector (deferred — the search schema also doesn't exist in v2) and customer_tax_certificate.document_ref (deferred — the files schema also doesn't exist in v2).
  • DR-35credit_terms/credit_limit_cents (v1 columns on customer) were removed entirely at 2026-07-06 lock. MODULE_INDEX.md already assigned "customer credit" to the not-yet-built Billing module; v1's own schema doc had already split charge_account out 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-36customer.is_verified defaults true — deviates from shared.plant's false default. 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 in decision_provenance (row-level is_verified can't express mixed per-field trust — same documented workaround multi_loc.site.decision_provenance already established for climate_zone_code/measurement_system).
  • DR-37 — Global addresses: crm.address and crm.customer_tax_certificate's issuing-jurisdiction fields both use flat address lines + natural-key FKs into shared.country (iso_alpha2, char(2)) / shared.administrative_region (iso_3166_2, text), replacing v1's US-only us_state/country_id shape. Mirrors multi_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_status gate 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-41customer_merge_candidate is kept separate from customer_merge (not unified 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 (most candidates never produce a merge). Consolidation-pass-justified as two tables, not one.
  • DR-42customer_consent gets 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-43customer_tax_certificate.status defaults '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-44customer_tax_certificate reuses its existing status(pending_review)/verified_by_actor_id/verified_at columns as the review seam, rather than adding a parallel review_status/reviewed_by_actor_id/reviewed_at column set — v1 already built an equivalent gate; adding a duplicate would be redundant structure.
  • DR-45customer_segment_definition mirrors identity.role's mixed-scope pattern exactly: 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. 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-47customer_segment_membership uses a stored status column (active/expired), not a partial index predicate referencing now() (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 convention identity.role_assignment.ends_at/identity.agent_duty_grant.ends_at already 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_code changed from nullable to NOT NULL, restoring v1's country_id NOT NULL guarantee. Found by the 2026-07-07 crm/inventory/pricing erosion audit's adversarial pass: chk_address_region_country_match only fires when region_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's ON DELETE behavior was tightened from SET NULL to RESTRICT in 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 via pg_constraint.confdeltype='r' and live INSERT reproduction. See PROJECT_DECISIONS #28.
  • DR-49 (added 2026-07-07, crm reopen) — customer_tax_certificate gained chk_customer_tax_certificate_active_requires_verification (status <> 'active' OR (verified_by_actor_id IS NOT NULL AND verified_at IS NOT NULL)) and lost issuing_country_code's DEFAULT 'US'. Found by the same audit: status could be set 'active' — "this customer is currently tax-exempt" — with both verifier fields NULL, since DR-43's pending_review default only suggested the review workflow rather than enforcing it (a live bug, same class as pos'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 stays NOT NULL, callers must now state the country explicitly. Both are permanent fixes (unlike pos'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, and customer_merge (the module's three append-only event/audit tables) had their id DEFAULT changed from gen_random_uuid() to platform.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_assignment was 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, CHECK chk_customer_credit_limit_nonnegative) and customer.credit_terms (text, nullable, CHECK chk_customer_credit_terms IN due_on_receipt/net_15/net_30/net_45/net_60/net_90) are added back to crm.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," but crm.customer never actually carried these columns for it to read. The governing ownership principle is now explicit and enforced: crm.customer owns credit-worthiness (a relationship judgment about the customer — squarely crm's domain, same class of judgment as tax_exempt/customer_group_id), while billing.ar_account reads credit_limit_cents/credit_terms from 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 exceed credit_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 existing credit_terms CHECK-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) carries net_days/discount_percent/discount_days per row, letting 2_10_net_30 genuinely 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's cod/prepaid/net_7/2_10_net_30 codes 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 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) mirrors platform.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 existing ein_ref vault-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_code and customer_tax_certificate.issuing_country_code/issuing_region_code FK to shared.country.iso_alpha2/shared.administrative_region.iso_3166_2. Both nullable except issuing_country_code (NOT NULL, default 'US'). Added 2026-07-08 (Remediation Phase 4, Item 17b): customer.payment_terms_id FKs to the new shared.payment_terms_catalog — nullable, additive-interim alongside customer.credit_terms's unchanged CHECK-enum, explicitly NOT a clean vocabulary subset (see DR-52).
  • crm → multi_loc: customer_note.site_id and customer_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 to identity.actor. Prospective consumer of identity.agent_duty_grant for autonomy enforcement once CrmService exists (§8).
  • crm → consumer (DEFERRED): customer.consumer_id is a plain nullable UUID with no FK — the cross-tenant consumer-identity bridge is deferred until the consumer module 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) is crm'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) once billing.ar_account's own column comment was found to already assume — incorrectly — that crm.customer had them. The corrected, now-live seam: billing.ar_account reads crm.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 the rewards schema does not exist in v2 — MODULE_INDEX.md/CROSS_MODULE_CONTRACTS.md marking 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 — crm only tracks customer_consent/customer.marketing_opt_in as the targeting/eligibility source of truth; dispatch itself belongs to Notifications. Ownership boundary only; no seam exists yet.
  • crm → Pricing: customer_group_id is 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).
Last modified: Jul 8, 2026, 2:57 PM PT
On this page
Esc