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

# Custom fields

> Add typed, validated, owner-scoped fields to core entities like bookings, people, and proposals at runtime: ownership, field types, write validation, and channel visibility.

The common client ask is "add a few fields to bookings, people, or products." Every core entity already carries a free-form `metadata` jsonb as the unstructured escape hatch, but you usually want more than a bag of untyped values. **Custom fields** turn a *declared* subset of that space into fields that are validated on write, visibility-aware, and PII-aware, without forking the platform.

Custom fields live in `@voyant-travel/custom-fields`. Definitions are **data, not code**: they are rows in `custom_field_definitions`, created and managed at runtime through the module's API rather than declared in a source file and shipped in a build.

## Ownership

Every definition has an **owner**, and the owner is what keeps one party's fields from colliding with another's:

| Owner kind | Who it is                                                                                                                                     |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `platform` | Shipped with the platform.                                                                                                                    |
| `operator` | Created by the operator for their own business.                                                                                               |
| `app`      | Namespaced to an installed [app](/docs/platform/extending/apps), so an app can carry its own data on core entities without touching anyone else's. |

`operatorCustomFieldDefinitionOwner` and `createAppCustomFieldDefinitionOwner(...)` construct owners, and `assertCustomFieldDefinitionOwner(...)` enforces that a writer only touches definitions it owns. An app cannot edit operator fields, and vice versa.

## Field types

`customFieldTypeEnum` is the closed set:

`varchar`, `text`, `double`, `monetary`, `date`, `boolean`, `enum`, `set`, `json`, `address`, `phone`.

Each definition also carries a lifecycle state (`customFieldLifecycleStateEnum`), so a field can be retired without deleting historical values.

## How the registry resolves

Definitions come from the `custom_field_definitions` table, scoped by owner. There is no code-declared tier to merge with and no build step: creating a field is an API call, and it takes effect without a deploy.

Because definitions live in the database, the registry is resolved **per request** from a `db` handle rather than being a boot-time constant. The platform passes it into the entity services that validate on write.

## Managing fields

Definitions are backed by the `custom_field_definitions` table (TypeID prefix `cfdf`) and managed through the admin API under `/v1/admin/relationships/custom-fields`. An app manages its own namespaced definitions the same way, through its granted scopes.

| Method and path                                               | What it does                                       |
| ------------------------------------------------------------- | -------------------------------------------------- |
| `GET /v1/admin/relationships/custom-fields?entityType=person` | List definitions, optionally filtered by entity    |
| `POST /v1/admin/relationships/custom-fields`                  | Create a definition                                |
| `GET /v1/admin/relationships/custom-fields/{id}`              | Get one definition                                 |
| `PATCH /v1/admin/relationships/custom-fields/{id}`            | Update its label, required, searchable, or options |
| `DELETE /v1/admin/relationships/custom-fields/{id}`           | Remove a definition                                |

A definition payload looks like this. The runtime surface uses `fieldType`, `isRequired`, and `isSearchable`, the runtime equivalents of the code-declared `type`, `required`, and `visibility.search`.

```json theme={null}
{
  "entityType": "person",
  "key": "loyalty_tier",
  "label": "Loyalty tier",
  "fieldType": "enum",
  "isRequired": false,
  "isSearchable": true,
  "options": [
    { "label": "Silver", "value": "silver" },
    { "label": "Gold", "value": "gold" }
  ]
}
```

Two rules keep stored data safe. `entityType` and `fieldType` are **immutable** after creation: changing the entity type would orphan every stored value, and changing the field type would reinterpret stored JSON under the wrong type, so both are omitted from the update surface. Renaming `key` is allowed; the service migrates the stored JSON keys in lockstep.

<Note>
  Runtime definitions cover the CRM entity types (`organization`, `person`, `quote`, `activity`), and their runtime field types (`varchar`, `text`, `double`, `monetary`, `date`, `boolean`, `enum`, `set`, `json`, `address`, `phone`) map onto the canonical code-declared types (`varchar` to `text`, `double` to `number`, `enum` to `select`, `set` to `multiselect`, `address` and `phone` to `json`). Code-declared fields are broader: their `entity` is any string. On a `(entity, key)` collision the code-declared field wins, so a deployment's code contract is never overridden by a runtime edit.
</Note>

## Validation on write

When an entity is written, the service validates the incoming custom-fields payload against the registry with `validateCustomFields(registry, entity, input)`. It rejects unknown keys, errors on missing required fields, and type, options, and custom-rule checks every present value. It returns the cleaned value and any errors; the caller persists `value` into the entity's custom-fields JSON only when `ok` is true.

```ts theme={null}
import { validateCustomFields } from "@voyant-travel/core/custom-fields"

const result = validateCustomFields(registry, "booking", {
  group_size: 12,
  meal_plan: "half-board",
})

if (!result.ok) {
  // result.errors: [{ key, message }, ...]
}
// Persist result.value into the booking's custom_fields jsonb.
```

## Channel visibility

Each field declares whether it surfaces in exports, invoices, and search, so those readers consult the registry instead of dumping or hiding everything. Defaults are conservative: visible in exports, hidden from invoices and search.

```ts theme={null}
import { customFieldsVisibleIn } from "@voyant-travel/core/custom-fields"

// Only the booking fields flagged visible on invoices.
const onInvoice = customFieldsVisibleIn(registry, "booking", "invoice")
```

<Note>
  A `monetary` field stores money the same way the rest of the platform does: integer minor units plus an ISO-4217 currency (`{ amountCents, currency }`). See [Data models](/docs/concepts/how-it-is-built) for the money convention.
</Note>

## Where custom fields are available

Custom-field validation is wired into the write paths of the entities that opt in, today including [people and organizations](/docs/platform/modules/relationships), [bookings](/docs/platform/modules/bookings), and [proposals](/docs/platform/modules/proposals). An entity adopts custom fields by carrying a custom-fields JSON column and consulting the injected registry on write, so the set can grow without changing this contract.

## Next steps

<CardGroup cols={2}>
  <Card title="Data models" icon="table" href="/docs/concepts/how-it-is-built">
    The entity columns, money convention, and JSON storage behind custom fields.
  </Card>

  <Card title="Build an app" icon="cubes" href="/docs/platform/extending/apps">
    The same drop-a-folder discovery seam, applied to whole capabilities.
  </Card>

  <Card title="Relationships" icon="users" href="/docs/platform/modules/relationships">
    People and organizations, the most common home for custom fields.
  </Card>

  <Card title="Configuration" icon="sliders" href="/docs/platform/fundamentals/configuration">
    How a deployment declares and injects its own config and capabilities.
  </Card>
</CardGroup>
