Dynamic defaults — frontend integration
Dynamic defaults — frontend integration
Status: backend shipped on stage (2026-07-27). Frontend not yet built.
What's new
A field's default value may now be a computed expression instead of a fixed
literal — TODAY(), DATEADD(TODAY(), 7, 'days'), the current user, another
field's value, or a small formula. It is evaluated once, when a record is
created and the field is left empty, and the result is then an ordinary
editable value that is never recomputed.
This is not a new field type. No new endpoint, no new column, no FieldType
value.
The full behavior contract is reproduced in Full behavior contract
at the bottom of this page — source of truth: src/api/v1/object/fields/DYNAMIC_DEFAULTS.md.
Update: the DYNAMIC_DEFAULTS_ENABLED flag has been removed — the feature is
always on. Render the Expression tab unconditionally; there is no longer any
state in which it is a dead control.
The one breaking-shaped change: default_value may be an object
default_value on field create/update/read now accepts and returns three
shapes:
// 1. bare literal — everything that exists today, unchanged
"default_value": "Draft"
// 2. explicit static envelope — same meaning
"default_value": { "mode": "static", "static": "Draft" }
// 3. expression default
"default_value": {
"mode": "expression",
"expression": "DATEADD(TODAY(), 7, 'days')",
"static": "2026-01-01" // optional fallback if evaluation fails
}Reading: a default_value that is an object with a mode key is an
envelope; anything else (including a location {lat,lng} default) is still a
plain literal. Existing code that renders default_value directly must branch on
mode or it will print [object Object] for expression defaults.
Writing: send expression only. Any ast you send is discarded and
recompiled server-side — do not build or persist one.
The public form endpoint is the exception: it always returns the plain literal (an expression default is evaluated server-side and never exposed there).
Where it is saved
The existing field endpoints: POST /object/field/:objectSlug and
PATCH /object/field/:objectSlug/:fieldSlug.
Validation errors to surface
All are rejected at save, so an invalid default can never be stored:
| Code | HTTP | Meaning |
|---|---|---|
DEFAULT_SYNTAX_ERROR | 400 | the expression does not parse |
DEFAULT_UNKNOWN_FIELD | 400 | {slug} is not a field on this object |
DEFAULT_TYPE_MISMATCH | 400 | the result does not fit this field's type |
DEFAULT_TOO_COMPLEX | 400 | over the size bound, or uses an aggregate |
DEFAULT_DEPENDENCY_CYCLE | 422 | this default would depend on itself |
Bounds: ≤ 200 nodes, ≤ 20 levels deep, ≤ 50 functions, ≤ 50 field refs.
UI to build
Field editor — Default value section. A segmented Static / Expression toggle with Static preselected. Static keeps its current single typed input.
The Expression tab should lead with presets matched to the field type, so the common cases are one click and most authors never see the raw builder:
| Field type | Presets → expression |
|---|---|
| date / datetime / start / due | Today → TODAY() · Now → NOW() · In 7 days → DATEADD(TODAY(), 7, 'days') · In 30 days → DATEADD(TODAY(), 30, 'days') |
| user-ish | Current user → {current_user} |
| text / number | Copy from another field → {other_slug} |
Behind the presets, reuse the calculation formula builder, filtered to
expressions that produce this field's type. Note the unit strings are plural:
days, weeks, months, years.
Helper copy — the single most important thing to say:
Applied once when a record is created, only if this field is left empty. Users can change it afterward — it is not recomputed.
Preview. Show the resolved value with the timezone named, e.g. "In 7 days → 2026-07-22, workspace time (Asia/Riyadh)". Relative-date defaults resolve in the acting user's timezone, so the author should see which "today" is being used.
Success toast: "Default updated" — and make clear existing records are untouched. A default only affects future creates; there is no back-fill.
Record create form. A seeded value arrives from the server as a normal value.
Render it pre-filled and editable with a subtle "auto-filled" hint that
clears the moment the user types. Deliberately no fx / read-only badge —
this is not a computed field.
Worked examples
Fixture for every line: server clock 2026-07-14T21:30:00Z, user timezone
Asia/Riyadh (local day is already the 15th), acting admin id 7, object
fields amount (currency), qty (number), code (text), rep (text),
start_on/end_on (date). All outputs verified against the engine.
Preset → expression → what the user sees pre-filled
| Preset label | Expression to send | Field type | Pre-filled with |
|---|---|---|---|
| Today | TODAY() | date | 2026-07-15 |
| Now | NOW() | datetime | 2026-07-14T21:30:00.000Z |
| In 7 days | DATEADD(TODAY(), 7, 'days') | due_date | 2026-07-22 |
| In 30 days | DATEADD(TODAY(), 30, 'days') | due_date | 2026-08-14 |
| Next month | DATEADD(TODAY(), 1, 'months') | date | 2026-08-15 |
| Next year | DATEADD(TODAY(), 1, 'years') | date | 2027-07-15 |
| Last week | DATEADD(TODAY(), -1, 'weeks') | date | 2026-07-08 |
| Current user | {current_user} | text | 7 |
| Copy from field | {code} | text | whatever code holds |
| Days between | DATEDIFF({end_on}, {start_on}, 'days') | number | 59 |
| Percent of | ROUND({amount} * 0.15, 2) | currency | 150 |
| Composed ref | CONCAT({code}, "-", TODAY()) | text | ACME-2026-07-15 |
| First non-empty | COALESCE({rep}, {current_user}) | text | rep, else 7 |
| Conditional | IF({amount} > 500, "vip", "standard") | select | option id o-vip |
Note the select row: you send the option's value/label in the expression, and
the record comes back holding the option id. Multi-select works the same way
and wraps into an array ("alpha" → ["o-a"]).
Composed text (CONCAT) — the most-requested pattern
Adds first_name = Ahmed, last_name = Saleh, city = Riyadh, country = SA,
rep (empty), amount = 1500.5, start_on = 2026-01-05, and a stage select
holding option id o-new.
| Use case | Expression | Pre-filled with |
|---|---|---|
| Full name | CONCAT({first_name}, " ", {last_name}) | Ahmed Saleh |
| Sortable name | CONCAT({last_name}, ", ", {first_name}) | Saleh, Ahmed |
| Work email | CONCAT({first_name}, ".", {last_name}, "@acme.com") | Ahmed.Saleh@acme.com |
| Territory | CONCAT({country}, "-", {city}) | SA-Riyadh |
| Dated invoice ref | CONCAT("INV-", TODAY()) | INV-2026-07-15 |
| Prefixed ref | CONCAT("Q-", {code}, "-", TODAY()) | Q-ACME-2026-07-15 |
| Code + quantity | CONCAT({code}, "/", {qty}) | ACME/3 |
| Code + amount | CONCAT({code}, " (", {amount}, ")") | ACME (1500.5) |
| Derived length | CONCAT("REF-", LENGTH({code})) | REF-4 |
| Computed total | CONCAT("total: ", {qty} * {amount}) | total: 4501.5 |
| Owner tag | CONCAT("user-", {current_user}) | user-7 |
| With a fallback | CONCAT(COALESCE({rep}, "unassigned"), "@acme.com") | unassigned@acme.com |
| Conditional suffix | CONCAT({code}, "-", IF({amount} > 1000, "VIP", "STD")) | ACME-VIP |
| Renewal note | CONCAT("Renewal for ", {code}, " due ", DATEADD(TODAY(), 365, 'days')) | Renewal for ACME due 2027-07-15 |
| Age in days | CONCAT("Day ", DATEDIFF(TODAY(), {start_on}, 'days')) | Day 191 |
| Arabic / RTL | CONCAT("مرحبا ", {first_name}) | مرحبا Ahmed |
Rules to surface in the builder's preview:
- Empty operands vanish — they become an empty string, never
"null".CONCAT({rep}, {code})with an emptyrepgivesACME. - Numbers are unformatted —
1500.5, not1,500.50. OfferROUND(x, 2). - Dates become ISO —
2026-07-15fromTODAY(); the full instant fromNOW(). - Anything nests —
IF,COALESCE,LENGTH,DATEADD,DATEDIFF, arithmetic. - ⚠️ A select/status operand gives its option ID, not its label.
CONCAT({stage}, "-", {code})→o-new-ACME, notNew-ACME. Warn the author (or exclude select fields) when they drop one into a text expression.
Full round trip
Save the rule — PATCH /object/field/deals/follow_up_due_cd34:
{ "default_value": {
"mode": "expression",
"expression": "DATEADD(TODAY(), 7, 'days')" } }Read the field back — default_value now returns the envelope, with an ast
you should ignore and never echo back:
{ "mode": "expression",
"expression": "DATEADD(TODAY(), 7, 'days')",
"ast": { "type": "function", "name": "DATEADD", "args": [ … ] } }Render the editor from expression (and static), never from ast.
Create a record leaving the field out — POST /object/data/deals:
{ "data": { "name": "Acme" } }Response holds follow_up_due_cd34: "2026-07-22". Show it pre-filled and
editable with the auto-filled hint. Send a value for that key instead and the
default is skipped entirely.
Gotchas to encode in the builder
- Units are plural.
'days' 'weeks' 'months' 'years'.'day'→ 400. TODAY()for date fields,NOW()for datetime. Both pass validation on either, butNOW()on a date field seeds a full UTC instant, which near midnight is the wrong local day. Only offerNOW()on datetime.{current_user}is text. It is rejected on a number field. Only offer the Current user preset for text-ish fields.- Month/year shifts clamp — Jan 31 + 1 month → Feb 28.
DATE("2027-01-01")parses, but a fixed date belongs in a static default; don't surface it as a preset.- Aggregates are not offered —
SUM_LINESand cross-record rollups are rejected. Filter them out of the function list for defaults.
Failure the user should never see as an error
{ "mode": "expression", "expression": "{amount} / {qty}", "static": 0 }qty = 0 at create → the field is seeded with the static fallback (0) and
the record still creates. With no static, the field just comes back empty.
Never surface this as a create failure — there is no error in the response.
Behavior worth knowing
- If the creator supplies a value, the default is skipped entirely.
- On update, defaults never re-apply. Clearing a field leaves it empty.
- An expression that fails at create falls back to its
staticliteral, else to no value — the record still creates. Never surface this as a create error. - Editing an operand later does not change an already-seeded value.
- A
calculation,serial_numberorrelationfield cannot carry a default; don't offer the control there. - If a select field has both an
is_defaultoption and an expression, the expression wins while the feature is on.
Full behavior contract
Let a field's default_value be a computed expression instead of a fixed
literal: a Created On that defaults to today, a Follow-up Due that defaults
to TODAY() + 7 days, an Owner that defaults to the creating admin, a field
that copies another field. The expression is evaluated once, when a record is
created and the field is left empty, and the result is then an ordinary,
user-editable value.
This is a field-configuration enhancement, not a new field type: no FieldType
value, no new column, no DDL.
Always on — there is no feature flag. A field left on a literal or
mode:'static' default takes exactly its pre-feature path; see
Backward compatibility.
Dynamic default vs. calculation field
Both use the same formula engine and the same FormulaNode AST. They are
lifecycle opposites, and every design decision follows from that:
calculation field | dynamic default | |
|---|---|---|
| What is stored | the formula; the value is engine-owned | the evaluated value; the formula is only a seed recipe |
| Who writes the value | the engine, forever | the engine writes the first value; the user owns it after |
| Recompute on operand change | yes, via the dependency graph and recalc cascade | never — no graph, no cascade, no cron, no runtime cycle risk |
| Editable | no (read-only, COMPUTED_FIELD_TYPES) | yes — it is a normal field value |
| Back-fills existing rows | yes | no — a default only affects future creates |
A calculation field cannot carry a default at all: calculation,
serial_number and relation remain in the skip-set.
Where it lives
In the field's existing default_value JSONB column. Three shapes are accepted,
mapping onto two modes:
// 1. a bare literal — everything that exists today, unchanged
"default_value": "Draft"
// 2. an explicit static envelope — same effect
"default_value": { "mode": "static", "static": "Draft" }
// 3. an expression default
"default_value": {
"mode": "expression",
"expression": "DATEADD(TODAY(), 7, 'days')",
"ast": { /* compiled at save; never accepted from a client */ },
"static": "2026-01-01" // optional fallback if evaluation fails
}Only an object carrying a literal mode discriminator is read as an envelope,
so a location default ({lat,lng}) or a file blob still reads as a static
literal. readDefaultConfig / getStaticDefault in
object/shared/utils/default-value-config.util.ts are the single place that
knows these shapes.
Expression language
The same parser, executor and functions as a calculation formula. What a default uses in practice:
| Reference / function | Use |
|---|---|
TODAY() | seed a date field with today (in the caller's timezone) |
NOW() | seed a datetime field with the create instant |
DATEADD(date, n, unit) | relative date — units are days, weeks, months, years |
{other_field} | copy another field on the same object |
{current_user} | the admin creating the record |
CONCAT, COALESCE, ROUND, IF, … | compose a seed |
{current_user} is a reserved reference, not a column. A field whose slug is
literally current_user shadows it.
Aggregates (SUM_LINES, cross-record rollups) are rejected: a default runs on
every create, and an aggregate would drag a calculation's recompute cost into a
one-shot seed.
Cookbook
Every row below is verified against the engine. Fixture for all of them: server
clock 2026-07-14T21:30:00Z, caller timezone Asia/Riyadh (so the local day is
already the 15th), acting admin id 7, and a deals object carrying
amount (currency), qty (number), code (text), rep (text),
start_on / end_on (date).
Dates
| Expression | Field type | Seeds |
|---|---|---|
TODAY() | date | 2026-07-15 |
NOW() | datetime | 2026-07-14T21:30:00.000Z |
DATEADD(TODAY(), 7, 'days') | due_date | 2026-07-22 |
DATEADD(TODAY(), -1, 'weeks') | date | 2026-07-08 |
DATEADD(TODAY(), 1, 'months') | date | 2026-08-15 |
DATEADD(TODAY(), 1, 'years') | date | 2027-07-15 |
DATEADD({start_on}, 30, 'days') | due_date | 2026-01-31 (from start_on = 2026-01-01) |
DATE("2027-01-01") | date | a fixed date — but prefer a static default for that |
Units are plural: days, weeks, months, years. 'day' is rejected at
save. Month and year shifts clamp to the last valid day (Jan 31 + 1 month → Feb 28).
Use
TODAY()for date fields andNOW()for datetime fields. Both pass the type-check on either, butNOW()on a date field seeds a full UTC instant (2026-07-14T21:30:00.000Z) — which near midnight is the wrong local day, the exact failureTODAY()exists to avoid.
Numbers
| Expression | Field type | Seeds |
|---|---|---|
{qty} * 2 | number | 6 (from qty = 3) |
ROUND({amount} * 0.15, 2) | currency | 150 (from amount = 1000) |
DATEDIFF({end_on}, {start_on}, 'days') | number | 59 |
{end_on} - {start_on} | number | 59 — date minus date is days |
LENGTH({code}) | number | character count |
DATEDIFF and date subtraction only see dates as dates when the operand fields
are on the object, which they always are at runtime.
Text, user and options
| Expression | Field type | Seeds |
|---|---|---|
{current_user} | text | 7 |
COALESCE({rep}, {current_user}) | text | rep if the creator typed one, else 7 |
CONCAT({code}, "-", TODAY()) | text | ACME-2026-07-15 |
IF({amount} > 500, "vip", "standard") | select | the option id — o-vip |
"alpha" | multi_select | ["o-a"] — wrapped and resolved to an id |
{amount} > 500 | boolean | true |
A select/multi-select expression yields the option's value or label; it is
resolved to the stored id on the way in, the same path a static option default
takes. A boolean result on a text field is stored as "true" / "false".
Composed text (CONCAT)
CONCAT takes any number of arguments and stringifies each one. Fixture adds
first_name = Ahmed, last_name = Saleh, city = Riyadh, country = SA,
rep = null, amount = 1500.5, start_on = 2026-01-05, and a stage select
holding option id o-new.
| Expression | Seeds |
|---|---|
CONCAT({first_name}, " ", {last_name}) | Ahmed Saleh |
CONCAT({last_name}, ", ", {first_name}) | Saleh, Ahmed |
CONCAT({first_name}, ".", {last_name}, "@acme.com") | Ahmed.Saleh@acme.com |
CONCAT({country}, "-", {city}) | SA-Riyadh |
CONCAT("INV-", TODAY()) | INV-2026-07-15 |
CONCAT("Q-", {code}, "-", TODAY()) | Q-ACME-2026-07-15 |
CONCAT({code}, "/", {qty}) | ACME/3 |
CONCAT({code}, " (", {amount}, ")") | ACME (1500.5) |
CONCAT("REF-", LENGTH({code})) | REF-4 |
CONCAT("total: ", {qty} * {amount}) | total: 4501.5 |
CONCAT("user-", {current_user}) | user-7 |
CONCAT(COALESCE({rep}, "unassigned"), "@acme.com") | unassigned@acme.com |
CONCAT({code}, "-", IF({amount} > 1000, "VIP", "STD")) | ACME-VIP |
CONCAT("Renewal for ", {code}, " due ", DATEADD(TODAY(), 365, 'days')) | Renewal for ACME due 2027-07-15 |
CONCAT("Day ", DATEDIFF(TODAY(), {start_on}, 'days')) | Day 191 |
CONCAT("مرحبا ", {first_name}) | مرحبا Ahmed |
How each argument stringifies:
- null / empty → empty string, never the text
"null".CONCAT({rep}, {code})with an emptyrepseedsACME, notnullACME. - Numbers print plainly —
1500.5, not rounded or thousands-separated. Wrap inROUND(…, 2)if you want a fixed shape. - Dates serialize ISO: a date operand or
TODAY()becomes2026-07-15;NOW()becomes the full instant. - Unicode and RTL pass through untouched.
- Any function may nest inside
CONCAT—IF,COALESCE,LENGTH,DATEADD,DATEDIFF, arithmetic.
A select/status operand stringifies to its option ID, not its label.
CONCAT({stage}, "-", {code})seedso-new-ACME, notNew-ACME, because the record stores the id. Don't build a human-readable reference out of a select field.
What the type-check rejects
| Expression | Field type | Why |
|---|---|---|
"tomorrow" | date | text result on a date field |
{amount} | date | a currency operand is not a date |
TODAY() | number | a date is not a number |
{current_user} | number | {current_user} is typed as text — put it on a text field |
DATEADD(TODAY(), 7, 'day') | date | the unit is 'days' |
SUM_LINES({code}) | number | argument must be a Smart Catalog field |
{nope} | any | no such field on this object |
Worked request / response
Configure — PATCH /object/field/deals/follow_up_due_cd34:
{ "default_value": {
"mode": "expression",
"expression": "DATEADD(TODAY(), 7, 'days')",
"static": null } }Stored (the ast is compiled server-side; a client-supplied one is discarded):
{ "mode": "expression",
"expression": "DATEADD(TODAY(), 7, 'days')",
"ast": { "type": "function", "name": "DATEADD", "args": [
{ "type": "function", "name": "TODAY", "args": [] },
{ "type": "constant", "value": 7 },
{ "type": "constant", "value": "days" } ] } }Create — POST /object/data/deals with { "data": { "name": "Acme" } } → the
row stores follow_up_due_cd34 = 2026-07-22, editable, never recomputed.
Send { "data": { "name": "Acme", "follow_up_due_cd34": "2026-09-01" } } and the
default is skipped entirely.
Chained defaults
Two defaults on the same object, where one reads the other:
// code_a1 → "AB"
// reference_b2 → CONCAT({code_a1}, "-1")Both empty at create → code_a1 = "AB", then reference_b2 = "AB-1". Order of
declaration does not matter; they are sorted by dependency. A default that reads
a field the creator typed sees the typed value, because defaults run after user
values are merged. code_a1 → {reference_b2} and reference_b2 → {code_a1}
is a cycle and is rejected at save.
Failure, end to end
{ "mode": "expression", "expression": "{amount} / {qty}", "static": 0 }qty = 0 at create → evaluation fails → the field is seeded with 0, one warn
line names the field, and the record still creates. Drop "static" and the
field is simply left empty. The same happens when a seed is the right type but
breaks a bound (a 300-char text seed on a VARCHAR(255)).
Save time
DynamicDefaultValidatorService.validate runs at field create/update, before any
write, and a bad expression is never storable:
- Compile. The AST is always recompiled from
expression; any client-suppliedastis discarded, so no caller can smuggle in a node the parser would never produce. - Complexity bound. ≤ 200 nodes, ≤ 20 levels deep, ≤ 50 functions, ≤ 50
refs → else
DEFAULT_TOO_COMPLEX. - Type-check. Every
{slug}must exist on the object, and the result must fit the field's own type — a date field needs a date result, a number field a number, a select field an option id, a text field anything stringifiable. - Cycle check. A default that would (transitively) depend on itself is
rejected with
DEFAULT_DEPENDENCY_CYCLE(422). Only meaningful on update, where the owner's slug exists. - Fallback check. A
staticfallback goes through the field's own type handler like any literal default.
Error codes: DEFAULT_SYNTAX_ERROR, DEFAULT_UNKNOWN_FIELD,
DEFAULT_TYPE_MISMATCH, DEFAULT_TOO_COMPLEX (400) and
DEFAULT_DEPENDENCY_CYCLE (422).
A literal default never loads the object's fields, so an ordinary field save costs nothing extra.
Apply time
applyFieldDefaults(data, fields, applier) gains an optional applier, built per
create or per import batch by FieldsPublicService.createDynamicDefaultApplier()
(null when the flag is off). Order within one create:
- user-supplied values are already in
recordData; - static defaults merge for every empty field;
- expression defaults evaluate, in dependency order, so a
{ref}reads what the creator typed and what a static default supplied; - each seed is coerced to the field's stored shape (select-family → option id)
and then run through the field's own type handler — the same gate a typed
value passes. Save-time type-checking cannot see a bound (a 300-char text
seed on a
VARCHAR(255), a number pastmax), so a seed that would only fail at write time is rejected here and falls back, rather than aborting the create.
Every create path is covered: interactive create, import, workflow create-record, lead-ads and messaging auto-create, and public forms.
Failure never blocks a create. A throw, a division by zero, or a result that
coerces to nothing falls back to the configured static literal, else to no
value at all — and the fallback is logged, so a default that is quietly erroring
on every create is traceable.
Update never re-applies a default. Clearing a field on update leaves it empty; the seeded value is edited like any other value.
Timezone
TODAY() / NOW() resolve against a pinned clock: one instant plus one IANA
zone, captured when the applier is built. So:
- a record created at 21:30 UTC by a UTC+3 caller lands on the next calendar day, not the server's day;
- every row of one import batch agrees on which day "today" is, even across midnight.
The zone comes from the acting admin's timezone (AdminContext), the only
timezone the platform records today. When a workspace-level timezone lands, it
becomes the source in DynamicDefaultContext.timeZone and nothing else moves.
A calculation formula is unaffected: without a clock the executor reads the
server clock exactly as before.
Backward compatibility
For any field left on a literal / mode:'static' default, nothing about the old
path changes: no parse, no type-check, no engine call, no timezone lookup, no
extra query, no DDL. The skip-set and the select-family is_default option path
are untouched.
An expression stored uncompiled (no ast) can never be evaluated; it
degrades to its static fallback. That is the shape of any row written before
this feature shipped, back when it was gated behind a DYNAMIC_DEFAULTS_ENABLED
flag — re-saving the field compiles it and revives the rule as written.
When a select field carries both an is_default option and an expression, the
expression wins whenever it can run; the option is the fallback.
What is deliberately not built
No dependency graph is retained after a seed lands, no recompute cascade, no
daily cron, no back-fill of existing rows, and no column DEFAULT for an
expression default (a column default would back-fill every existing row with one
frozen value). Those all belong to calculation fields.
Environment
None. The feature has no environment variable and no runtime toggle.
Code map
| Concern | File |
|---|---|
| Stored shapes + unwrap helpers | object/shared/utils/default-value-config.util.ts |
| Config types | object/shared/interfaces/default-value-config.interface.ts |
| Applier contract | object/shared/interfaces/dynamic-default-applier.interface.ts |
| Save-time compile / type-check / cycle | fields/services/dynamic-default/dynamic-default-validator.service.ts |
| Complexity bound | fields/services/dynamic-default/default-expression-complexity.ts |
{current_user} + type map | fields/services/dynamic-default/default-system-refs.ts |
| Apply-time evaluation | fields/services/dynamic-default/dynamic-default-resolver.service.ts |
| Where defaults merge | object/shared/utils/field-default.util.ts |