> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom fields: the definition model and the dependency-scan lifecycle

> What a custom-field definition is, how contact values live in two JSONB columns, who can change the schema, what the pre-delete dependency scan covers, and how a force-delete stays safe.

# Custom fields: the definition model and the dependency-scan lifecycle

A custom field turns business-specific contact metadata - a plan tier, an
account manager, a renewal date - into a typed, validated, queryable
attribute instead of a freeform note. Two different models sit behind that:
the **definition** (your per-tenant schema that says what a field is and
how its values are validated) and the **value** (the key's value stored on
each contact). This page is the definition model the
[Custom Fields guide](/guides/custom-fields) and the
[Custom Fields API reference](/api-reference/custom-fields) assume: what a
definition is, which column a value lands in, who can edit the schema, and
why the delete path runs a dependency scan before it removes anything.

## What a custom-field definition is

A definition is a row in the tenant-scoped `custom_field_definitions`
table that describes one allowed key on a contact. Its shape:

* `key` - the machine identifier (e.g. `plan_tier`). Frozen at creation.
* `type` - `text`, `number`, `date`, `boolean`, `select`, `multiselect`,
  or `user_ref`. Frozen at creation, alongside the `key`, because changing
  either would orphan values already stored on contacts.
* `options` - the allowed list, required for `select` / `multiselect`.
* `default_value` - backfilled onto contacts at create time.
* `required` - gates every contact create / update until a value is
  supplied. See the required-field gate below.
* `searchable` - whether the field participates in its pickers.
* `display_order` - its position in the field list; the reorder endpoint
  in the route table below writes to it.
* Validators - `validator_regex`, `min_len` / `max_len`, and
  `enum_values`, applied whenever a value is written. A failing value
  returns `422 VALIDATION_ERROR`.

A typical definition reads like this:

```json theme={null}
{
  "key": "plan_tier",
  "type": "select",
  "name": "Plan tier",
  "options": ["starter", "growth", "enterprise"],
  "required": false,
  "searchable": true,
  "display_order": 3,
  "validator_regex": null,
  "min_len": null,
  "max_len": null,
  "enum_values": null
}
```

Definitions are the schema; the actual values live on the contact.

## The dual JSONB storage model

Contact custom-field values occupy **two** JSONB columns on the `contacts`
table, not one:

* `custom_attributes` - the canonical store. Inline edits, the
  `PUT /custom-fields/values` write, and create-time promotion write here.
* `attributes` - the legacy store. CSV import packs values here, and the
  create form / required-gate path reads from here.

The usage counter, sample explorer, and dependency scan probe **both**
columns, in a fixed COALESCE order - canonical `custom_attributes` first,
legacy `attributes` second - so a per-contact key collision resolves
deterministically to the canonical value, and a field populated only in
the legacy store still reports its usage. This is the
`CONTACT_ATTRIBUTE_COLUMNS` tuple in the controller, and it is why a
CSV-imported audience shows a non-zero usage count even though it never
wrote a single `custom_attributes` byte.

Because one of the columns is added by a later tenant migration, the scan
first checks which of the two columns exist on the tenant (a base-schema
column is always there; the canonical one arrives later). The probe THEN
builds its predicate from only the present columns, so a partial-migration
tenant gets a real count from whichever column it has - instead of a
statement that referenced both columns and failed at analysis time,
silently reporting zero usage. This is why "Usage shows 0 for a populated
field" on an imported audience is a real care-more case, not a cosmetic
one.

## Roles - who can do what

All routes in this group sit behind tenant authentication. Schema-changing
writes - create, update, delete, reorder, set value, force-delete -
require **owner, admin, or developer**. Viewers can read the definitions,
dependency scan, and explorer payload. The distinction matters because
reorder and force-delete are schema mutations even when they touch no
values.

## Validation rules degrade, not error

A value that fails the field's validators returns `422 VALIDATION_ERROR`.
The exception is reorder: `POST /custom-fields/reorder` accepts a list of
definition IDs and **ignores unknown IDs** rather than erroring on them.
That degrade-not-error choice keeps an optimistic UI update safe if
another admin deletes a row mid-drag - the request succeeds and omitted
IDs keep their current `display_order`. Duplicate IDs in the same request
are the one hard rejection, because they would silently collide on
position.

## The dependency scan

Deleting a definition without knowing who references it breaks every
dependent surface silently - a segment filter returns zero matches, a
campaign mediates on a broken personalization token, a flow condition
never fires. The dependency scan enumerates the blast radius BEFORE any
delete is allowed through. It reports counts and id/name lists across
five surfaces:

* `contacts_with_data` - contacts holding the key in either attribute
  column, counted via the dual-column probe above.
* `segments` - segments referencing the key in their `filters` ast or the
  legacy `rules` column.
* `campaigns` - campaigns referencing the key in `message_template`
  (mustache `{{contact.custom.<key>}}`, a TEXT scan), `variables`, or
  `journey_definition` (structured filter JSON).
* `drip_enrollments` - active or paused drip enrollments tied to any
  campaign in the list above (a count, not an id list, because enrollments
  are not an operator-facing concept).
* `flows` - automation flows and IVR flows referencing the key in their
  `definition` or `trigger_config` (plus `published_definition` for IVR).

