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

# Forms and tag validation

> JSON Schema ticket forms, catalog vs inline definitions, bindings to metadata and tags, and tag-kind rules on create and transition.

Ticket **forms** collect structured answers when someone creates a ticket or moves it to certain states. Each form is a **JSON Schema** plus **bindings** that copy submitted field values into ticket **metadata** or **tags**. Workflows declare when a form is required through `validation.on_create` and `validation.before_transition`.

Use the [Console](/ticketing/console) **Forms** editor (YAML) or the project form catalog API. Wire forms into workflows on the [Workflows](/ticketing/workflows) page. For HTTP details, see [API reference](/ticketing/api-reference).

```bash theme={null}
export API=https://api.ilyama.golain.io/core/api/v1
export TOKEN=…          # OIDC access token (same as Console)
export ORG_ID=…         # Organization UUID
export PROJECT_ID=…
```

| Header | Value |
| - | - |
| `Authorization` | `Bearer $TOKEN` |
| `ORG-ID` | `$ORG_ID` |

Creating or updating forms requires the project permission **manage workflows**. Reading forms and submitting `form_payload` on tickets requires project membership (same as other ticket mutations).

## What forms do

| When | Request field | Workflow rule |
| - | - | - |
| Create ticket | `form_payload` on `POST …/tickets` | `validation.on_create.require_form` or `optional_form` |
| Transition | `form_payload` on `POST …/tickets/{ticket_id}/transition` | Matching `validation.before_transition` entry for `from` → `to` |

After validation, the platform **merges** metadata (dot paths such as `wo.location`) and **inserts tags** for bindings with `writes_to.kind: tag`. Form submission does not replace the whole metadata object—it merges into existing keys.

> [!NOTE]
>
> If the workflow does not require a form for that create or transition, `form_payload` is ignored (you can still send it; it has no effect).

## Catalog forms vs inline

| Source | Where it lives | How workflows reference it |
| - | - | - |
| **Catalog** | Project rows from `POST …/ticket-forms` (versioned by name) | `forms.import` lists dependencies; `require_form` / `optional_form` use `{ name, version? }` |
| **Inline** | `forms.extra` array inside the workflow spec | Same `name` as in `require_form`; resolved before the catalog |

**Resolution order:** for a given form `name`, the engine uses the matching entry in `forms.extra` if present; otherwise it loads the catalog form by `name` and `version`.

| `require_form.version` | Behavior |
| - | - |
| Omitted or `0` | **Latest** version of that name in the project (after each catalog PATCH) |
| Non-zero integer | **Pinned** snapshot (`name` + exact `version`) |

