Documentation Index
Fetch the complete documentation index at: https://docs.seal.run/llms.txt
Use this file to discover all available pages before exploring further.
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.
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.
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
below.
Concise syntax
A formula is a single Python expression. Three equivalent forms:
# 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.
Read fields by exact UI name. Bracket notation is the common value-read form:
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:
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:
# 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:
# 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:
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.
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.
# 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.
# 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.
# 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.
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:
# 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:
# 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:
# On a child's "Materials" multi-REFERENCE field
entity.created_from["Materials"]
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.
Dates and times
datetime and timedelta are pre-loaded as globals. No imports — import is blocked.
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.
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.
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.
Instance only — what the toggle does
Only pseudo-field formulas (title and status_tag) carry an Instance only checkbox. Regular field formulas have no per-formula override — they always compute on the template and on every instance.
For a title or status-tag formula authored on a template, the checkbox controls whether the formula also computes against the template entity itself:
| Formula kind | Default on a template | What it means |
|---|
| Title | On (Instance only checked) | Formula computes on instances only. The template’s own title stays manually editable so you can give the template itself a meaningful name. |
| Status tag | On (Instance only checked) | Formula computes on instances only. The template’s own status tag stays manually editable. |
Instances always compute regardless of the checkbox — the toggle only affects what happens on the template entity itself.
When Instance only is checked on a template, you’ll still see the fx affordance next to the title / status-tag cell — the formula is still configured here, you just authored it for instances. The cell stays editable and isn’t painted with the “computed” background.
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.
# Title formula — assign to the `title` global
title = f"{entity['Compound']} — Batch {entity['Batch Number']}"
# 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:
# Bare expression — compiles to `title = ...`
f"{entity['Compound']} — Batch {entity['Batch Number']}"
# Explicit return
return f"{entity['Compound']} — Batch {entity['Batch Number']}"
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 below.
Lifecycle triggers (onSchedule, onPublish, status changes, etc.) aren’t available for formulas — use a script 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.
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
if entity["Volume"] is None or entity["Density"] is None:
skip()
field.value = entity["Volume"] * entity["Density"]
Out-of-spec warning
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
field.value = seal.sum(entity["Line Items"], "Quantity")
Days since publication
if entity.last_published_at is None:
skip()
field.value = (datetime.now() - entity["Published Date"]).days
Forward a reviewer
# Copy the reviewer of the parent batch onto this sub-batch
parent = entity.created_from
field.value = parent["Reviewer"] if parent else None
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.
# field.value on "Total quantity (mL)" — the canonical intermediate
field.value = sum(item["Volume mL"] for item in entity["Items"] or [])
# 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.
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.
- 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).
- The modal opens read-only with a “no longer supported” banner at the top.
- 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.
- 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).
# 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.