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

# Definiciones de atributos personalizados

> Cuando esto se entregue, un gimnasio podrá guardar "plan de membresía" y una clínica "previsión de salud" sobre un contacto, y ambos podrán segmentar por eso — sin que ninguno se convierta en una migración de schema.

> Traducción. Autoritativo: [`fs-core-0011-custom-attributes.md`](/modules/core/features/fs-core-0011-custom-attributes).

## Contexto

Todo tenant tiene campos que no anticipamos, y las dos respuestas obvias son ambas incorrectas. Un
JSONB libre es inconsultable e intipable, así que el constructor de segmentos no lo puede ofrecer y
la validación no lo puede proteger. Una migración por tenant no es un producto.

La respuesta es una **definición tipada**: el tenant declara el atributo y su tipo una vez, y esa
declaración alimenta la validación Zod al escribir, los campos disponibles en el constructor de
segmentos y la presentación en la consola. Los valores viven en JSONB sobre el contacto; el *schema*
de ese JSONB es dato.

## Alcance *(normativo)*

* `core.attribute_definitions`: clave, etiqueta, tipo y restricciones por tenant.
* Tipos: `string`, `number`, `date`, `boolean`, `enum`.
* Generación de esquema Zod desde las definiciones, cacheada por tenant.
* Validación de los atributos personalizados del contacto en cada escritura.
* Exposición al DSL de segmentos como objetivos de condición tipados.
* Promoción de un atributo caliente a columna real, como operación documentada.

## Fuera de alcance *(normativo)*

* Atributos sobre entidades distintas del contacto. Loyalty y CRM definen los suyos si los necesitan.
* Atributos calculados o derivados. Un valor que se calcula es un segmento, no un atributo.
* Definiciones compartidas entre tenants. Toda definición pertenece a exactamente un tenant.

## Comportamiento *(normativo)*

1. Una definición está acotada a un tenant. `key` es único por tenant e **inmutable** — renombrarlo
   dejaría huérfano a todo segmento que lo referencie.
2. El tipo es inmutable tras la creación. Cambiar `string` a `number` contra valores existentes es
   una migración, no una edición; la operación correcta es una definición nueva.
3. Los valores se validan contra el esquema Zod generado en **cada** escritura. Un valor inválido se
   rechaza — un atributo tipado que acepta cualquier cosa en silencio es un JSONB disfrazado.
4. Las definiciones `enum` llevan sus valores permitidos; agregar uno está bien, quitar uno solo se
   permite cuando ningún contacto lo tiene.
5. Las definiciones se cachean por tenant y se invalidan al cambiar. La validación está en el camino
   de escritura y nunca debe unir tablas.
6. Borrar una definición es un **borrado lógico**. Los valores quedan sobre los contactos; solo se
   retira la definición, porque segmentos y exportaciones pueden seguir referenciando datos
   históricos.
7. El DSL de segmentos ve las definiciones como objetivos de condición tipados — un atributo `date`
   ofrece operadores de fecha, uno `number` ofrece numéricos.
8. **Promoción**: un atributo usado en caminos calientes se puede promover a columna real mediante
   una migración documentada. La definición pasa a apuntar a la columna y se descarta la copia JSONB.
   Esta es la salida de emergencia que mantiene honesto al patrón a escala.
9. PROHIBIDO: un atributo personalizado que contenga un documento de identidad o cualquier valor que
   la plataforma ya modela.

## Datos *(normativo)*

| Tabla                        | Invariantes clave                                                                                                                                                                |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.attribute_definitions` | `tenant_id`; `key` único por tenant e inmutable; `label`; `type`; `enum_values` JSONB nullable; `is_required` BOOL; `promoted_column` nullable; `is_active`; solo borrado lógico |

Los valores viven en `core.contacts.custom_attributes` JSONB, con índice GIN para las consultas de
segmentos.

## API *(normativo)*

| Endpoint                                        | Clase      | Permiso                                  | Presupuesto |
| ----------------------------------------------- | ---------- | ---------------------------------------- | ----------- |
| `GET/POST/PATCH /v1/core/attribute-definitions` | Management | `core.attributes.{read\|create\|update}` | p95 \<1 s   |

## Eventos *(normativo)*

Ninguno. Un cambio de definición es administrativo y queda cubierto por la auditoría; los cambios de
atributos del contacto ya emiten `core.contact.updated`.

## Criterios de aceptación *(normativo)*

1. Un valor que viola su tipo se rechaza con un error tipado que nombra el atributo.
2. `key` y `type` no se pueden cambiar tras la creación.
3. Quitar un valor de `enum` que todavía tiene un contacto se rechaza.
4. Una definición con borrado lógico deja sus valores intactos y legibles.
5. Las condiciones de segmento sobre un atributo `date` ofrecen operadores de fecha y rechazan una
   comparación de string.
6. La validación no hace ninguna unión de base de datos en el camino de escritura.
7. Un atributo promovido se lee desde su columna sin que cambie nada desde la vista del llamador.
8. **Negativo:** una definición con una clave que colisiona con un campo de plataforma se rechaza.

## Ejecución

Un solo slice, comando síncrono. F1b. El generador Zod vive en `packages/core` junto al generador de
unions de catálogos — mismo patrón, misma disciplina de caché.

## Preguntas abiertas

| # | Pregunta                                                                      | Decide | Para             |
| - | ----------------------------------------------------------------------------- | ------ | ---------------- |
| 1 | Cantidad máxima de definiciones por tenant: ¿hay tope, y es gateado por plan? | Daniel | antes de aprobar |
| 2 | ¿Soportamos un tipo `list` (multivalor), o eso es asunto de segmentos?        | Daniel | antes de aprobar |

## Changelog

| Versión | Fecha      | Cambio           | Por qué | Autor                  |
| ------- | ---------- | ---------------- | ------- | ---------------------- |
| 0.1.0   | 2026-08-17 | Borrador inicial | —       | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
