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 porgetDb(tenantCtx). - Las FK entre schemas se permiten únicamente hacia
core.loyalty/messaging/crmNUNCA referencian tablas entre sí.identityno 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 encore.*si es realmente transversal: currencies, notification_channels, national_id_types). - Columnas estándar:
codeTEXT PK (clave de negocio estable),label,is_systemBOOL,is_activeBOOL,sort_order,metadataJSONB. - 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 = truese siembran en migraciones y generan los union types Zod/TS enpackages/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'aloyalty.ledger_transaction_typespor 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 incluyetenant_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:
3. Dinero
- Montos monetarios:
amountBIGINT en unidades menores +currency_codeCHAR(3) FKcore.currencies(ISO 4217). NUNCA floats. NUNCA una columna de monto sin su columna de moneda. - F1: una sola moneda por tenant.
tenant.base_currencyes 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_amountINTEGER + FKloyalty.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; agregarAND 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_eventsysends. - 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_typeFKcore.national_id_types): normalizado antes de persistir (normalizador por tipo), regex de formato obligatoria, dígito verificador validado donde exista algoritmo (flagdv_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 encore.audit_log. contact_kind=person | company. Contactos empresa:legal_name, sinbirth_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.