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)
- A definition is scoped to one tenant.
keyis unique per tenant and immutable — renaming it would orphan every segment referencing it. - The type is immutable after creation. Changing
stringtonumberagainst existing values is a migration, not an edit; the correct operation is a new definition. - 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.
enumdefinitions carry their allowed values; adding one is fine, removing one is only allowed when no contact holds it.- Definitions are cached per tenant and invalidated on change. Validation is on the write path and must never join.
- 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.
- The segment DSL sees definitions as typed condition targets — a
dateattribute offers date operators, anumberoffers numeric ones. - 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.
- FORBIDDEN: a custom attribute holding a national ID or any value the platform already models.
Data (normative)
Values live in
core.contacts.custom_attributes JSONB, with a GIN index for segment queries.
API (normative)
Events (normative)
None. A definition change is administrative and covered by the audit log; contact attribute changes already emitcore.contact.updated.
Acceptance criteria (normative)
- A value violating its type is rejected with a typed error naming the attribute.
keyandtypecannot be changed after creation.- Removing an
enumvalue still held by a contact is rejected. - A soft-deleted definition leaves its values intact and readable.
- Segment conditions on a
dateattribute offer date operators and reject a string comparison. - Validation performs zero database joins on the write path.
- A promoted attribute reads from its column with the definition unchanged from the caller’s view.
- Negative: a definition with a key colliding with a platform field is rejected.
Execution
Single slice, synchronous command. F1b. The Zod generator lives inpackages/core beside the
catalog union generator — same pattern, same cache discipline.