Corteksa
GuidesOther

DYNAMIC DEFAULTS

DYNAMIC DEFAULTS

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 fielddynamic default
What is storedthe formula; the value is engine-ownedthe evaluated value; the formula is only a seed recipe
Who writes the valuethe engine, foreverthe engine writes the first value; the user owns it after
Recompute on operand changeyes, via the dependency graph and recalc cascadenever — no graph, no cascade, no cron, no runtime cycle risk
Editableno (read-only, COMPUTED_FIELD_TYPES)yes — it is a normal field value
Back-fills existing rowsyesno — 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 / functionUse
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

ExpressionField typeSeeds
TODAY()date2026-07-15
NOW()datetime2026-07-14T21:30:00.000Z
DATEADD(TODAY(), 7, 'days')due_date2026-07-22
DATEADD(TODAY(), -1, 'weeks')date2026-07-08
DATEADD(TODAY(), 1, 'months')date2026-08-15
DATEADD(TODAY(), 1, 'years')date2027-07-15
DATEADD({start_on}, 30, 'days')due_date2026-01-31 (from start_on = 2026-01-01)
DATE("2027-01-01")datea 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 and NOW() for datetime fields. Both pass the type-check on either, but NOW() 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 failure TODAY() exists to avoid.

Numbers

ExpressionField typeSeeds
{qty} * 2number6 (from qty = 3)
ROUND({amount} * 0.15, 2)currency150 (from amount = 1000)
DATEDIFF({end_on}, {start_on}, 'days')number59
{end_on} - {start_on}number59 — date minus date is days
LENGTH({code})numbercharacter 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

ExpressionField typeSeeds
{current_user}text7
COALESCE({rep}, {current_user})textrep if the creator typed one, else 7
CONCAT({code}, "-", TODAY())textACME-2026-07-15
IF({amount} > 500, "vip", "standard")selectthe option ido-vip
"alpha"multi_select["o-a"] — wrapped and resolved to an id
{amount} > 500booleantrue

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.

ExpressionSeeds
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 empty rep seeds ACME, not nullACME.
  • Numbers print plainly — 1500.5, not rounded or thousands-separated. Wrap in ROUND(…, 2) if you want a fixed shape.
  • Dates serialize ISO: a date operand or TODAY() becomes 2026-07-15; NOW() becomes the full instant.
  • Unicode and RTL pass through untouched.
  • Any function may nest inside CONCATIF, COALESCE, LENGTH, DATEADD, DATEDIFF, arithmetic.

A select/status operand stringifies to its option ID, not its label. CONCAT({stage}, "-", {code}) seeds o-new-ACME, not New-ACME, because the record stores the id. Don't build a human-readable reference out of a select field.

What the type-check rejects

ExpressionField typeWhy
"tomorrow"datetext result on a date field
{amount}datea currency operand is not a date
TODAY()numbera date is not a number
{current_user}number{current_user} is typed as text — put it on a text field
DATEADD(TODAY(), 7, 'day')datethe unit is 'days'
SUM_LINES({code})numberargument must be a Smart Catalog field
{nope}anyno 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:

  1. Compile. The AST is always recompiled from expression; any client-supplied ast is discarded, so no caller can smuggle in a node the parser would never produce.
  2. Complexity bound. ≤ 200 nodes, ≤ 20 levels deep, ≤ 50 functions, ≤ 50 refs → else DEFAULT_TOO_COMPLEX.
  3. 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.
  4. 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.
  5. Fallback check. A static fallback 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:

  1. user-supplied values are already in recordData;
  2. static defaults merge for every empty field;
  3. expression defaults evaluate, in dependency order, so a {ref} reads what the creator typed and what a static default supplied;
  4. 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 past max), 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

ConcernFile
Stored shapes + unwrap helpersobject/shared/utils/default-value-config.util.ts
Config typesobject/shared/interfaces/default-value-config.interface.ts
Applier contractobject/shared/interfaces/dynamic-default-applier.interface.ts
Save-time compile / type-check / cyclefields/services/dynamic-default/dynamic-default-validator.service.ts
Complexity boundfields/services/dynamic-default/default-expression-complexity.ts
{current_user} + type mapfields/services/dynamic-default/default-system-refs.ts
Apply-time evaluationfields/services/dynamic-default/dynamic-default-resolver.service.ts
Where defaults mergeobject/shared/utils/field-default.util.ts

On this page