> ## Documentation Index
> Fetch the complete documentation index at: https://seal-d2ca5bea.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Formulas

> Per-field Python expressions that compute a field, title, or status tag from the rest of the entity.

<Note>
  Formulas are an early-access feature. This page is unlinked from the
  public docs index for now. Reach out to your Seal contact if you'd
  like to use them.
</Note>

A formula is a small Python expression attached to one writable target
— a field's value, the entity's title, or its status tag. It computes
that target from the rest of the entity, the entities it references,
and a few helpers.

```python theme={null}
field.value = entity["Density"] * entity["Volume"]
```

The editor gives you autocomplete on `entity[...]`, `field`, `seal.*`,
and the rest of the formula API as you type — no need to memorise
field names or helper signatures.

You don't write triggers either. Seal **derives them from your code**:
reading `entity["Density"]` adds a watch on **Density**, so the formula
re-runs whenever Density changes. You can also press **Recompute** in
the Fields tab summary bar to run every formula on the entity on
demand. Inside the formula modal, **Preview** shows what the formula
would produce against the current entity state without persisting or
recomputing the rest of the entity — see [Preview and commit](#preview-and-commit)
below.

## Concise syntax

A formula is a single Python expression. Three equivalent forms:

```python theme={null}
# 1. Bare expression (one-liner sugar)
entity["Volume"] * 1.5

# 2. Explicit assignment
field.value = entity["Volume"] * 1.5

# 3. Top-level return
return entity["Volume"] * 1.5
```

Multi-statement formulas must end with `field.value = ...` or
`return ...` — the bare-expression form is one-liner only.

## Formula context vs scripts

Formulas use Python syntax, but they do **not** receive the same raw
entity JSON object as scripts. They receive a formula context that is
already focused on field values:

```python theme={null}
# Formula value read
entity["Lot Number"]

# Equivalent full field-object read
entity.fields["Lot Number"].value
```

This differs from scripts, where authors usually work with the entity
JSON shape and access field data through the `fields` object. In
formulas, `entity["Field name"]` is intentionally the shorthand for the
field's current value. Field names must match the UI name exactly,
including spaces, punctuation, and units.

Entity metadata is exposed as attributes, not JSON keys:

```python theme={null}
entity.index
entity.template_index
entity.status
```

Use `entity.fields["Name"]` only when you need the `Field` object itself
(`.value`, `.type`, `.warning`, and so on). Formula code cannot mutate
arbitrary entity fields or properties; the writable target is the target
formula slot: `field.value` / `field.warning` for a field formula,
`title` for a title formula, and `status_tag` for a status-tag formula.

Read fields by exact UI name. Bracket notation is the common value-read form:

```python theme={null}
entity["Reactor Temp (°C)"]
entity["Lot Number"]
entity["Supplier"]["Country"]              # chain across single REFERENCE
[m["SKU"] for m in entity["Materials"]]    # iterate multi REFERENCE
seal.sum(entity["Items"], "Quantity")      # aggregate (one SQL query)
```

Use `entity.fields["Name"]` when you need the full `Field` object
(`.value`, `.type`, `.warning`, and so on). `entity.fields.get("Name")`
returns the same `Field` object, or `None` when the field is missing:

```python theme={null}
reading = entity.fields.get("Reading")
if reading is not None and reading.value > 100:
    field.warning = "Out of spec"
```

### Single vs multi cardinality

The shape of a field read is decided by the field's **Allow multiple** setting:

* **Allow multiple off** → `entity["X"]` returns one value: an `Entity` (REFERENCE), `str` (SELECT, STRING), `datetime` (DATETIME), `int`/`float` (NUMBER), `bool` (BOOLEAN), and so on. Read it directly — do **not** index with `[0]`.
* **Allow multiple on** → `entity["X"]` returns a `list` of those values. Iterate, index, or aggregate over it.

Indexing a single-cardinality read with `[0]` is a common mistake. It fails differently depending on type — and one mode is silent:

```python theme={null}
# WRONG — "Supplier" is single REFERENCE → KeyError: "Field '0' not found"
entity["Supplier"][0]["Country"]

# CORRECT
entity["Supplier"]["Country"]

# WRONG — "Decision" is single SELECT → returns the first character ("B" from "Blocked"), silently
field.value = entity["Decision"][0]

# CORRECT
field.value = entity["Decision"]

# CORRECT — "Materials" is multi REFERENCE, list shape
[m["SKU"] for m in entity["Materials"]]
entity["Materials"][0]["SKU"]
```

The save-time linter flags `entity["X"][0]` on single-cardinality fields and refuses the save with a clear error, so the REFERENCE-style mistake is caught before it ships. The SELECT-style silent truncation on cross-entity reads (`backref["Decision"][0]`) currently still saves cleanly — when in doubt, check the source field's **Allow multiple** setting.

Writes go the other way: **the value you assign must match the target field's cardinality.** Writing a list to a single-cardinality field (or a scalar to a multi field) fails at run time — *"Field X does not allow multiple references, but the formula returned 2"* — and the stored value is left untouched. The most common cause is forwarding a value from a multi field on a referenced entity into a single field on this one:

```python theme={null}
# WRONG — upstream "F-33 Centrifuge" is multi, target is single → run errors
ref = entity["F-35 - Drying"]
field.value = ref["F-33 Centrifuge"]

# FIX EITHER SIDE
# (a) make the target field allow multiple (matches the upstream model), or
# (b) collapse explicitly to a single value:
field.value = ref["F-33 Centrifuge"][0] if ref["F-33 Centrifuge"] else None
```

Bail out without writing anything:

```python theme={null}
if not entity["Approved"]:
    skip()
```

Mark a value out of spec — auto-clears next run. Read the target field by name (`entity["..."]`); a bare `field.value` read in a warning-only formula is rejected because it leaves older-version instances pinned to stale triggers.

```python theme={null}
if entity["Reading"] > 100:
    field.warning = "Out of spec"
```

Signatures:

| Helper        | Signature                                       | Returns                                    |
| ------------- | ----------------------------------------------- | ------------------------------------------ |
| `seal.sum`    | `(source, field, *, where=None, group_by=None)` | `int \| float` (or `dict` with `group_by`) |
| `seal.count`  | `(source, *, where=None, group_by=None)`        | `int` (or `dict` with `group_by`)          |
| `seal.min`    | `(source, field, *, where=None, group_by=None)` | scalar (or `dict` with `group_by`)         |
| `seal.max`    | `(source, field, *, where=None, group_by=None)` | scalar (or `dict` with `group_by`)         |
| `seal.pluck`  | `(source, field, *, where=None)`                | `list`                                     |
| `seal.filter` | `(source, *, where=None)`                       | `list[Entity]`                             |
| `seal.only`   | `(source, *, where=None)`                       | `Entity \| None` (raises on >1 match)      |

### `group_by=` — bucketed aggregation

Pass a field name to `group_by` to get a `dict` keyed by that field's distinct values instead of a single number. One SQL query, one result.

```python theme={null}
# Sum amounts grouped by unit — returns {"g": 12.3, "kg": 0.5, "mL": 200.0}
totals = seal.sum(entity.referenced_by, "Amount",
                  group_by="Unit",
                  where={"template": "Line Item"})
```

`group_by` is not supported on `seal.pluck`, `seal.filter`, or `seal.only`. Like `where=`, the field name must be a literal string.

### `seal.filter` — filtered entity list

`seal.filter` returns the matching entities as `Entity` proxy objects, not a scalar. Use it when you need to read multiple fields from each result — pick the first matching item, build a summary list, etc.

```python theme={null}
# First finished lot among referenced lots
lots = seal.filter(entity["Lots"], where={"status": "FINISHED"})
field.value = lots[0]["Batch ID"] if lots else None

# Comma-separated names of active items
active = seal.filter(entity["Items"], where={"fields": {"Status": "Active"}})
field.value = ", ".join(item["Name"] for item in active)

# All finished backlink entities of a given template
results = seal.filter(
    entity.referenced_by,
    where={"template": "Experiment", "status": "FINISHED"},
)
```

`seal.filter` supports the same `where=` dict as all other `seal.*` calls. It does **not** accept `field=` or `group_by=` — those are for scalar aggregates.

### `seal.only` — single-match entity, fails loud on duplicates

`seal.only` returns the **single** matching entity from a multi-REFERENCE or backlink set, or `None` if nothing matches. If the `where=` clause matches more than one row, the formula fails with a clear error — it never silently picks the first result.

```python theme={null}
# The single Lot Record reachable through this entity's backlinks
lot = seal.only(entity.referenced_by, where={"template": "Lot Record"})
field.value = lot["Batch ID"] if lot else None

# An author-typed multi-REFERENCE narrowed to one match — assert at most one
# active item is selected, raise loudly otherwise
active = seal.only(entity["Items"], where={"fields": {"Status": "Active"}})
field.value = active["Quantity"] if active else 0
```

Use `seal.only` when at-most-one is the actual contract — the cardinality is part of your data model and a duplicate is a bug. Reach for `seal.filter` instead when many matches are legitimate, or for plain `entity["Ref"][0]` when "first arbitrary match" is genuinely fine.

`seal.only` does **not** accept `field=` or `group_by=`. Read fields off the returned entity directly: `seal.only(...)["FieldName"]`.

**Cap:** `seal.filter` is capped at 500 entities and 512KB of returned entity JSON. If the filter matches too many rows or too much data, the formula run fails with a clear error. Use `seal.count`, a scalar aggregate, or a tighter `where=` filter when you do not need full entity objects.

**Sub-reference fields:** Returned entities have the same read contract as `entity.referenced_by` items — you can read scalar fields, `id`, `title`, `kind`, `status`. REFERENCE fields return thin proxies whose `.id` and `.version` are reliable but where reading a field (`results[0]["Donor"]["Name"]`) raises `KeyError` — nested blobs are not pre-materialised. USER fields resolve only when the user is already loaded into the parent formula's context (typically because they appear on the host entity); otherwise they read as `None`. If you need fields from entities one hop deeper, use `seal.pluck` (a separate call) to flatten the values you need.

`source` is either `entity["RefField"]` (a multi-REFERENCE field) or `entity.referenced_by` (the backlink set). Anything else — a Python list comprehension, a sliced ref list, a value bound to a variable through a function — is rejected at save time, because the source has to be statically resolvable for the server to know what to precompute.

### `where=` — filter dict

`where=` is a dict that mirrors the entity surface you already read. Top-level keys are entity properties; nested `fields` carries field-equality clauses on the aggregated entity. All clauses are AND'd. Values match the **pristine form** — the same string `entity.kind` / `entity.status` reads back — so writing the filter is a direct transcription of the Python you'd use to inspect a single entity.

```python theme={null}
where = {
    "template": "Lot Record",        # name (resolved) or UUID
    "type":     "Sample",
    "kind":     "instance",           # lowercase — matches entity.kind
    "status":   "FINISHED",           # uppercase — matches entity.status
    "archived": False,
    "fields": {                      # mirrors entity.fields[...]
        "Status": "Active",
        "Region": "EU",
    },
}
```

Supported keys (the equality-friendly subset of the entity surface):

| Key          | Filters by                 | Value                                                |
| ------------ | -------------------------- | ---------------------------------------------------- |
| `"template"` | the entity's template      | name (resolved server-side) or UUID                  |
| `"type"`     | the entity's type          | name (resolved server-side) or UUID                  |
| `"kind"`     | entity kind                | `"type"` / `"template"` / `"variant"` / `"instance"` |
| `"status"`   | entity status              | `"EDITABLE"` / `"IN_REVIEW"` / `"FINISHED"`          |
| `"archived"` | archive flag               | `True` / `False`                                     |
| `"fields"`   | a field's value (equality) | dict of field name → value                           |

Names in `"template": "Lot Record"` and `"type": "Sample"` resolve server-side at run time, the same way Neil's `listEntities` tool resolves them — pass a name (preferred) or a UUID. If a name doesn't match anything, or matches more than one entity, the formula run fails with a clear error.

The dict has to be a literal — `where=variable` and `where={**partial}` are rejected at save time, because the server pre-resolves the filter before the formula runs and needs to see it statically.

**Why these keys and not "anything in the entity"?** Each supported key has a clear equality semantic and a single SQL path, so the filter compiles to one JSONB clause without surprises. Other entity properties (`created_at`, `editors`, `tags`, etc.) need richer operators — date ranges, list containment, comparisons — that aren't supported here yet. Reach for those? Iterate in Python over a small ref set, or use a script.

### When to use `seal.*` vs iterate

| Source                        | Size           | Use                                                 |
| ----------------------------- | -------------- | --------------------------------------------------- |
| `entity["Items"]` (multi-REF) | small (≤ \~50) | iterate freely; `seal.*` if you only need a summary |
| `entity["Items"]` (multi-REF) | large          | always `seal.*`                                     |
| `entity.referenced_by`        | any            | **always `seal.*`** for aggregation                 |

`entity.referenced_by` materialises at most 100 entity blobs into the runner — meant for *display-style* iteration (titles, ids), not summing fields across hundreds of related records. The aggregate path doesn't load any of them; it queries the GIN-indexed backref column directly. Iterating in Python over a backref set with thousands of entries will silently truncate at 100 — `seal.*` is the only way to span the whole set.

### Worked example — equipment usage

A piece of equipment is referenced by every experiment that used it. To compute total hours logged across *all* experiments where the equipment was actually used:

```python theme={null}
# On an Equipment template — adds up hours across every Experiment that
# references this equipment, filtered to finished runs only. One SQL
# aggregate, regardless of count.
field.value = seal.sum(
    entity.referenced_by,
    "Hours Logged",
    where={"template": "Experiment", "status": "FINISHED"},
)
```

### Worked example — forward a parent ref

A child template inheriting from a parent (via "Create from") wants to copy the parent's `Donor` reference. The formula sits on the child's `Donor` REFERENCE field:

```python theme={null}
# On a child template's "Donor" REFERENCE field. Reads the parent (via
# entity.created_from) and forwards its Donor. Runs on entity creation and
# whenever the user clicks Recompute on the formula.
entity.created_from["Donor"]
```

For a multi-ref forward (e.g. all materials), the read shape returns a list and the same formula handles it:

```python theme={null}
# On a child's "Materials" multi-REFERENCE field
entity.created_from["Materials"]
```

<Note>
  Hierarchy hops (`entity.created_from`, `entity.template`, `entity.type`) populate the formula's value but do **not** generate auto-rerun triggers — the linter only watches same-entity field reads (`entity["X"]`) and REFERENCE-field hops (`entity["RefField"]["X"]`). If you need the child to track later changes to the parent's Donor automatically, give the child a REFERENCE field pointing at the parent and read through it (`entity["Parent"]["Donor"]`) instead — that produces an `onReferenceChange` trigger.
</Note>

## Dates and times

`datetime` and `timedelta` are pre-loaded as globals. **No imports** — `import` is blocked.

```python theme={null}
field.value = entity["Created"] + timedelta(days=7)
field.value = (datetime.now() - entity["Started"]).days
field.value = datetime(2026, 1, 1)
```

DATE fields read as `datetime.date`; DATETIME fields read as `datetime.datetime` (UTC, tz-aware). Writes accept either Python objects or ISO strings.

## Entity properties

`entity.X` (read-only). Any of these can read `None` when not applicable.

### Identity & lifecycle

| Property                | Type                                              | Notes                                 |
| ----------------------- | ------------------------------------------------- | ------------------------------------- |
| `entity.id`             | `str`                                             | UUID                                  |
| `entity.title`          | `str \| None`                                     | Use a title formula to write          |
| `entity.kind`           | `"type" \| "template" \| "variant" \| "instance"` | Lowercase                             |
| `entity.status`         | `"EDITABLE" \| "IN_REVIEW" \| "FINISHED"`         | Uppercase                             |
| `entity.status_tag`     | `str \| None`                                     | Custom tag text, or `None` when unset |
| `entity.version`        | `str \| None`                                     | `None` when unpublished               |
| `entity.tags`           | `list[str]`                                       | Tag names                             |
| `entity.index`          | `int`                                             | Global creation index                 |
| `entity.type_index`     | `int \| None`                                     | Instance only                         |
| `entity.template_index` | `int \| None`                                     | Instance only                         |
| `entity.variant_index`  | `int \| None`                                     | Variant only                          |

### Timestamps & people

| Property                   | Type                 |
| -------------------------- | -------------------- |
| `entity.created_at`        | `str` (ISO 8601 UTC) |
| `entity.updated_at`        | `str` (ISO 8601 UTC) |
| `entity.last_published_at` | `str \| None`        |
| `entity.created_by`        | `User \| None`       |
| `entity.updated_by`        | `User \| None`       |
| `entity.last_published_by` | `User \| None`       |
| `entity.editors`           | `list[User]`         |

### Hierarchy

| Property                    | Type             | Meaning                                                     |
| --------------------------- | ---------------- | ----------------------------------------------------------- |
| `entity.type`               | `Entity \| None` | The TYPE entity                                             |
| `entity.template`           | `Entity \| None` | The TEMPLATE entity                                         |
| `entity.created_from`       | `Entity \| None` | The entity this one was created from (via a creation field) |
| `entity.created_from_field` | `str \| None`    | Field name on `created_from` that produced this entity      |
| `entity.submitted_from`     | `Entity \| None` | Entity that submitted this one (via a submission field)     |
| `entity.duplicated_from`    | `Entity \| None` | Source entity if duplicated                                 |

### Relationships

| Property               | Type                | Meaning                                                                                            |
| ---------------------- | ------------------- | -------------------------------------------------------------------------------------------------- |
| `entity.referenced_by` | `list[Entity]`      | Backlinks. **Capped at 100 for in-Python iteration.** Aggregating? Use `seal.*` over the full set. |
| `entity.siblings`      | `SiblingCollection` | Same-creation-field peers (summary view — `id`, `title`, `created_at`, `is_archived`, `is_test`)   |

### System

| Property             | Type  |
| -------------------- | ----- |
| `entity.system.id`   | `str` |
| `entity.system.name` | `str` |
| `entity.system.slug` | `str` |

### Type-only

| Property              | Type          |
| --------------------- | ------------- |
| `entity.icon`         | `str \| None` |
| `entity.content_type` | `str \| None` |

## User

`entity.created_by`, `entity["Reviewer"]`, etc. return a `User`:

| Property            | Type          | Notes          |
| ------------------- | ------------- | -------------- |
| `user.id`           | `str`         | Role id (UUID) |
| `user.email`        | `str`         |                |
| `user.display_name` | `str \| None` |                |

`User` instances compare equal by id and are hashable — safe to put in sets and dict keys.

## Sibling collection

`entity.siblings` returns a small collection of summaries. Sibling entries are deliberately *not* full `Entity` objects — they don't load the per-entity blob.

```python theme={null}
for sib in entity.siblings:
    print(sib.title)

entity.siblings.by_id("...")    # SiblingEntitySummary | None
entity.siblings.ids             # list[str]
entity.siblings.titles          # list[str | None]
len(entity.siblings)            # 0 when not in a creation field
```

Each entry exposes:

| Property          | Type          |
| ----------------- | ------------- |
| `sib.id`          | `str`         |
| `sib.title`       | `str \| None` |
| `sib.created_at`  | `str \| None` |
| `sib.is_archived` | `bool`        |
| `sib.is_test`     | `bool`        |

## Content

`entity.content` returns a `Content` for entities that carry a body (templates, instances). The `type` field discriminates which value is non-None — only one of `page` / `script_code` / `file_id` / `vega` is populated per entity.

| Property              | Type                 | Populated when           |
| --------------------- | -------------------- | ------------------------ |
| `content.type`        | `str`                | always                   |
| `content.page`        | `list[dict] \| None` | `type == "PAGE_CONTENT"` |
| `content.script_code` | `str \| None`        | `type == "SCRIPT_CODE"`  |
| `content.file_id`     | `str \| None`        | `type == "FILE"`         |
| `content.vega`        | `dict \| None`       | `type == "CHART"`        |

## What's available

| Global                | What it is                                                         |
| --------------------- | ------------------------------------------------------------------ |
| `entity` (and `self`) | The current entity                                                 |
| `field`               | The target field — writable on `.value` and `.warning`             |
| `seal`                | Aggregate helpers: `sum`, `count`, `min`, `max`, `pluck`, `filter` |
| `datetime`            | `datetime.datetime` (constructor, `.now()`, etc.)                  |
| `timedelta`           | `datetime.timedelta`                                               |
| `skip()`              | Exit early with no diff                                            |

Standard Python built-ins work: `len`, `max`, `min`, `sum`, `sorted`, `range`, `abs`, `round`, `str`, `int`, `float`, `bool`, `list`, `dict`, `set`, `tuple`, `enumerate`, `zip`, `any`, `all`, ...

## What's not available

* **`import` statements** — including `from x import y`. No third-party libraries.
* **`__import__`, `exec`, `eval`, `compile`, `open`, `breakpoint`, `input`, `exit`, `quit`** — blocked builtins.
* **Network and file system.**
* **Entity mutation outside the target.** `entity["X"] = ...`, `entity.title = ...`, and `entity.status_tag = ...` are rejected at lint time. Title and status tag have their own pseudo formulas.

If you need any of the above, you're outside formula territory — use a [script](/entity-content/scripts).

## Title and status tag formulas

Title and status tag are **pseudo-fields** — they don't appear in the field list, but you can still attach a formula that owns them.

**Where to set one:** open the entity (template or instance), expand **Info** in the right-side panel, hover the **Title** or **Status tag** row, click the **⋯** menu, and pick **Add formula** (or **Edit formula** when one already exists). Pseudo-field formulas write to a bare global — `title = ...` or `status_tag = ...` — not `field.value`. The bare-expression and `return` sugars work too; both compile down to the same write.

While a title formula is set, the title in the header reads as **Automated** and is no longer manually editable — every save and every dependency change recomputes it. Same for the status tag picker.

### Template pseudo-field formulas run on instances

Regular field formulas compute on the template *and* on every instance. Template pseudo-field formulas are different: `title` and `status_tag` formulas authored on a template always compute on instances only.

The template's own title and status tag stay manually editable so the template can keep a meaningful name and status. You'll still see the **fx** affordance next to the template title or status-tag cell because the formula is configured there; the formula result is applied when instances are created or recomputed.

<Note>
  Types don't carry formulas. Title formulas supersede the legacy template-level **computed title expression** (`{{ field.X }}` syntax) on entities where the formula is set; the expression keeps working everywhere else and is still the right tool when you want every instance of a template to share a templated title without authoring a formula.
</Note>

```python theme={null}
# Title formula — assign to the `title` global
title = f"{entity['Compound']} — Batch {entity['Batch Number']}"
```

```python theme={null}
# Title formula using an entity index
title = "Internal Certificate of Conformance " + str(entity.index)
```

```python theme={null}
# Status tag formula — string for text-only, or (text, intent) tuple
if entity["Approved"]:
    status_tag = ("Approved", "success")
else:
    status_tag = ("Pending", "primary")
```

Intent is one of `"none"`, `"primary"`, `"success"`, `"warning"`, `"danger"`.

The bare-expression sugar and explicit `return` work for pseudo-fields too — both compile to the same write:

```python theme={null}
# Bare expression — compiles to `title = ...`
f"{entity['Compound']} — Batch {entity['Batch Number']}"

# Explicit return
return f"{entity['Compound']} — Batch {entity['Batch Number']}"
```

When a title formula is authored on a template, it runs on instances.
Instance indexes such as `entity.template_index` are therefore `None`
while you are looking at the template itself; they resolve when the
formula is evaluated for an instance.

## Triggers (auto-derived)

You don't manage triggers — they're inferred from the AST every time you save (saving is automatic, \~300 ms after the last keystroke). The linter records:

* `onSpecificFieldChange` — for every `entity["X"]` read
* `onReferenceChange` — for every REFERENCE field whose contents are reached (`entity["Ref"]["X"]` or `seal.*(entity["Ref"], …)`)
* `onReferencedByChange` — when `entity.referenced_by` is read

`onCreate` and `onManualRun` are always present. The formula re-runs whenever a tracked dependency changes; press **Recompute** in the Fields tab summary bar to force a run on demand. Inside the formula modal, **Preview** evaluates the current draft against the entity without dispatching a real run — see [Preview and commit](#preview-and-commit) below.

Lifecycle triggers (`onSchedule`, `onPublish`, status changes, etc.) aren't available for formulas — use a [script](/entity-content/scripts) when you need scheduled or status-driven work.

## Preview and commit

While editing a formula in the modal, **Preview** evaluates that one formula against the entity's current state and shows the result directly in the field cell — the same renderer used for real computed values. It's a dry run: no `trigger_runs` row is recorded, no downstream formulas re-execute, and the entity blob is not touched. Use it to iterate on a draft quickly without paying the cost of a whole-entity recompute.

Saving is implicit. The formula source auto-saves as you type (\~300 ms after the last keystroke). When you close the modal, Seal checks whether anything changed compared to when you opened it; if it did, the entity recomputes automatically. Closing without edits (pure inspection, or type-and-revert) does nothing — the preview cell state clears and the entity is left untouched.

Notes:

* Preview reads sibling field values from their **persisted** state. If another field on the same entity also has an unsaved formula edit in another modal, Preview won't see those unsaved changes — it sees what the server has right now.
* Title and status-tag pseudo-fields preview into the title / status-tag area at the top of the entity. Errors there surface as a toast (no `field.warning` slot to render into).
* The Fields-tab **Recompute** button is still the explicit "run every formula on this entity now" gesture. Use it when you want to force a fresh run without opening the editor.

## Debugging

`print()` output is captured into the run's console output. Click **Last run** in the summary bar to see the latest run's status, console output, and per-field outcome. Tracebacks land there too.

```python theme={null}
print(f"computed {entity['Input']} → {entity['Input'] * 10}")
field.value = entity["Input"] * 10
```

Common lint errors:

* **"Dynamic field lookups not allowed"** — `entity[some_var]`. Field names must be string literals so the linter can record reads at save time.
* **"Imports are not allowed"** — top-level `import` or `from x import y`.
* **"Multi-statement formula must end with `field.value = ...` or `return`"** — multi-line script with a trailing bare expression is ambiguous.
* **"CYCLE\_DETECTED"** — two formulas on the same entity transitively depend on each other. The error message names the cycle.

## Common patterns

### Guard against missing inputs

```python theme={null}
if entity["Volume"] is None or entity["Density"] is None:
    skip()
field.value = entity["Volume"] * entity["Density"]
```

### Out-of-spec warning

```python theme={null}
field.value = entity["Reading"]
if field.value is not None and not (10 <= field.value <= 90):
    field.warning = f"{field.value} outside spec range 10–90"
```

### Roll up a child quantity

```python theme={null}
field.value = seal.sum(entity["Line Items"], "Quantity")
```

### Days since publication

```python theme={null}
if entity.last_published_at is None:
    skip()
field.value = (datetime.now() - entity["Published Date"]).days
```

### Forward a reviewer

```python theme={null}
# Copy the reviewer of the parent batch onto this sub-batch
parent = entity.created_from
field.value = parent["Reviewer"] if parent else None
```

### Share computed state between formulas via a sibling field

Each formula has its own scope — there's no shared variable across formulas on the same entity. When two formulas need the same intermediate result, **compute it once into a dedicated field, then read it from the others.** The intermediate field can be hidden from page content if it's purely internal.

```python theme={null}
# field.value on "Total quantity (mL)" — the canonical intermediate
field.value = sum(item["Volume mL"] for item in entity["Items"] or [])
```

```python theme={null}
# field.value on "Total quantity display" — reads the intermediate, formats it
total = entity["Total quantity (mL)"] or 0
field.value = f"{total/1000:.2f} L" if total >= 1000 else f"{total:.0f} mL"
```

The reader's `entity["Total quantity (mL)"]` registers a watch, so the display field recomputes whenever the canonical one does.

## Authoring guidance

* **Prefer the bare expression for one-liners.** When the whole formula is a single expression, write the expression and drop `field.value =`. Reserve the explicit form for multi-statement logic — a `skip()` guard, a `field.warning`, branching.
* **Keep formulas simple.** A formula that needs intricate Python, or in-Python iteration over hundreds of items, is usually working around a missing primitive — a `seal.*` helper, an entity property, or a richer `where=` operator. Prefer raising that gap over a clever workaround; formulas should stay readable.
* **Share a value through a field, not duplication.** When two formulas need the same intermediate, compute it once into a dedicated field and read it from the others — see [Share computed state between formulas](#share-computed-state-between-formulas-via-a-sibling-field).

## Migrating legacy formulas and out-of-spec expressions

Per-field formulas replace the older **legacy formulas** (math.js expressions on the dedicated **Formula** field type) and **out-of-spec expressions** (math.js predicates configured on any field). Both surfaces are retired going forward.

**What you can and can't do under per-field formulas**

* New legacy formulas and new out-of-spec expressions can no longer be created — the **Formula** option is removed from the new-field menu, and the "Add out of spec criteria" menu item is hidden on fields that don't already have one.
* Existing legacy formulas and out-of-spec expressions on **templates and instances are view-only**. Opening one shows the original math.js expression for reference; the body cannot be edited.
* A new **Migrate** action replaces editing. It's the only forward-going change you can make to a legacy formula.

**Running the migration**

Migration is a template-level change and follows the standard change-set flow — open a draft on the template, run the migration there, publish.

1. On the template (in a draft / open change set), open the field's three-dot menu → **Configure field** (for legacy Formula fields) or **View out of spec criteria** (for OOS expressions).
2. The modal opens read-only with a "no longer supported" banner at the top.
3. **Select the output type.** For legacy Formula fields, an icon-row picker just above the footer lets you select the type the migrated field should produce (Number, Text, Checkbox, Date, Time & date, JSON) — defaulted to the type the formula was already producing. Out-of-spec migrations on non-Formula fields keep the field's existing type — no picker.
4. Click **Migrate** in the footer. The legacy expression is cleared and the formula is written in the same change-set entry — atomic, no intermediate state. The new formula seeds with the legacy expression wrapped via `legacy_formula(...)`, so behaviour is preserved out of the box; open it from the formula editor afterwards to rewrite as idiomatic Python whenever you're ready.

When the change set publishes, new instances created from the new template version pick up the migrated formula and compute their values via `onCreate`. Instances created from earlier template versions keep their old field shape and their previously-computed values — the migration does not retroactively re-evaluate or rewrite them.

**About `legacy_formula(...)`**

The wrapper is a transitional bridge — it runs your original math.js expression server-side and returns the value to the surrounding Python. You can keep using it indefinitely while you learn the per-field-formula syntax, then rewrite the body in idiomatic Python at any time (the per-field formula editor accepts both forms).

```python theme={null}
# Seeded by Migrate — your math.js body, preserved verbatim
field.value = legacy_formula('@"Volume" * 2')

# Equivalent native per-field formula — rewrite when you're ready
field.value = entity["Volume"] * 2
```

The two produce identical results for the same inputs, so existing instances and dashboards continue to show the same values.