Because a segment can reference a custom field by its dotted path
(`custom_attributes.<key>`), the legacy singular prefix
(`custom_attribute.<key>`), or a bare filter-AST `field` leaf, the scan
matches all three markers. The five-surfaces fan-out runs concurrently;
only the drip-enrollment count resolves after campaigns, because it
filters on the campaign id list. Every surface is `LIKE`-matched over a
`COALESCE(...::text)` cast so a missing column degrades to a zero count
instead of throwing. Empty arrays and zero counts are a normal 200 - they
mean the field is unused, which is exactly the answer the UI needs to
render "safe to delete."

A dependency-scan response looks like this:

```json theme={null}
{
  "field_key": "customer_tier",
  "contacts_with_data": 45,
  "segments": [{ "id": "seg_01J8Z9K3P4Q5R6S7T8U9V0W1X5", "name": "Gold tier customers" }],
  "campaigns": [{ "id": "cmp_01J8Z9K3P4Q5R6S7T8U9V0W1X6", "name": "Tier upgrade nudge" }],
  "drip_enrollments": 12,
  "flows": []
}
```

## The force-delete flow

Deletion is a two-phase safety gate. The scan above powers both phases:

1. **Soft delete** (`DELETE /custom-fields/:id` without `force=true`) -
   scans the dependents. If any surface has a match, the request returns
   `409 CUSTOM_FIELD_IN_USE` with the full snapshot embedded in the error
   details, and nothing is deleted. The UI renders the snapshot in a
   type-to-confirm dialog so the operator sees exactly what breaks.
2. **Force delete** (`DELETE /custom-fields/:id?force=true`) - confirms
   the operator has seen the blast radius, then deletes. Dependent
   segments are marked `status = 'broken'`, dependent campaigns are
   flagged in `metadata` (not their lifecycle status, so an in-flight
   send isn't cut mid-recipient), dependent flows are marked `broken`
   (IVR flows flip `active = false`), and the definition's key is
   stripped from every contact's attribute blob regardless. A
   per-surface audit row records exactly which segments, campaigns,
   flows, and drip enrollments broke, so the cleanup trail is
   reconstructable.

That split is what makes the force path safe: the soft-check surfaces the
blast radius, the type-to-confirm makes the operator acknowledge it, and
the broken-flagging makes the dependents findable for cleanup instead of
silently failing to match.

## Where segments and formulas pick the field up

Custom fields are first-class segment operands. The segmentation engine
accepts both the canonical `custom_attributes.<key>` and the legacy
`custom_attribute.<key>` prefix (the field-path regex
`/custom_attributes?\./` permits either), and renders each filter against
a COALESCE spanning both attribute columns, canonical-first. That means a
segment filter written against a CSV-imported field still matches
correctly once values are promoted into `custom_attributes`, and vice
versa - the read path is deliberately identical to the dependency scan's
resolution rule, so "45 contacts hold this field" and "segment returns
45" agree.

## PII handling

Values returned through the schema-explorer (`GET /:id/explorer`) are
masked by the caller's reveal context: before any sample value leaves the
API it passes through PII redaction (`redactPii`), and strings longer
than 120 chars are tail-truncated with an ellipsis. Redaction is
defense-in-depth - the explorer is limited to owner/admin/developer, but
a mis-keyed custom attribute should never echo a raw phone number or
email into an operator panel.

## Required-field gate

When a definition is marked `required`, every contact create or update is
gated until a value for it is present. The gate reads the definitions
store and caches it per organization (60-second TTL) with invalidation
hooks on every definition mutate - create, update, delete, and the
reorder do not affect the gate but create/update/delete do - so toggling
Required takes effect on the next write rather than after the cache
lapses. A write missing a mandatory value returns `422` instead of
persisting partial data.

## The route surface at a glance

The definition controller surfaces roughly a dozen routes. Grouped by
verb:

| Method   | Path                                     | Purpose                                                          |
| -------- | ---------------------------------------- | ---------------------------------------------------------------- |
| `GET`    | `/api/v1/custom-fields/`                 | List all definitions in display order                            |
| `GET`    | `/api/v1/custom-fields/:id`              | Fetch one definition                                             |
| `GET`    | `/api/v1/custom-fields/:id/dependencies` | Dependency scan preview                                          |
| `GET`    | `/api/v1/custom-fields/:id/explorer`     | Definition + usage + PII-redacted samples                        |
| `POST`   | `/api/v1/custom-fields/`                 | Create a definition                                              |
| `PATCH`  | `/api/v1/custom-fields/:id`              | Update editable attributes (key/type frozen)                     |
| `DELETE` | `/api/v1/custom-fields/:id`              | Soft-delete check - 409 with snapshot if referenced              |
| `DELETE` | `/api/v1/custom-fields/:id?force=true`   | Force-delete - flag dependents broken, strip values              |
| `POST`   | `/api/v1/custom-fields/reorder`          | Persist display order (unknown IDs ignored, duplicates rejected) |
| `PUT`    | `/api/v1/custom-fields/values`           | Set one value on one contact (validated against the definition)  |

Every one of these routes is tenant-authenticated; the mutations require
owner/admin/developer.

## See also

* [Custom Fields guide](/guides/custom-fields) - when to use a field, how the types and validators work, and the curl flow
* [Custom Fields API reference](/api-reference/custom-fields) - full endpoint list and request/response schemas
* [Segments](/api-reference/segments) - filtering contacts by a custom field
* [Consent, opt-out, and suppression model](/concepts/consent-and-suppression-model) - the other tenant-owned contact-state gates
