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

# Custom Attribute Definitions

> After this ships, a gym can store "membership plan" and a clinic can store "insurance provider" on a contact, and both can segment on it — without either becoming a schema migration.

## Context

Every tenant has fields we did not anticipate, and the two obvious answers are both wrong. A free
JSONB blob is unqueryable and untypeable, so the segment builder cannot offer it and validation
cannot protect it. A migration per tenant is not a product.

The answer is a **typed definition**: the tenant declares the attribute and its type once, and that
declaration drives Zod validation on write, the segment builder's available fields, and the display
in the console. The values stay in JSONB on the contact; the *schema* of that JSONB is data.

## Scope *(normative)*

* `core.attribute_definitions`: key, label, type, and constraints per tenant.
* Types: `string`, `number`, `date`, `boolean`, `enum`.
* Zod schema generation from definitions, cached per tenant.
* Validation of contact custom attributes on every write.
* Exposure to the segment DSL as typed condition targets.
* Promotion of a hot attribute to a real column, as a documented operation.

## Non-scope *(normative)*

* Attributes on entities other than contacts. Loyalty and CRM define their own if they need them.
* Computed or derived attributes. A value that is computed is a segment, not an attribute.
* Cross-tenant shared definitions. Every definition belongs to exactly one tenant.

## Behaviour *(normative)*

1. A definition is scoped to one tenant. `key` is unique per tenant and **immutable** — renaming it
   would orphan every segment referencing it.
2. The type is immutable after creation. Changing `string` to `number` against existing values is a
   migration, not an edit; the correct operation is a new definition.
3. Values are validated against the generated Zod schema on **every** write. An invalid value is
   rejected — a typed attribute that silently accepts anything is a JSONB blob wearing a costume.
4. `enum` definitions carry their allowed values; adding one is fine, removing one is only allowed
   when no contact holds it.
5. Definitions are cached per tenant and invalidated on change. Validation is on the write path and
   must never join.
6. Deleting a definition is a **soft delete**. The values stay on the contacts; only the definition
   is retired, because segments and exports may still reference historic data.
7. The segment DSL sees definitions as typed condition targets — a `date` attribute offers date
   operators, a `number` offers numeric ones.
8. **Promotion**: an attribute used in hot paths can be promoted to a real column by a documented
   migration. The definition then points at the column and the JSONB copy is dropped. This is the
   escape hatch that keeps the pattern honest at scale.
9. FORBIDDEN: a custom attribute holding a national ID or any value the platform already models.

## Data *(normative)*

| Table                        | Key invariants                                                                                                                                                                |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.attribute_definitions` | `tenant_id`; `key` unique per tenant, immutable; `label`; `type`; `enum_values` JSONB nullable; `is_required` BOOL; `promoted_column` nullable; `is_active`; soft delete only |

Values live in `core.contacts.custom_attributes` JSONB, with a GIN index for segment queries.

## API *(normative)*

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

## Events *(normative)*

None. A definition change is administrative and covered by the audit log; contact attribute changes
already emit `core.contact.updated`.

## Acceptance criteria *(normative)*

1. A value violating its type is rejected with a typed error naming the attribute.
2. `key` and `type` cannot be changed after creation.
3. Removing an `enum` value still held by a contact is rejected.
4. A soft-deleted definition leaves its values intact and readable.
5. Segment conditions on a `date` attribute offer date operators and reject a string comparison.
6. Validation performs zero database joins on the write path.
7. A promoted attribute reads from its column with the definition unchanged from the caller's view.
8. **Negative:** a definition with a key colliding with a platform field is rejected.

## Execution

Single slice, synchronous command. F1b. The Zod generator lives in `packages/core` beside the
catalog union generator — same pattern, same cache discipline.

## Open questions

| # | Question                                                                         | Decides | By              |
| - | -------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Maximum number of definitions per tenant — is there a cap, and is it plan-gated? | Daniel  | before approval |
| 2 | Do we support a `list` type (multi-value), or is that a segment concern?         | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
