Skip to main content

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)

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

Changelog

Delivery record

Not implemented yet.