# How-to: model your domain

**Goal:** design and create a custom schema — attributes composed into types —
and change it safely once records exist.

Assumes you've done the [Quickstart](/docs/quickstart.html) and have `$LYDIAN`,
`$TENANT`, and `$KEY` exported. The examples model a music school, but the
mechanics are identical for any domain and any of the five record kinds.

---

## 1. Decide what is an attribute — and what isn't

Before creating anything, split your domain's fields three ways:

- **Base fields — already there.** Every person has `firstName`, `lastName`,
  `email`; every event has `name`, `description`, `startAt`, `endAt`; every
  document has `title`, `description`. Don't re-model these as attributes. Check what a
  kind gives you for free by reading `baseFields` on any of its types:

  ```bash
  curl -s -H "X-API-Key: $KEY" "$LYDIAN/tenants/$TENANT/people-types" \
    | jq '.[0].baseFields'
  ```

- **Attributes — your domain's vocabulary.** Anything your records need beyond
  the base fields: an instrument, a fee, a skill level, a parent reference.
- **Your app's own state — keep it out.** UI preferences, sync cursors,
  caches: those belong in your own datastore, not in tenant schema.

Attributes are one shared pool per tenant. If "Session Fee" matters on both
events and invoices, define it **once** and reference it from both types.

## 2. Create the attributes

Each attribute is a name, a required description, and a typed constraint
(`type.kind` picks one of eight kinds). Three representative ones:

```bash
# a constrained string
INSTRUMENT=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/attributes" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"name": "Instrument", "description": "The instrument this student plays",
       "type": {"kind": "string", "required": true, "maxLength": 40}}' | jq -r .id)

# a picklist
LEVEL=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/attributes" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"name": "Skill Level", "description": "Self-assessed playing level",
       "type": {"kind": "picklist", "required": false,
                "options": ["beginner", "intermediate", "advanced"]}}' | jq -r .id)

# a person reference — this is how you build typed relationships
PARENT=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/attributes" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"name": "Parent", "description": "The parent responsible for this student",
       "type": {"kind": "person", "required": false, "multiple": false}}' | jq -r .id)
```

The full kind reference:

| `kind` | Type fields | Value in `attributeValues` |
|---|---|---|
| `string` | `minLength?`, `maxLength?` | a string |
| `number` | `minValue?`, `maxValue?` | a JSON number |
| `boolean` | `defaultValue?` | `true` / `false` |
| `date` | `defaultValue?` | `"2026-07-30"` |
| `email` | — | an email string |
| `address` | — | `{street1, street2, city, state, zip}` |
| `picklist` | `options` (non-empty) | one of `options` |
| `person` | `multiple` | a person id, or an array of them |

(`required` is mandatory on every kind. `person` values are validated to exist
in your tenant, and a referenced person can't be deleted while the value
stands.)

## 3. Compose the type

A type is an ordered list of attribute ids on top of the base fields:

```bash
STUDENT=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/people-types" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{\"name\": \"Student\", \"description\": \"An enrolled music student\",
       \"attributeIds\": [\"$INSTRUMENT\", \"$LEVEL\", \"$PARENT\"]}" | jq)
```

The same call shape works at `/event-types`, `/document-types`,
`/invoice-types`, and `/communication-types` (communication types also accept
an optional `channel` — default `MESSAGE` — which is then fixed for the
type's lifetime). Records that name this type must now
satisfy it — required attribute values present, all values within their
constraints, no values for undeclared attributes. Validation is strict in both
directions.

Keep a map of attribute **name → id** in your app's config or bootstrap code:
`attributeValues` is keyed by id, and you'll need the ids constantly.

## 4. Evolve the schema

Reality: your schema will change. What each change does today:

**Adding an optional attribute to a type — safe.** Update the type with the
extended id list (`PUT` replaces the whole list, so include the existing ids):

```bash
curl -s -X PUT "$LYDIAN/tenants/$TENANT/people-types/$STUDENT_ID" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{\"name\": \"Student\", \"description\": \"An enrolled music student\",
       \"attributeIds\": [\"$INSTRUMENT\", \"$LEVEL\", \"$PARENT\", \"$NEW_ATTR\"]}"
```

Existing records simply lack a value for the new attribute; new records may
supply one. Adding a **required** attribute is legal too but means existing
records no longer satisfy their type on their next full-body update — backfill
promptly.

**Removing an attribute from a type — allowed, with a sharp edge.** Existing
records keep their stored value, but a record update that still sends a value
for the removed attribute is rejected (undeclared attribute). Update your
writers first, then the type.

**Changing an attribute's constraints** (`PUT /attributes/{id}`) affects
validation of *future* writes everywhere the attribute is used — it's the
shared pool, so check every type referencing it before tightening.

**The Base types are immutable.** Modifying or deleting a Base type is
rejected (`400 INVALID_REQUEST`); they're the guaranteed floor, not a
starting template.

**Deletion is reference-blocked, never cascading.** An attribute referenced by
any type can't be deleted (`409 REFERENCE_BLOCKED`, message names the
blocker); a type in use by records can't be deleted. Unwind top-down: records,
then types, then attributes.

**About versions:** types and attributes carry a `{major, minor}` version and
records snapshot it — but today every version is `1.0` and updates don't bump
it. Don't build migration logic on version numbers yet; see
[conventions](/docs/conventions.html#versioning-semantics).

---

## Next

- [Invoice from events](/docs/invoice-from-events.html) uses a number
  attribute on events as its billing bridge — a good worked example of the
  shared attribute pool.
- The [core concepts](/docs/concepts.html) page explains why the model is
  shaped this way.
