Corteksa

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:

CodeHTTPMeaning
DEFAULT_SYNTAX_ERROR400the expression does not parse
DEFAULT_UNKNOWN_FIELD400{slug} is not a field on this object
DEFAULT_TYPE_MISMATCH400the result does not fit this field's type
DEFAULT_TOO_COMPLEX400over the size bound, or uses an aggregate
DEFAULT_DEPENDENCY_CYCLE422this 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 typePresets → expression
date / datetime / start / dueToday → TODAY() · Now → NOW() · In 7 days → DATEADD(TODAY(), 7, 'days') · In 30 days → DATEADD(TODAY(), 30, 'days')
user-ishCurrent user → {current_user}
text / numberCopy 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 labelExpression to sendField typePre-filled with
TodayTODAY()date2026-07-15
NowNOW()datetime2026-07-14T21:30:00.000Z
In 7 daysDATEADD(TODAY(), 7, 'days')due_date2026-07-22
In 30 daysDATEADD(TODAY(), 30, 'days')due_date2026-08-14
Next monthDATEADD(TODAY(), 1, 'months')date2026-08-15
Next yearDATEADD(TODAY(), 1, 'years')date2027-07-15
Last weekDATEADD(TODAY(), -1, 'weeks')date2026-07-08
Current user{current_user}text7
Copy from field{code}textwhatever code holds
Days betweenDATEDIFF({end_on}, {start_on}, 'days')number59
Percent ofROUND({amount} * 0.15, 2)currency150
Composed refCONCAT({code}, "-", TODAY())textACME-2026-07-15
First non-emptyCOALESCE({rep}, {current_user})textrep, else 7
ConditionalIF({amount} > 500, "vip", "standard")selectoption 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 caseExpressionPre-filled with
Full nameCONCAT({first_name}, " ", {last_name})Ahmed Saleh
Sortable nameCONCAT({last_name}, ", ", {first_name})Saleh, Ahmed
Work emailCONCAT({first_name}, ".", {last_name}, "@acme.com")Ahmed.Saleh@acme.com
TerritoryCONCAT({country}, "-", {city})SA-Riyadh
Dated invoice refCONCAT("INV-", TODAY())INV-2026-07-15
Prefixed refCONCAT("Q-", {code}, "-", TODAY())Q-ACME-2026-07-15
Code + quantityCONCAT({code}, "/", {qty})ACME/3
Code + amountCONCAT({code}, " (", {amount}, ")")ACME (1500.5)
Derived lengthCONCAT("REF-", LENGTH({code}))REF-4
Computed totalCONCAT("total: ", {qty} * {amount})total: 4501.5
Owner tagCONCAT("user-", {current_user})user-7
With a fallbackCONCAT(COALESCE({rep}, "unassigned"), "@acme.com")unassigned@acme.com
Conditional suffixCONCAT({code}, "-", IF({amount} > 1000, "VIP", "STD"))ACME-VIP
Renewal noteCONCAT("Renewal for ", {code}, " due ", DATEADD(TODAY(), 365, 'days'))Renewal for ACME due 2027-07-15
Age in daysCONCAT("Day ", DATEDIFF(TODAY(), {start_on}, 'days'))Day 191
Arabic / RTLCONCAT("مرحبا ", {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 empty rep gives ACME.
  • Numbers are unformatted1500.5, not 1,500.50. Offer ROUND(x, 2).
  • Dates become ISO2026-07-15 from TODAY(); the full instant from NOW().
  • Anything nestsIF, COALESCE, LENGTH, DATEADD, DATEDIFF, arithmetic.
  • ⚠️ A select/status operand gives its option ID, not its label. CONCAT({stage}, "-", {code})o-new-ACME, not New-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, but NOW() on a date field seeds a full UTC instant, which near midnight is the wrong local day. Only offer NOW() 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 offeredSUM_LINES and 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 static literal, 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_number or relation field cannot carry a default; don't offer the control there.
  • If a select field has both an is_default option 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 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