`forms.import` entries use `{ name, version }` (version optional, same latest-vs-pinned semantics). They document catalog dependencies—especially when you instantiate a [workflow template](/ticketing/automation#workflow-templates)—and should match forms you create in the project before activation.

## Form spec fields

| Field | Required | Description |
| - | - | - |
| `name` | Yes | Stable identifier (catalog name or inline name) |
| `schema` | Yes | JSON Schema object describing `form_payload` |
| `bindings` | No | Maps schema field names to metadata or tags |

Each binding:

| Field | Description |
| - | - |
| `field` | Key in `form_payload` |
| `writes_to.kind` | `metadata` or `tag` |
| `writes_to.metadata_path` | Required when `kind` is `metadata` (dot-separated path) |
| `writes_to.tag_kind` | Required when `kind` is `tag` (must be a registered tag kind) |

## JSON Schema validation

The platform validates `form_payload` with the same JSON Schema compiler used at authoring time (draft **2020-12** and other drafts supported by the schema library).

| Topic | Behavior |
| - | - |
| Standard keywords | `type`, `required`, `properties`, `enum`, `minimum` / `maximum`, `pattern`, `additionalProperties`, `if` / `then` / `else`, and so on |
| Custom keywords | Ignored at validation time—for example `x-golain-labels` on enum fields (Console uses these for display labels and ranks; they do not affect server validation) |
| Bound fields | **Every binding `field` must appear in `form_payload`**, even if JSON Schema does not list it under `required` |

Failed schema validation returns a message like **`form payload failed validation`**. A missing bound field returns **`missing form field "{field}" for form "{name}"`**.

## Bindings in practice

```mermaid theme={null}
flowchart LR
  FP[form_payload JSON]
  SCH[JSON Schema check]
  BIND[bindings loop]
  META[metadata merge]
  TAG[ticket_tags insert]
  FP --> SCH --> BIND
  BIND -->|kind metadata| META
  BIND -->|kind tag| TAG
```

| `writes_to.kind` | What gets written |
| - | - |
| `metadata` | Value stored at `metadata_path` (nested objects created as needed) |
| `tag` | New row with `kind = tag_kind`, `value` = string form of the field (strings, numbers, and booleans normalized to text) |

Tag bindings run through the same tag validation as `POST …/tags` (kind registry, optional JSON Schema on values, entity existence checks).

## Requiring forms on create

| Rule | `form_payload` absent | `form_payload` present |
| - | - | - |
| `require_form` | **400** — `on_create requires form payload` | Validate and apply that form |
| `optional_form` | Allowed (no form step) | Validate and apply that form |
| Neither | Ignored | Ignored |

`require_form` and `optional_form` **cannot both** be set on the same workflow; validation rejects that combination at publish time.

Use `optional_form` when automated creators (maintenance plans, [alarms](/alarms/overview) auto–work orders) open tickets without human input but operators may still attach intake data when creating manually.

## Requiring forms on transition

Add a `before_transition` rule:

```yaml theme={null}
validation:
  before_transition:
    - from: in_progress
      to: resolved
      require_form:
        name: maintenance-completion
```

On `POST …/transition`, if the edge matches and `form_payload` is empty, the API returns **400** with a message like **`transition in_progress -> resolved requires form payload`**.

You can combine `require_form` with `require_tag_kinds` on the same rule. **Tag kinds are checked against tags already on the ticket** before the state change; add tags via `initial_tags`, `POST …/tags`, or earlier form bindings.

## Form versioning

| Action | Effect |
| - | - |
| `POST …/ticket-forms` | Creates **version 1** for a new name, or the next version if the name exists |
| `PATCH …/ticket-forms/{form_id}` | **Append-only**: writes a new version row; older versions remain addressable |
| Unpinned `require_form` | Always resolves to the **latest** catalog version at request time |
| Pinned `version: 2` | Always uses that snapshot, even after you publish version 3 |

Inline forms in `forms.extra` are part of the workflow version—they version with the workflow, not the catalog.

## Tags and tag kinds

Tags are `{ kind, value, label? }` on the ticket. The API stores `kind` and `value`; `label` is optional display text.

| Constraint | Detail |
| - | - |
| Uniqueness | At most one tag per `(ticket_id, kind, value)` |
| Multiple values | Same `kind` with **different** `value` is allowed (for example several `custom` tags) |
| Unknown kind | **`unknown tag kind "{kind}"`** |

### Built-in tag kinds (platform registry)

There is **no public API to register new tag kinds**—kinds are seeded on the platform. Use only kinds from this registry (plus any added in future migrations).

| Kind | Value meaning |
| - | - |
| `user` | Platform user id; must be a **project member** |
| `user_group` | User group UUID in the organization |
| `device` | Device UUID in the project |
| `asset` | Asset UUID in the project |
| `fleet` | Fleet UUID in the project |
| `project` | Project UUID in the organization |
| `object` | Object UUID in the project |
| `ota_release` | OTA release UUID in the project |
| `maintenance_plan` | Maintenance plan UUID in the project |
| `ticket`, `subticket` | Peer ticket UUID in the project |
| `alarm_instance` | Alarm instance id |
| `severity`, `custom`, `wo_type`, `eta`, `priority` | **Scalar** strings (no entity lookup; optional `value_schema` on the kind if configured) |
| `datapoint`, `user_context` | Definition ids (scalar fallback validation) |

Entity kinds reject missing or out-of-scope targets—for example **`device not found in this project`**, **`user is not a project member in scope`**, **`ticket not found in this project`**.

Optional JSON Schema on a kind’s `value_schema` or tag `metadata` is enforced when tags are added.

### Requiring tag kinds

| Rule | When checked | Error (examples) |
| - | - | - |
| `validation.on_create.require_tag_kinds` | After create tags + create form | `on_create requires tag kind "asset"` |
| `before_transition.require_tag_kinds` | Before transition (tags already on ticket) | `missing required tag kind "user" before transition` |

Tag kinds can come from `initial_tags`, `POST …/tags`, or form bindings that write tags.

***

## Example: catalog form

Create a maintenance intake form in the catalog:

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "form": {
      "name": "maintenance-request-intake",
      "schema": {
        "type": "object",
        "additionalProperties": false,
        "required": ["wo_type", "priority", "summary"],
        "properties": {
          "wo_type": {
            "type": "string",
            "enum": ["preventive", "corrective", "inspection", "other"]
          },
          "priority": {
            "type": "string",
            "enum": ["critical", "high", "medium", "low"]
          },
          "summary": { "type": "string", "minLength": 1 },
          "estimated_duration": { "type": "string", "pattern": "^[0-9]+(m|h|d)$" }
        }
      },
      "bindings": [
        { "field": "wo_type", "writes_to": { "kind": "tag", "tag_kind": "wo_type" } },
        { "field": "priority", "writes_to": { "kind": "tag", "tag_kind": "priority" } },
        { "field": "summary", "writes_to": { "kind": "metadata", "metadata_path": "request.summary" } },
        { "field": "estimated_duration", "writes_to": { "kind": "tag", "tag_kind": "eta" } }
      ]
    }
  }' \
  "$API/projects/$PROJECT_ID/ticket-forms"
