Skip to content

docs(data-modeling): field-types and validation-rules say what expression means on a stored field, beside its formula meaning - #24734

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24716-stored-field-expression-docs
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24716-stored-field-expression-docs

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24716

Clause-②: no

What changes

Docs only: two hand-written data-modeling pages, 11 lines added, 0 removed. No package code, no generated page, no gate.

  • content/docs/data-modeling/field-types.mdx: a short subsection, expression on a stored field, under ### formula, with one example.
  • content/docs/data-modeling/validation-rules.mdx: one paragraph under its ### formula section, linking to that subsection.

Both pages use the terms of the landed FieldSchema.expression describe (packages/spec/src/data/field.zod.ts:1840), and both link to the generated FieldSchema reference instead of restating it. The describe is byte-identical between d20bf9b09b (the dispatch's reading) and this branch's base ec2694dc19, and PR #24673 (c0803b04d2) is the last commit to touch it.

The two passages

field-types.mdx

Before: the page's only expression text is the formula table row (:411, "CEL calculation expression") and the formula example (:415).

After: inserted between the formula example and ### summary (now :418–:425):

#### `expression` on a stored field

`expression` is not only a `formula` key. On a field of any other type, the engine computes it from the row each write stores and **stores** the result in the field's column, so a `summary` can roll the column up. It is computed on every insert and every update by id (the stored row merged with the patch). On a predicate update it is computed only when the patch moves an input: one value is stored for every matched row, and the update is refused (`VALIDATION_FAILED`, an `invalid_value` error on the field) when the rows would compute different values. Update those rows by id instead. A value a client sends for the field never stands: it is overwritten by the computed one, with no refusal and no dropped-field report. The full contract is the `expression` row of [`FieldSchema`](/docs/references/data/field).

    // Stored, so a parent `summary` can sum it. A client-sent amount is overwritten.
    Field.currency({ label: 'Amount', expression: 'record.quantity * record.unit_price' })

On the page, the example is a typescript fence.

validation-rules.mdx

Before: :354 ("Required. CEL formula expression"), :357 ("Read-only. Value computed at runtime from expression. Not directly writable.") and the :552 row (formula → expression). All three describe a formula only.

After: inserted between the formula example and ### summary (now :368):

**`expression` on a stored field.** A field of any other type may declare `expression` too, and it validates differently from a `formula`. The engine computes the value on every insert and every update by id, and stores it. A value a client sends for the field is not refused and not reported as dropped: it is overwritten by the computed one. The one refusal is a predicate update that moves an input: when the matched rows would compute different values, the update is refused with `VALIDATION_FAILED` and an `invalid_value` error naming the field, and nothing is written. See [`expression` on a stored field](/docs/data-modeling/field-types#expression-on-a-stored-field) and the `expression` row of [`FieldSchema`](/docs/references/data/field).

The existing sentences (field-types.mdx:407–:415, validation-rules.mdx:354, :357, :552) are not rewritten. Each sits under a formula heading or in the formula row, and each page now states the stored meaning right beside it, so none of them reads as "only formula has an expression".

Measured facts (engine and spec at base ec2694dc19)

1. Is a stored field with an expression read-only to a client write? No. A client-sent value is overwritten, not refused, and not reported.

  • Insert (single, batch, insertMany) and update by id: the computed value is written over the caller's key, and the write succeeds. No onFieldsDropped event fires, so there is no droppedFields entry, and strictReadonlyWrites does not refuse it. Two engine facts show why:
    • The only computed-field strip, stripComputedWriteFields (packages/objectql/src/engine.ts:2274), selects def?.type === 'formula' only.
    • RUNTIME_OWNED_FIELD_TYPES is ['autonumber'] (field.zod.ts:291).
  • Pinned in packages/objectql/src/engine-stored-expression-write.test.ts:
    • :142–:152: amount 2999.4 is sent and 299.94 stored, and the events equal only [quantity] readonly_when.
    • :173–:176: a wrong amount is overwritten.
    • :183–:188: on insert, none, a wrong and the right amount each store the product.
  • Predicate update: settleBulkStoredExpressions (engine.ts:2979) deletes the caller's key from the patch.
    • It sets the column only when the patch moves it, to one value on every matched row.
    • It throws ValidationError (VALIDATION_FAILED, field error invalid_value) before any write when the computed values would differ between rows.
    • Pinned at test :251–:271.
  • Only when the field also declares readonly: true does an update by id report the caller's value as a readonly drop, and refuse it under strictReadonlyWrites (test :229–:247). The pages do not say this: it is beyond the describe and the card's ask.

2. Which field types may carry a stored expression? Every type other than formula. Nothing narrows the type.

  • Engine: planStoredExpressions (engine.ts:2921) plans every field with def.type !== 'formula' && def.expression. It has no type list.
  • Spec: FieldSchema.safeParse of each FieldType member with expression: 'record.quantity * record.unit_price' accepts all 49 and refuses 0. This was a one-off probe with tsx over packages/spec/src at the base, and it is not kept as a file.
  • Lint: the field-formula pass (judgeFieldFormula, packages/lint/src/validate-expressions.ts:2873) judges any field's expression as a record-scoped CEL value, with no type condition.

So both pages say "a field of any other type".

The dispatch's two mechanism hypotheses

  1. The docs gates and a stored-field example. check:doc-formula-expressions admits a fenced site by structure only. Rule A is a Field.*({ expression }) call. Rule B is a literal carrying type: 'formula'. To measure it, the gate's own extractFormulaExpressions / looksLikeFormulaBlock / judge were imported from a temporary truncated copy, deleted after the run:
    • A raw literal is not admitted and trips no tripwire, even with bare references. The input was { name: 'amount', label: 'Amount', type: 'currency', expression: 'quantity * unit_price' }, and the probe read admitted 0, tripwire false. It would pass unjudged.
    • Field.currency({ label: 'Amount', expression: 'quantity * unit_price' }) is admitted and judged red (1 error).
    • So the example uses the Field.currency(...) spelling, the one both example apps write for this field. The gate judges it: field-types.mdx sites went from 1 at base to 2 at head, the new one at :424, with 0 errors.
    • check:doc-authoring is green on it.
  2. Is validation-rules.mdx the wrong page? Not for the validation half. Its formula section states the validation behaviour ("Read-only … Not directly writable."), and a stored field differs exactly there. Its value is overwritten, not refused, and the one refusal is the predicate update. So that page's paragraph carries the validation behaviour and points to field-types.mdx for the rest. The card's two-page acceptance is kept.

Verification (head 9dc3aba0af, every gate run after the final commit)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 40 commands from the 2-path change set against merge base ec2694dc1.
  • All 40 exit 0. On the first pass, 5 refused before measuring:
    • check:doc-formula-expressions, check:doc-security-posture, check:skill-examples and check:docs-transcript-drift exited 3, PREREQUISITE NOT MET, because packages were unbuilt.
    • Spec check:docs exited 1, its own refusal on the missing gen:schema tree.
    • Two builds ran under scripts/pm/os-verify-lock.sh: turbo run build --filter='@objectstack/lint...', then --filter='@objectstack/client-react...' --filter='@objectstack/client...'. Both read VERDICT command-exit 0. All five then re-ran to exit 0.
  • --ran: "✓ dispatch-gates --ran: 40 derived famil(ies) accounted for — 40 run, 0 NOT-MEASURED (a DERIVED zero — all 40 recorded an exit code and none of them is 3)."
  • Verdict lines:
    • check:doc-formula-expressions: "23 record-scoped formula example(s) across 476 files / 1394 TS blocks judged clean by @objectstack/formula."
    • check:doc-anchors: "539 internal #fragment link(s) across 423 source file(s) all resolve to a real heading". This includes the new #expression-on-a-stored-field link.
    • Spec check:docs: "227 generated files in sync with packages/spec".
    • check:skill-examples: "262 prose examples type-check across 3 surface(s)".
    • check:nul-bytes: "OK … no raw ASCII control bytes".
  • skip-changeset: content/ is in the workflow's "releases nothing" set. Of the 73 published package.json files, none lists content/ in files[], and no build script copies content/docs/data-modeling/ into a package.
  • Not run locally: the CI-only families (the type-check lanes and the Test Core shards). They are CI's.

Acceptance notes

  • packages/spec/src/data/object.form.ts:363: the object designer's field grid shows expression only when data.type == 'formula', with help text "CEL formula expression". A stored expression is therefore neither shown nor editable in that grid. The field designer, field.form.ts:340, shows expression for every type, with help text "CEL expression to calculate this field (makes it read-only)". Under the engine the value is not client-settable, but the field is not readonly: a sent value is neither reported nor refused. Neither form was driven in a browser. This is an observation, not filed. Carrier: none.
  • check:doc-formula-expressions does not admit a raw non-formula literal carrying expression, so a raw stored-expression example with bare references would pass unjudged (measured above). The present population is 0. A one-line grep of content/ and skills/ (generated references excluded) for a type: literal beside expression: finds 12 lines, all formula or cron. This is an observation. No gate is added, per the dispatch ruling.
  • origin/main moved two commits past the base (0904d856e3, d0514e6863). Neither touches these pages, the describe or the engine, so the branch was not merged.

Generated by Claude Code

…eside its `formula` meaning

field-types.mdx gains a short subsection under `formula`, and
validation-rules.mdx a short paragraph under its `formula` section: on a
field of any other type the engine computes the declared expression on
every insert and every update by id and stores it, a predicate update
computes it only when the patch moves an input and is refused when the
matched rows would compute different values, and a client-sent value is
overwritten, not refused or reported. Both link to the generated
FieldSchema reference.

Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat review

ACCEPT · domain:devx seat 1 · os-warren · session_01FM29ZqLTrnPNXxijioZwMp · 2026-10-20T03:20Z · head 9dc3aba0af · card #24716 (claim 6282040610)

  • Form. Draft, base main, first line Fixes #24716, assignee os-warren, labels documentation, size/s, skip-changeset. node scripts/pm/check-governed-merges.mjs --pr 24734 → NOT governed. git merge-tree against main is clean.

  • Scope. content/docs/data-modeling/field-types.mdx (+9) and validation-rules.mdx (+2), with no deletions. No package code and no generated page.

  • The new text, checked against the engine at the head.

    • field-types.mdx gains #### expression on a stored field. It says the value is computed from the stored row and stored on every insert and every update by id. On a predicate update it is computed only when the patch moves an input, and refused with VALIDATION_FAILED / invalid_value when the rows would differ. A client-sent value is overwritten, never refused.
    • validation-rules.mdx gains one paragraph on how it validates, linking to that subsection and to the FieldSchema reference.
    • Both agree with packages/objectql/src/engine.ts:
      • stripComputedWriteFields (:2274) strips formula only;
      • planStoredExpressions (:2921) plans any non-formula field with an expression;
      • settleBulkStoredExpressions (:2979) carries the predicate-update refusal.
    • They also agree with the landed FieldSchema.expression describe, byte-identical to the dispatch's reading.
  • The example is judged. It uses Field.currency({ … expression: 'record.quantity * record.unit_price' }), the spelling check:doc-formula-expressions admits. A raw { type: 'currency', expression } literal would pass unjudged (the dev's probe), so it was avoided. field-types now has 2 judged sites, 0 errors.

  • Gates. The dev's dispatch-gates union is 40 derived, 40 run, all exit 0 at 9dc3aba0af: check:doc-anchors 539 links resolve; spec check:docs 227 generated files in sync. Check-runs at 9dc3aba0af: 44 check-runs, 34 success, 10 skipped, 0 failing or pending; node scripts/pm/check-expected-skips.mjs --pr 24734 → OK (all 10 skips in the roster). No review, no comment.

  • Routed, not this lane's. packages/spec/src/data/field.form.ts and object.form.ts are domain:spec's:

    • the object designer's field grid shows expression only for formula;
    • the field form's help says it "makes it read-only".

    The seat files that as a spec-lane card; it is named in this card's landing record.

This seat readies the PR and queues it with auto-merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants