Repository navigation
fix(spec): rewrite the cross_field and script examples in validation.zod.ts in evaluable CEL, un-inverting two (#20252) - #20276
Conversation
…in evaluable CEL Example 1 (close date in the current or a future month) is rewritten to a condition that is TRUE on the violation - a close date before the first day of the current month - using the stdlib date(), today() and addDays() plus CEL's getDate() accessor. Example 3 (products required for Closed Won) keeps its direction and drops the lone `=` and the `AND` keyword. The Salesforce-formula side of each example is unchanged. TSDoc text only. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 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 dde7145b95c0abf842317e3a846fe8778089f677 && git checkout dde7145b95c0abf842317e3a846fe8778089f677
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 08cb6583fe8787bfdb1555a79a2500418be1d925 && git checkout -B drift-repro 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 && git merge --no-ff 08cb6583fe8787bfdb1555a79a2500418be1d925
node scripts/docs-audit/affected-docs.mjs --json 17bd31877109b7cc692e7e54c4fe39f82a5c32d5
|
… record. and un-invert the cross_field describe example Example 2 (`discount > (amount * 0.40)`) and the header script example (`discount_percent > 0.40`) named bare fields, which the evaluator does not bind. The CrossFieldValidationSchema.condition description gave `record.end_date > record.start_date`, which under TRUE-is-violation refuses every valid end-after-start range; it now gives the violation, `record.end_date < record.start_date`, and says a TRUE condition fails validation. Documentation text only. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
…lidation.zod.ts examples Generator output of `pnpm --filter @objectstack/spec gen:docs` after the header script example and the CrossFieldValidationSchema.condition description changed; not hand-edited. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Contract reviewServed-tier: 73/73 ① Derived judgments
CI on the head: 49 check-runs, 42 success, 7 skipped, 0 failures (all three ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20252
Clause-②: no
Rework round 1: what changed since the first revision
The order of record is the seat's comment 5858340569 on #20252.
packages/spec/src/data/validation.zod.ts, text only: the headerscriptexample at :100,cross_fieldExample 2 at :257, and the.describe()onCrossFieldValidationSchema.conditionat :294.content/docs/references/data/validation.mdx, regenerated bypnpm --filter @objectstack/spec gen:docs. It is generator output, not hand-edited.check:docswent stale because both the file-header docblock and the describe render into that page.ec694f0b55(Examples 1 and 3), unchanged;efc0b4aa15(the three sites and the changeset text) and08cb6583fe(the regenerated page).origin/mainhad not moved invalidation.zod.ts, so there was no merge.What changed (every example predicate in
validation.zod.ts)scriptexamplediscount_percent > 0.40(bare field)record.discount_percent > 0.40cross_fieldExample 1MONTH(close_date) >= MONTH(TODAY()) AND YEAR(close_date) >= YEAR(TODAY())(does not parse, and inverted)date(record.close_date) < addDays(today(), 1 - today().getDate())cross_fieldExample 2discount > (amount * 0.40)(bare fields)record.discount > (record.amount * 0.40)cross_fieldExample 3products = null AND stage = "closed_won"(does not parse)isBlank(record.products) && record.stage == "closed_won".describe()onCrossFieldValidationSchema.conditionPredicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date`(inverted)Predicate (CEL) comparing fields. If TRUE, validation fails — the condition describes the violation. e.g. P`record.end_date < record.start_date` refuses an end date before the start date.The Salesforce-formula side of every example is byte-for-byte unchanged. This is documentation text only: the TSDoc, one
.describe()string and the regenerated reference page. There is no schema, behaviour or export change. Changeset:@objectstack/specpatch.Semantics: TRUE is the violation
evaluateRuledispatches bothscriptandcross_fieldtocheckPredicate(packages/objectql/src/validation/rule-validator.ts), which returns arule_violationonly whenresult.value === true(line 4078 atec694f0b55). So every example's condition must be TRUE on the record the rule rejects.truefor a 2026-09-15 close date andfalsefor 2026-08-31 on a 2026-09-27 clock (transcript C)..describe()example was inverted, and is un-inverted.record.end_date > record.start_dateevaluates totrueon a valid end-after-start range andfalseon a real violation (transcript E). It becomesrecord.end_date < record.start_date, and the description now says a TRUE condition fails validation. The siblingScriptValidationSchema.conditiondescription already says this.scriptexample keep their direction. Only their spelling changed.Functions: what the stdlib offers, and the choice made for Example 1
Measured in
packages/formula/src/stdlib.ts(registerStdLib). The date functions arenow(),today(),daysFromNow(n),daysAgo(n),daysBetween(a, b),addDays(d, n),addMonths(d, n),date(x)anddatetime(x). The stdlib registers noMONTH/YEAR/TODAY.today()returns the reference-timezone calendar day as a UTC-midnight Timestamp (line 247).Month and year extraction exist as cel-js built-in Timestamp receiver methods (
@marcbachmann/cel-js8.0.0lib/functions.js).getDate()is the 1-based day of the month, read in UTC. The platform accepts the receiver form.validate.test.tskeepsrecord.created.getFullYear()valid, andvalidate.tsdocuments the receiver-only names as resolving but ineligible for the bare-call catalogCEL_STDLIB_FUNCTIONS.So Example 1 is expressible and was kept, not replaced.
addDays(today(), 1 - today().getDate())is the first day of the current month, anddate(record.close_date)accepts both aField.datestring and a driver-hydratedDate. Example 3 uses the stdlib'sisBlank, the stdlib's documentedISBLANK()analog, which is TRUE for null,''and[].Evaluation transcript (
ExpressionEngine.evaluate,@objectstack/formuladist built from the spec source at head08cb6583fe)Method, the same one PR #20239 used.
condition:literal is extracted fromgit show REF:packages/spec/src/data/validation.zod.tsbyte for byte, with the comment prefix stripped and the quotes kept.(0, eval)(literal), to get exactly the runtime string a reader's paste produces. It is never hand-retyped..describe()site, the describe literal is unescaped the same way, which gives the published description text. The P-tagged example is then cut out of that text and handed to Node as the tagged template a reader would paste, with the spec's realPhelper in scope. Itssourceis what gets evaluated.validateExpression('predicate', src, { scope: 'record', … }), the verdict@objectstack/lintgives a validation-rule condition, is printed beside each.Clock. The evaluation clock is fixed through
EvalContext.now:2026-09-27T12:00:00Z, plus2027-01-15T12:00:00Zfor the year boundary. Two Example 1 rows use the real clock instead, with dates computed relative to the run date: the first day of the current UTC month, and the day before it. Timezone is the evaluator default, UTC, which is whatcheckPredicatepasses. A row with a JSDatevalue (the driver-hydrated shape) renders as its ISO string. None of the round-1 sites depends ontoday().A. Examples 1 and 3 at head
08cb6583fe(unchanged since round 0)Re-run at head
08cb6583fe: 13 of 13 rows OK, 0 mismatches.B. Examples 1 and 3 before (BASE
a9fb83ef06), same extractionC. Example 1's inversion, shown on the old logic with only its syntax repaired
D. Round 1: the three new sites at head
08cb6583fe10 of 10 rows OK, 0 mismatches.
E. Round 1: the same three sites before (round-0 head
ec694f0b55), same extractionEnumerating checks
The extended check: every example predicate references fields only through
record.The instrument is a script run against
git show REF:packages/spec/src/data/validation.zod.ts.condition:/when:literal, plus every P-tagged example inside a.describe()string, each unescaped as a reader's paste would be.@objectstack/formula'scollectCelRootIdentifiers, which never reports member or function names. A site is a hit when it has a root other thanrecord, or when it does not parse.condition/whenkey but yielded no site is printed as UNREAD. There were 0 UNREAD lines.08cb6583feec694f0b55(control):100 discount_percent,:257 discount, amounta9fb83ef06(control):100,:257, and:240/:274do not parseAt head, the 26 sites are:
ConditionalValidationSchemadocblock (:378–:532);.describe()examples at :180 (record.amount < 0), :294 (record.end_date < record.start_date) and :546 (record.type == 'enterprise').Every one has roots
["record"]. No remaining hit. A root check cannot see a direction error: the round-0 describe example was record-rooted and still inverted. Direction is covered by the evaluations above, not by this check.The card's original grep
grep -n -E "^\s*\*.*(condition|when)\s*:\s*['\"].*([^=!<>]=[^=]|\bAND\b|\bOR\b)"overvalidation.zod.ts:08cb6583fe: 0 hits (exit 1);a9fb83ef06: lines 240 and 274 (exit 0).It has no Salesforce-side hit either. The pattern anchors on
condition:/when:followed by a quote, which no Salesforce-formula line carries.Where each string ships, and the dist check (spec built from the head
08cb6583fesource)packages/spec/dist/object.zod-Bjpk_moO.d.ts. Each new string appears ×1 there, each old string ×0.condition: 'record.amount > 100000 && record.approval == null'×1..d.ts. The new text appears ×1 in each of the 22 dist JS files that carry it, and in the gitignoredjson-schema/output (14 files). The old exampleP`record.end_date > record.start_date`appears ×0 acrossdist/andjson-schema/.content/docs/references/data/validation.mdx: new ×4, old ×0. There are four rows because the schema renders four times, the same count as before.scriptexample (:100) is in the file-level comment, which is attached to no declaration, so it ships in nodist/file, before or after. It ships in the generatedvalidation.mdx:condition: 'record.discount_percent > 0.40'×1,condition: 'discount_percent > 0.40'×0.Fixtures
No
validation.test.tsfixture copies any of the five strings verbatim, so no test is touched. The fixture namedclose_date_futureatvalidation.test.ts:267is ascriptrule withclose_date < TODAY(), a different string.Local verification (all at head
08cb6583fe)pnpm --workspace-concurrency=2 --filter '@objectstack/formula...' build(spec + formula): VERDICT command-exit 0. It ran atefc0b4aa15, whosepackages/tree equals08cb6583fe(git diff --stat efc0b4aa15 08cb6583fe -- packages/is empty).@objectstack/sdui-parser+@objectstack/lintbuild: exit 0.pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2:Test Files 550 passed (550),Tests 16239 passed | 1 todo (16240).pnpm --filter @objectstack/spec typecheck: exit 0. The test layer is held at its ledger: "53 file(s) / 255 error(s) / 142 pinned signature(s)".pnpm --filter @objectstack/spec check:generated:gen:docs: "All 15 generated artifacts are up to date".node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.listgave "101 derived famil(ies) accounted for — 98 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)". All 98 that ran exited 0, 0 UNRUN. The union was re-derived for the new 3-path change set and re-run in full at this head.check:dual-build-cjs-loadsandcheck:lean-entry-closure. Both refuse with PREREQUISITE NOT MET. They need the whole-workspace build and the@objectstack/objectqlbuild closure. CI's lanes run them.check:skill-examples. It refuses with PREREQUISITE NOT MET because the@objectstack/client-react/@objectstack/client.d.tsbuild closure is absent. That closure is 36 workspace packages, out of this card's local scope. The gate reads only blocks carrying itsos:checkmarker, and neither changed file carries one (grep count 0 in each). CI runs it.check-changeset-no-major. It is PR-scoped and needs apull_requestpayload. Its no-major half is green.pnpm lint:validation.zod.tsis linted (parserOptions.projectundefined). The.changesetfile and the.mdxpage are ignored ("no matching configuration").--format jsonover the three changed paths: 3 results.validation.zod.tshas 0 errors and 0 warnings.eslint.config.mjs, so this diff cannot move any untouched file's verdict.Acceptance notes
MONTH(CloseDate) < MONTH(TODAY()) || YEAR(CloseDate) < YEAR(TODAY())also fires on a future-year date with a smaller month number, for example 2027-01-10 when today is 2026-09-27. The ObjectStack condition implements the example's stated intent, and that row readsfalsein transcript A.validation.test.tscross_field fixtures stay out of scope, per the order. They still carry non-CEL strings such asamount > 1000 AND discount_percent > 50andpercent_a + percent_b + percent_c = 100. They are schema-acceptance fixtures, since the schema does not compile CEL, and are not documented examples.cross_fielddocblock,Date range validations (end_date > start_date)andAmount comparisons (discount < total). They state the requirement each rule enforces. They are notcondition:/when:/ describe predicates, so the extended check does not read them.Authored by the
domain:specseat 2 dispatch, sessionsession_01QcAS3qiYYZNezaxZxaUdMV(https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV).