Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): apply a formula field's declared scale where the value is produced - #10515
Conversation
…ue is produced
`Field.formula({ scale: 2 })` was inert: `applyFormulaPlan` assigned the
evaluator's raw double, so a percentage formula returned
`41.666666666666664` and every consumer — including the record page —
inherited all fifteen digits.
`scale` now reaches `applyFormulaPlan` on the plan entry
`planFormulaProjection` builds, and the value is rounded with
`Number(v.toFixed(scale))` (round-half-away-from-zero, matching objectui's
`computeRow`). All three plan-entry call sites — `find`, `findOne`, and the
write-response hydration — inherit it from the one producer.
The rounding is deliberately NOT in `validateFieldValue`'s `scale` branch:
that branch enforces `scale` by REJECTION for caller-supplied values
(#7501, maintainer ruling 2026-08-11), and rounding there would convert a
refusal into the silent data alteration the ruling forbids. A comment at
that branch records the boundary.
Fixes#10280
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HMCo-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
…rmula-scale-at-producer
…the scale suite `check:type-check-debt --re-measure` caught the new test file adding one raw tsc error to @objectstack/objectql's TEST_DEBT ledger (355 -> 356): src/engine-formula-scale.test.ts(270,55): error TS2554: Expected 2-5 arguments, but got 1. Fixed at the author's end, per the ratchet's own prescription — the ledger is shrink-only and was NOT raised. Re-measured at 355 with zero errors from this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0b898aa6cdf3ab138bb3d9e886618824706422d && git checkout a0b898aa6cdf3ab138bb3d9e886618824706422d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 99f1f91b06a233d70e873a83025420c16cf83b62 3929e68d0e5dc6588eca784d7a3d6d075aac18fe && git checkout -B drift-repro 99f1f91b06a233d70e873a83025420c16cf83b62 && git merge --no-ff 3929e68d0e5dc6588eca784d7a3d6d075aac18fe
node scripts/docs-audit/affected-docs.mjs --json 99f1f91b06a233d70e873a83025420c16cf83b62 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10280
Field.formula({ scale: 2 })was accepted and then ignored.applyFormulaPlanassigned the evaluator's raw double with no
scaleread anywhere on the path, soHotCRM's
crm_campaign.response_ratereturned41.666666666666664fornum_sent: 12 / num_responses: 5and the record page printed all fifteen digits.scaleis the only declarative rounding a formula author has; inert, it made everydivision-shaped formula a coin flip on how it prints.
The fix — rounding at the producer
packages/objectql/src/engine.tsFormulaPlanEntrygainsscale?: number, read inplanFormulaProjectionbyformulaRoundingScale(def).applyFormulaPlanpasses the evaluated value throughroundFormulaValuebefore assigning it.
planFormulaProjectionfeeds all three plan-entry call sites, so one producercovers the whole surface:
find,findOne, andhydrateWriteFormulas(the writeresponse, itself reached from both the single-record and the batch write paths). A
fix that rounded on read but not on the write response would have shipped the
defect on half the surface; each of the three is asserted separately below.
The card's title says "stores and returns". Only "returns" is true. A formula field
has no SQL column (
driver-sql'sfieldHasColumnreturns false forformula;sql-driverrefuses it as a cross-field referent — "virtual, no column"), anddriver-memoryreturns shallow copies, soapplyFormulaPlan's in-place mutationcannot reach any store. The
DECIMAL(10,2)hazard the card describes is real butstrictly downstream — an app copying the formula result into a stored money
field — and producer-side rounding is exactly what makes that copy writable. The
changeset is worded accordingly and claims no persistence path.
Rounding semantics
Number(v.toFixed(scale)), matching objectui'scomputeRow(GridField.tsx) so agrid's client-side computed column and the engine's server-side formula round the
same way. Per ECMA-262
toFixedextracts the sign first and then picks the largernon a tie — round-half-away from zero on the magnitude. Measured on thiscontainer rather than assumed, because the opposite (half-to-even) was believed at
dispatch time and is false:
(2.5).toFixed(0)32(1.25).toFixed(1)1.31.2(0.125).toFixed(2)0.130.12(-2.5).toFixed(0)-3-2-1.5→-2), not toward+Infinity.Edge cases, each pinned by a test
null)'hello'.toFixeddoes not exist — without thetypeofguard one scale-declaring string formula throws and takes down every read of that object (verified by ablation, below)NaN/InfinitynumberNaN.toFixed(2)yields the STRING'NaN'. Both round-trip unchanged throughNumber(...), so theNumber.isFiniteguard alters no value; it is kept so the helper is total by construction rather than by a coincidence of two library behaviours-0(e.g.-0.001atscale: 2)+0Number((-0.001).toFixed(2))is-0. It JSON-serializes as0, butnew Intl.NumberFormat('en-US', { minimumFractionDigits: 2 }).format(-0)is"-0.00"— a brand-new display wart on the very record page this card is about. One comparison at the producer beats a rule in each renderertoFixedreturns exponential form ('1e+21') whichNumber()round-trips losslessly — degrades to a no-op instead of corrupting the valuescaleabove 100toFixedthrowsRangeErrorpast 100 fraction digits andFieldSchema.scaledeclares no upper bound. A display-precision declaration must never be the reason a read fails, and asking for more decimals than a double carries is a no-op request anywayscale(2.5,-1)Number.isInteger(s) && s >= 0test the write path uses, so one malformed declaration cannot mean two things one layer apart. The producer already refuses it (z.number().int().min(0))⛔ Why NOT
record-validator.ts— and the control that proves itvalidateFieldValue'sscalebranch is the package's only other.scalereaderand therefore the tempting seam. It is the wrong one: that branch enforces
scaleby rejection (
max_scale) for caller-supplied values under the maintainer's2026-08-11 ruling — "
scale— enforced by REJECTION, never rounding" — because avalue someone sent has an author to refuse, and rounding it would be the silent
data alteration the ruling forbids. A formula result is platform-computed: there is
nobody to refuse.
Because plan entries are
type === 'formula'only, rounding insideapplyFormulaPlanleaves the #7501 path structurally untouched — and nothing wascarved out of #7501 to make it work: the type door at the end of
validateOnealready excludes formula / summary / autonumber outputs, so this fills a hole
#7501 never covered. A comment at that branch records the boundary so the next
reader is not tempted the same way.
The three controls
All three run through the protocol surface REST calls (
createData/updateData/
createManyData/insertManyData/getData/find), not by unit-calling aprivate helper. New file:
packages/objectql/src/engine-formula-scale.test.ts(24 tests).
1. Defect control — fails on
origin/mainRun first on the unmodified tree (
f094214b3), all 24 tests present:13 failed | 11 passed (24).After the fix:
24 passed (24). The card's own expression, evaluated by the realExpressionEngine, now returns41.67on every one of the three call sites.2. No-scale control — full precision, unchanged
response_rate_rawdeclares the same expression on the same record with noscale. It asserts41.666666666666664exactly, on both the write response andthe read path. It passed before the fix and passes after — which is the whole point:
without it, the change could quietly round every formula in the platform and nothing
would say so.
Blast-radius check backing it: a repo-wide scan for a
formuladeclaration thatalso declares
scalefinds no existing one outside the new test file (the twoField.formulafields inexamples/declare noscale; the nearbyscale: 2inthose files is on neighbouring
currencyfields). The change is a no-op for everyformula that exists today.
3. ⭐ Discriminating control —
Field.number({ scale })still REJECTSfs_campaign.plain_rateis a plainnumbercarrying the samescale: 2, on thesame object as the rounded formula. Writing
41.666666666666664into it is refused,with the envelope rather than a bare throw —
code: 'VALIDATION_FAILED', fieldentry
{ field: 'plain_rate', code: 'max_scale', constraint: { scale: 2, actual: 15 } },recognised by the platform's own
validationFailureDetails(the status side: anobjectql
ValidationErrordeliberately carries no.status, so the test reads therecogniser and
VALIDATION_FAILED_STATUSinstead of re-spelling400) — andnothing is written.
It can fail, and here is the run where it did. Ablation: the tempting wrong seam
implemented — the validator's
scalebranch stops refusing and rounds the caller'svalue in place instead:
3 failed | 21 passed (24)— all three CONTROL 3 tests red, CONTROL 1 and CONTROL 2untouched. That is the discrimination: the two paths are independent, and the control
is red exactly when the rounding is put in the wrong place. Ablation reverted with
git checkout HEAD -- …(the fix was committed first); restore leg re-run:24 passed (24).Second ablation — the
typeofguard is load-bearingGuard removed from
roundFormulaValue:8 failed | 16 passed (24), and the enginelog shows the mechanism rather than an assertion diff:
One scale-declaring string formula would have taken down every read and every write
of that object. Guard restored; restore leg green.
Verification
Both ablations state their restore leg. No
distresolution is involved in either:the test imports
./engine.jsand the validator relatively, fromsrc, so vitestcompiles the mutated source directly — the only cross-package imports
(
@objectstack/metadata-protocol,@objectstack/formula,@objectstack/types)were untouched by both ablations and were rebuilt before the runs regardless.
Also proved, because the fix is worthless if the authored declaration never reaches
def.scale:Field.formula({ …, scale: 2 })→FieldSchema.parsekeepsscale = 2on atype: 'formula'field.Gates
Set re-derived after the final commit with
node scripts/pm/dispatch-gates.mjs(no path arguments — the script takes the merge-base change set itself), which added
five convention-triggered gates the dispatch list did not name, all because this PR
adds a test file:
check:query-options-erasure,check:type-check-coverage,check:type-check-debt,check:engine-double-contract,check:where-matcher.All run at
3929e68d0, on the merged tree.pnpm check:changeset-gate-self-tests✓ check-changeset-no-major --self-test: 116 assertions …(plus the 118- and 212-assertion self-tests)pnpm check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.pnpm check:durability-log-level✓ durability-degradation log levels: 30 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller (5 propagating, declared).pnpm check:error-code-casing✓ no lowercase error codes in 4328 scanned file(s) (ADR-0112).pnpm check:objectui-changeset✓ objectui-range --self-test: all checks passedpnpm check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new … baseline key set verified against 5c3faa7: no files added.pnpm check:stack-collection-maps✓ check-stack-collection-maps: 7 enumerations reconciled against 32 declared collections (16 waiver rows, each with a reason).pnpm check:nul-bytescheck-nul-bytes: OK (scanned 6145 text file(s) …; no raw ASCII control bytes).pnpm check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new … baseline key set verified against 5c3faa7: no files added.pnpm check:engine-double-contractcheck-engine-double-contract: OK — 340 pinned, 133 in the DEBT ledger, 2 exempt.pnpm check:where-matcher✓ where-matcher conformance holds: 267 matcher(s) discovered, 267 answer the combinator battery correctly or refuse it loudly (161 refuse). 0 silently-wrong …pnpm check:type-check-coveragecheck-type-check-coverage: OK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledger …pnpm check:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured in 535.9s, 1924 raw tsc error(s) total, none above its recorded number.— red first, see belownode scripts/check-adr-0087-registration.mjs✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump.node scripts/check-cross-package-test-inputs.mjsOK: 12 package(s) read outside themselves, all declared …node scripts/check-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).node scripts/docs-audit/check-affected-docs.mjs✓ affected-docs self-test: 262 cases pass.node scripts/check-engine-split-ratio.mjsis report-only (always exits 0; theADR-0076 OQ#5 threshold is deliberately unset). It reports
engine-core commits 30 · also cross-package 28 · ratio 93.3%— unchanged by this PR's arithmetic in any waya threshold could read.
check:type-check-debtwas RED on the first run, and it was mine. The new testfile contributed one raw tsc error to
@objectstack/objectql's TEST_DEBT ledger:Fixed at the author's end, which is the ratchet's own prescription — the call now
passes the required
packageId(registerObject(obj as never, 'test'), thespelling every other test in this package uses). ⛔ The ledger was not raised.
Re-measured: 355, with zero errors attributed to the new file.
Serialization against PR #10455 (issue #10172), re-checked at push time
Condition from the dispatch, discharged rather than assumed.
git merge origin/mainmerged clean (no conflicts, no deferred regeneration). #10455 re-read at push time:
still 1 commit, 1 file, +13/−3, head
b54f1a44d, still exactly one hunk@@ -1357,9 +1357,19 @@ export interface HookEntry {— additive doc prose insidethe
HookEntrydocblock. It has not grown intoapplyFormulaPlan's region.This PR's edits sit at
FormulaPlanEntry/planFormulaProjectionandroundFormulaValue/applyFormulaPlan, all above it, in different declarations,with zero textual overlap.
One observation, recorded and not acted on: #10455 now reads
draft: false,where the dispatch described it as a draft. State on someone else's PR that I did not
set belongs to another actor — noted for the PM, not "corrected".
Deliberately not done
FieldSchema.scalealready declaresscalefor every fieldtype and
Field.formulaspreads it through, so nothing about what the platformaccepts or rejects moved. Verified by parsing the card's own declaration.
record-validator.tsbeyond a comment (see above).registry.tswas not touched, and neither was thecheck-query-options-erasure-ratchetparse failure tracked as [P0-suspect] check:query-options-erasure runs ESLint IN-PROCESS without the stack fix — registry.ts crossed the default-stack limit again and the gate now reds every PR's Lint & Repo Gates #10449 — which doesnot reproduce on this tree anyway: the gate ran green here, apparently cleared by
fix(spec): re-spell step17.rationale as a joined fragment array, collapsing registry.ts AST depth 977 to 76 #10446 landing on
main.scalecoupling to display. Rounding is a value decision at the producer;how a renderer groups or pads digits (
useGrouping,minimumFractionDigits) isuntouched.
Generated by Claude Code