Skip to main content
Traducción. Autoritativo: ../../standards/data.md.

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:
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.