> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Estándar — Datos (v2, actualización mayor para Engage/CRM)

> Alcance: toda tabla, migración y consulta del monorepo. Referencias: DEC-A, DEC-B.

> Traducción. Autoritativo: [`../../standards/data.md`](/standards/data).

## 1. Organización de schemas

* Un schema de Postgres por bounded context: `identity`, `core`, `loyalty`, `messaging`, `crm`
  (más los schemas existentes de la suite). Drizzle: `pgSchema('<nombre>')`.
* Toda tabla con alcance de tenant lleva `tenant_id` + `cell_id`; tenant guards y RLS obligatorios;
  el acceso a la base SOLO por `getDb(tenantCtx)`.
* Las FK entre schemas se permiten únicamente hacia `core`. `loyalty`/`messaging`/`crm` NUNCA
  referencian tablas entre sí. `identity` **no** tiene ninguna FK entre schemas: es una fundación
  que nadie más lee directamente (ADR-022).

## 2. Tablas paramétricas (enums de negocio)

* NUNCA enums de Postgres para valores de negocio. Todo conjunto cerrado vive en
  `{schema}.{entidad}_types` (o en `core.*` si es realmente transversal: currencies,
  notification\_channels, national\_id\_types).
* Columnas estándar: `code` TEXT PK (clave de negocio estable), `label`, `is_system` BOOL,
  `is_active` BOOL, `sort_order`, `metadata` JSONB.
* Las FK referencian `code` (texto). Los catálogos se cachean (memoria de proceso + Redis); los
  caminos calientes NO DEBEN unir catálogos.
* Las filas `is_system = true` se siembran en migraciones y generan los union types Zod/TS en
  `packages/core` (fuente única).
* Agregar una fila NO agrega comportamiento: las APIs rechazan códigos que la lógica de negocio aún
  no soporta, con un error tipado (`UNSUPPORTED_CODE`).
* Catálogos extensibles por tenant: SOLO donde el valor es taxonomía de negocio del tenant
  (`crm.activity_types`, `loyalty.reward_types`). NUNCA donde el valor gobierna una máquina de
  estados o lógica del sistema (tipos del ledger, estados de envío, estados de canje).
* Anti-ejemplo: agregar `'bonus'` a `loyalty.ledger_transaction_types` por SQL y esperar que el
  ledger lo maneje. INCORRECTO — exige cambio de código y de spec; la API debe rechazarlo hasta
  entonces.

## 2b. La unicidad es por tenant, nunca global

**Toda restricción de unicidad sobre datos con alcance de tenant incluye `tenant_id`.** No existe
en esta plataforma una clave natural que sea única a nivel de base de datos.

El caso que fija la regla: doña María es clienta de la empresa A y de la empresa B. Ambas corren un
programa Softcrum, ambas la crean como contacto y ambas pueden darle un login de member. Esas son
**dos personas completamente distintas para el sistema** — filas separadas, credenciales separadas,
consentimiento separado, puntos separados, y ninguna de las dos empresas puede enterarse jamás de
que existe la otra. Un email único global rompería eso en la primera colisión y, peor, convertiría
"invitar a esta dirección" en un oráculo que revela si esa persona es clienta de alguien más.

Aplica a: `core.contacts.email`, `core.contacts.national_id`, credenciales de member,
`identity.users.email`, y toda clave natural futura. El índice es parcial y con alcance de tenant:

```sql theme={null}
UNIQUE (tenant_id, email)          -- correcto
UNIQUE (email)                     -- PROHIBIDO sobre datos con tenant
```

Consecuencia aceptada deliberadamente: la misma persona con cuentas en dos tenants mantiene dos
juegos de credenciales. Vincularlas exigiría una identidad entre tenants, que es precisamente lo
que esta regla existe para impedir.

## 3. Dinero

* Montos monetarios: `amount` BIGINT en unidades menores + `currency_code` CHAR(3) FK
  `core.currencies` (ISO 4217). NUNCA floats. NUNCA una columna de monto sin su columna de moneda.
* F1: una sola moneda por tenant. `tenant.base_currency` es INMUTABLE tras la creación (el cambio
  solo por proceso asistido de migración). Multi-moneda y normalización FX se difieren a F2
  (ADR futuro).
* Los puntos NO son dinero: `points_amount` INTEGER + FK `loyalty.point_currencies`. Mezclar puntos
  y dinero en una columna está prohibido.

## 4. Particionamiento (por criterio, no por defecto)

* Se particiona (RANGE, mensual, sobre la columna de tiempo) SOLO las tablas append-only Y de alto
  volumen o con retención gestionada. Designadas en v1: `core.tracked_events`, `messaging.sends`,
  `messaging.send_status_history`, `core.audit_log`, `core.usage_snapshots`. Candidatas pendientes
  de datos reales: outbox, `loyalty.ledger_transactions`.
* Implementación: DDL en migraciones SQL custom (Drizzle no gestiona particiones declarativamente).
  Un job de cron pre-crea particiones N+2 meses; existe una partición DEFAULT que alerta si alguna
  vez recibe filas. pg\_partman: spike pendiente; se adopta si está disponible en Supabase.
* Toda consulta contra una tabla particionada DEBE incluir el predicado de la clave de partición
  (pruning). Anti-ejemplo: `SELECT * FROM core.tracked_events WHERE contact_id = $1` — INCORRECTO;
  agregar `AND occurred_at >= …`.

## 5. Retención y exportación

* Retención de detalle por plan: 13 meses (starter) / 25 meses (pro) / 37+ meses (enterprise) para
  `tracked_events` y `sends`.
* Antes de soltar una partición: exportar a Supabase Storage como NDJSON comprimido en
  `exports/{tenant}/{tabla}/{yyyymm}.ndjson.gz`; los agregados se conservan para siempre.
* Derecho de supresión (Ley 21.719): el perfil y los eventos se borran; las filas del ledger se
  ANONIMIZAN (la referencia al contacto queda en un tombstone), nunca se destruyen (integridad
  contable).

## 6. Clasificación de datos y `national_id`

* Niveles: público / interno / personal / sensible. Los controles por nivel se definen acá; rige la
  legislación MÁS ESTRICTA entre los países soportados (piso: Ley 21.719 + LGPD).
* `contacts.national_id` (+ `national_id_type` FK `core.national_id_types`): normalizado antes de
  persistir (normalizador por tipo), regex de formato obligatoria, dígito verificador validado donde
  exista algoritmo (flag `dv_validated`).
* Unicidad: índice único parcial `(tenant_id, national_id_type, national_id) WHERE national_id IS
  NOT NULL`. El mismo documento en tenants distintos siempre se permite.
* Manejo de lo sensible: enmascarado por defecto en toda UI y toda respuesta de API; el valor
  completo exige el permiso dedicado `core.contacts.read_national_id`; cada acceso al valor completo
  escribe en `core.audit_log`.
* `contact_kind` = `person | company`. Contactos empresa: `legal_name`, sin `birth_date`; los
  triggers por fecha aplican según el kind.

## 7. Auditoría (CQRS-lite)

* Todo command handler: UNA transacción = cambio de estado + evento(s) del outbox + fila de
  `core.audit_log` (actor tipado user/member/api\_key/system, entidad, acción, diff completo
  old→new, `correlation_id`, `occurred_at`).
* Los modelos de lectura son proyecciones; cada proyección documenta su procedimiento de
  reconstrucción.
* Event sourcing como sistema de registro: PROHIBIDO como patrón general (ADR-017). Los dominios
  append-only (ledger, medición, auditoría) ya lo proveen donde vale la pena.