```

Success uses `"ok": 1` with `data.item` containing `id`, `name`, and `version`.

### YAML equivalent (Console Forms editor)

```yaml theme={null}
name: maintenance-request-intake
schema:
  type: object
  additionalProperties: false
  required: [wo_type, priority, summary]
  properties:
    wo_type:
      type: string
      enum: [preventive, corrective, inspection, other]
    priority:
      type: string
      enum: [critical, high, medium, low]
    summary:
      type: string
      minLength: 1
    estimated_duration:
      type: string
      pattern: "^[0-9]+(m|h|d)$"
bindings:
  - field: wo_type
    writes_to: { kind: tag, tag_kind: wo_type }
  - field: priority
    writes_to: { kind: tag, tag_kind: priority }
  - field: summary
    writes_to: { kind: metadata, metadata_path: request.summary }
  - field: estimated_duration
    writes_to: { kind: tag, tag_kind: eta }
```

## Example: workflow requiring the form

Snippet for a **maintenance-request** workflow (labels `new` / `in_progress` / `resolved` / `cancelled`):

```yaml theme={null}
name: maintenance-request
initial: new
forms:
  import:
    - name: maintenance-request-intake
  extra:
    - name: maintenance-closeout
      schema:
        type: object
        required: [notes]
        properties:
          notes: { type: string, minLength: 1 }
      bindings:
        - field: notes
          writes_to: { kind: metadata, metadata_path: closeout.notes }
labels:
  extra:
    - { key: new, class: open }
    - { key: in_progress, class: in_progress }
    - { key: resolved, class: terminal_success, terminal: true }
    - { key: cancelled, class: cancelled, terminal: true }
validation:
  on_create:
    require_form:
      name: maintenance-request-intake
  before_transition:
    - from: in_progress
      to: resolved
      require_form:
        name: maintenance-closeout
transitions:
  - { from: new, to: in_progress, permit: [project.member] }
  - { from: in_progress, to: resolved, permit: [project.member] }
  - { from: new, to: cancelled, permit: [project.member] }
  - { from: in_progress, to: cancelled, permit: [project.member] }
```

Publish with `POST …/ticket-workflows`, activate the version, then create tickets against the workflow def id.

## Example: create with `form_payload`

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_def_id": "'"$WORKFLOW_DEF_ID"'",
    "title": "Replace filter on line 2",
    "form_payload": {
      "wo_type": "corrective",
      "priority": "high",
      "summary": "Pressure drop exceeded threshold",
      "estimated_duration": "2h"
    }
  }' \
  "$API/projects/$PROJECT_ID/tickets"
```

After success, `GET …/tickets/{id}` should show `metadata.request.summary` and tags for `wo_type`, `priority`, and `eta`.

## Example: transition with `form_payload`

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "to_label": "resolved",
    "reason": "Filter replaced and pressure nominal",
    "form_payload": {
      "notes": "Filter replaced; pressure within spec"
    }
  }' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/transition"
```

Use the label **key** (`resolved`), not the display name.

## Example: validation errors

**Missing required form on create** (workflow has `require_form`, body has no `form_payload`):

```json theme={null}
{
  "ok": 0,
  "message": "on_create requires form payload"
}
```

**Missing form on transition**:

```json theme={null}
{
  "ok": 0,
  "message": "transition in_progress -> resolved requires form payload"
}
```

**Invalid payload** (schema violation, for example wrong enum):

```json theme={null}
{
  "ok": 0,
  "message": "form payload failed validation"
}
```

**Missing bound field** (schema passes but a binding field is absent):

```json theme={null}
{
  "ok": 0,
  "message": "missing form field \"estimated_duration\" for form \"maintenance-request-intake\""
}
```

> [!WARNING]
>
> Catalog **PATCH** creates a new version. Workflows that pin an old `require_form.version` keep using that snapshot; unpinned rules pick up new fields and enums automatically.

## Next steps

* [First ticket](/ticketing/first-ticket) — end-to-end create and transition
* [Workflows](/ticketing/workflows) — `validation`, labels, and activation
* [Automation](/ticketing/automation) — actions that read metadata and tags after forms run
* [Troubleshooting](/ticketing/troubleshooting) — common ticket and form errors


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.