Repository navigation
Commit e6b7d8c
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.
- **File-surface amendment to claim 5858020673**, recorded by the seat
in that comment rather than as a second claim. It adds three more sites
in `packages/spec/src/data/validation.zod.ts`, text only: the header
`script` example at :100, `cross_field` Example 2 at :257, and the
`.describe()` on `CrossFieldValidationSchema.condition` at :294.
- **Generated consequence:**
`content/docs/references/data/validation.mdx`, regenerated by `pnpm
--filter @objectstack/spec gen:docs`. It is generator output, not
hand-edited. `check:docs` went stale because both the file-header
docblock and the describe render into that page.
- **Commits:**
- round 0: `ec694f0b55` (Examples 1 and 3), unchanged;
- round 1: `efc0b4aa15` (the three sites and the changeset text) and
`08cb6583fe` (the regenerated page).
- There was no rebase, amend or force-push. `origin/main` had not moved
in `validation.zod.ts`, so there was no merge.
- The round-0 rewrites of Examples 1 and 3 are kept byte for byte.
## What changed (every example predicate in `validation.zod.ts`)
| site | before | after |
|:--|:--|:--|
| :100, header `script` example | `discount_percent > 0.40` (bare field)
| `record.discount_percent > 0.40` |
| :240, `cross_field` Example 1 | `MONTH(close_date) >= MONTH(TODAY())
AND YEAR(close_date) >= YEAR(TODAY())` (does not parse, and inverted) |
`date(record.close_date) < addDays(today(), 1 - today().getDate())` |
| :257, `cross_field` Example 2 | `discount > (amount * 0.40)` (bare
fields) | `record.discount > (record.amount * 0.40)` |
| :274, `cross_field` Example 3 | `products = null AND stage =
"closed_won"` (does not parse) | `isBlank(record.products) &&
record.stage == "closed_won"` |
| :294, `.describe()` on `CrossFieldValidationSchema.condition` |
``Predicate (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/spec` patch.
## Semantics: TRUE is the violation
`evaluateRule` dispatches both `script` and `cross_field` to
`checkPredicate` (`packages/objectql/src/validation/rule-validator.ts`),
which returns a `rule_violation` only when `result.value === true` (line
4078 at `ec694f0b55`). So every example's condition must be TRUE on the
record the rule rejects.
- **Example 1 was inverted, and is un-inverted.** The old logic, with
only its syntax repaired, is `true` for a 2026-09-15 close date and
`false` for 2026-08-31 on a 2026-09-27 clock (transcript C).
- **The `.describe()` example was inverted, and is un-inverted.**
`record.end_date > record.start_date` evaluates to `true` on a valid
end-after-start range and `false` on a real violation (transcript E). It
becomes `record.end_date < record.start_date`, and the description now
says a TRUE condition fails validation. The sibling
`ScriptValidationSchema.condition` description already says this.
- **Examples 2 and 3 and the header `script` example 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 are `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`,
`daysBetween(a, b)`, `addDays(d, n)`, `addMonths(d, n)`, `date(x)` and
`datetime(x)`. The stdlib registers no `MONTH` / `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-js` 8.0.0 `lib/functions.js`). `getDate()`
is the 1-based day of the month, read in UTC. The platform accepts the
receiver form. `validate.test.ts` keeps `record.created.getFullYear()`
valid, and `validate.ts` documents the receiver-only names as resolving
but ineligible for the bare-call catalog `CEL_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, and `date(record.close_date)` accepts both a `Field.date`
string and a driver-hydrated `Date`. Example 3 uses the stdlib's
`isBlank`, the stdlib's documented `ISBLANK()` analog, which is TRUE for
null, `''` and `[]`.
## Evaluation transcript (`ExpressionEngine.evaluate`,
`@objectstack/formula` dist built from the spec source at head
`08cb6583fe`)
**Method, the same one PR #20239 used.**
- Each docblock `condition:` literal is extracted from `git show
REF:packages/spec/src/data/validation.zod.ts` byte for byte, with the
comment prefix stripped and the quotes kept.
- It is then unescaped by Node's own parser, `(0, eval)(literal)`, to
get exactly the runtime string a reader's paste produces. It is never
hand-retyped.
- For the `.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 real `P` helper in scope. Its
`source` is what gets evaluated.
- `validateExpression('predicate', src, { scope: 'record', … })`, the
verdict `@objectstack/lint` gives a validation-rule condition, is
printed beside each.
**Clock.** The evaluation clock is fixed through `EvalContext.now`:
`2026-09-27T12:00:00Z`, plus `2027-01-15T12:00:00Z` for 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 what `checkPredicate` passes. A row with a JS `Date` value (the
driver-hydrated shape) renders as its ISO string. None of the round-1
sites depends on `today()`.
### A. Examples 1 and 3 at head `08cb6583fe` (unchanged since round 0)
```text
## Example 1 (close_date_future) — packages/spec/src/data/validation.zod.ts:240
raw docblock literal : 'date(record.close_date) < addDays(today(), 1 - today().getDate())'
runtime string : "date(record.close_date) < addDays(today(), 1 - today().getDate())"
validateExpression('predicate', …, scope:'record') : ok=true errors=[] warnings=0
violating now=2026-09-27T12:00:00.000Z record={"close_date":"2026-08-31"} -> {"ok":true,"value":true} OK
valid now=2026-09-27T12:00:00.000Z record={"close_date":"2026-09-01"} -> {"ok":true,"value":false} OK
valid now=2026-09-27T12:00:00.000Z record={"close_date":"2026-10-15"} -> {"ok":true,"value":false} OK
valid now=2026-09-27T12:00:00.000Z record={"close_date":"2027-01-10"} -> {"ok":true,"value":false} OK
violating now=2026-09-27T12:00:00.000Z record={"close_date":"2026-08-31T00:00:00.000Z"} -> {"ok":true,"value":true} OK
violating now=2027-01-15T12:00:00.000Z record={"close_date":"2026-12-31"} -> {"ok":true,"value":true} OK
valid now=2027-01-15T12:00:00.000Z record={"close_date":"2027-01-01"} -> {"ok":true,"value":false} OK
violating now=REAL-CLOCK record={"close_date":"2026-08-31"} -> {"ok":true,"value":true} OK
valid now=REAL-CLOCK record={"close_date":"2026-09-01"} -> {"ok":true,"value":false} OK
## Example 3 (products_required_for_won) — packages/spec/src/data/validation.zod.ts:274
raw docblock literal : 'isBlank(record.products) && record.stage == "closed_won"'
runtime string : "isBlank(record.products) && record.stage == \"closed_won\""
validateExpression('predicate', …, scope:'record') : ok=true errors=[] warnings=0
violating record={"products":null,"stage":"closed_won"} -> {"ok":true,"value":true} OK
violating record={"products":[],"stage":"closed_won"} -> {"ok":true,"value":true} OK
valid record={"products":["prod_001"],"stage":"closed_won"} -> {"ok":true,"value":false} OK
valid record={"products":null,"stage":"prospecting"} -> {"ok":true,"value":false} OK
```
Re-run at head `08cb6583fe`: 13 of 13 rows OK, 0 mismatches.
### B. Examples 1 and 3 before (BASE `a9fb83ef06`), same extraction
```text
Example 1 raw literal: 'MONTH(close_date) >= MONTH(TODAY()) AND YEAR(close_date) >= YEAR(TODAY())'
validateExpression: ok=false — invalid CEL predicate: Unexpected character: 'D'
evaluate (both records): {"ok":false,"error":{"kind":"parse","message":"Unexpected character: 'D' …"}}
Example 3 raw literal: 'products = null AND stage = "closed_won"'
validateExpression: ok=false — invalid CEL predicate: Unexpected character: =
evaluate (both records): {"ok":false,"error":{"kind":"parse","message":"Unexpected character: = …"}}
```
### C. Example 1's inversion, shown on the old logic with only its
syntax repaired
```text
date(record.close_date).getMonth() >= today().getMonth() && date(record.close_date).getFullYear() >= today().getFullYear()
now=2026-09-27T12:00:00.000Z close_date=2026-09-15 -> {"ok":true,"value":true} (a valid record, refused)
now=2026-09-27T12:00:00.000Z close_date=2026-08-31 -> {"ok":true,"value":false} (an invalid record, accepted)
```
### D. Round 1: the three new sites at head `08cb6583fe`
```text
## Header script example (discount_cannot_exceed_40_percent) — packages/spec/src/data/validation.zod.ts:100
raw docblock literal : 'record.discount_percent > 0.40'
runtime string : "record.discount_percent > 0.40"
validateExpression('predicate', …, scope:'record') : ok=true errors=[] warnings=0
violating record={"discount_percent":0.5} -> {"ok":true,"value":true} OK
valid record={"discount_percent":0.1} -> {"ok":true,"value":false} OK
valid record={"discount_percent":0.4} -> {"ok":true,"value":false} OK
## Example 2 (discount_limit) — packages/spec/src/data/validation.zod.ts:257
raw docblock literal : 'record.discount > (record.amount * 0.40)'
runtime string : "record.discount > (record.amount * 0.40)"
validateExpression('predicate', …, scope:'record') : ok=true errors=[] warnings=0
violating record={"discount":500,"amount":1000} -> {"ok":true,"value":true} OK
valid record={"discount":100,"amount":1000} -> {"ok":true,"value":false} OK
valid record={"discount":400,"amount":1000} -> {"ok":true,"value":false} OK
## CrossFieldValidationSchema.condition .describe() — packages/spec/src/data/validation.zod.ts:294
published text : "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."
copied example : P`record.end_date < record.start_date`
P`…` evaluates to : {"dialect":"cel","source":"record.end_date < record.start_date"}
validateExpression('predicate', …, scope:'record') : ok=true errors=[] warnings=0
violating record={"start_date":"2026-09-10","end_date":"2026-09-01"} -> {"ok":true,"value":true} OK
valid record={"start_date":"2026-09-01","end_date":"2026-09-10"} -> {"ok":true,"value":false} OK
valid record={"start_date":"2026-09-01","end_date":"2026-09-01"} -> {"ok":true,"value":false} OK
violating record={"start_date":"2026-09-10T00:00:00.000Z","end_date":"2026-09-01T00:00:00.000Z"} -> {"ok":true,"value":true} OK
```
10 of 10 rows OK, 0 mismatches.
### E. Round 1: the same three sites before (round-0 head `ec694f0b55`),
same extraction
```text
:100 'discount_percent > 0.40'
validateExpression: ok=false — "bare reference `discount_percent` … Write `record.discount_percent`."
evaluate (all three records): {"ok":false,"error":{"kind":"type","message":"Unknown variable: discount_percent …"}}
:257 'discount > (amount * 0.40)'
validateExpression: ok=false — "bare reference `discount` … Write `record.discount`."
evaluate (all three records): {"ok":false,"error":{"kind":"type","message":"Unknown variable: discount …"}}
:294 published text "Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date`"
validateExpression: ok=true (it parses; the defect is its direction)
violating record={"start_date":"2026-09-10","end_date":"2026-09-01"} -> {"ok":true,"value":false} (a real violation, accepted)
valid record={"start_date":"2026-09-01","end_date":"2026-09-10"} -> {"ok":true,"value":true} (a valid range, refused)
valid record={"start_date":"2026-09-01","end_date":"2026-09-01"} -> {"ok":true,"value":false}
violating Date-valued start 2026-09-10 / end 2026-09-01 -> {"ok":true,"value":false} (accepted)
```
## Enumerating 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`.
- **Population:** every docblock `condition:` / `when:` literal, plus
every P-tagged example inside a `.describe()` string, each unescaped as
a reader's paste would be.
- **Verdict:** roots are read off the parsed CEL AST by
`@objectstack/formula`'s `collectCelRootIdentifiers`, which never
reports member or function names. A site is a hit when it has a root
other than `record`, or when it does not parse.
- **Coverage control:** any line that spells a P-tagged example or a
docblock `condition` / `when` key but yielded no site is printed as
UNREAD. There were 0 UNREAD lines.
| ref | predicates read | hits |
|:--|:--|:--|
| head `08cb6583fe` | 26 | **0** |
| round-0 head `ec694f0b55` (control) | 26 | 2: `:100 discount_percent`,
`:257 discount, amount` |
| BASE `a9fb83ef06` (control) | 26 | 4: `:100`, `:257`, and `:240` /
`:274` do not parse |
At head, the 26 sites are:
- docblock literals at :100, :240, :257, :274, and 19 in the
`ConditionalValidationSchema` docblock (:378–:532);
- P-tagged `.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)"`
over `validation.zod.ts`:
- head `08cb6583fe`: **0 hits** (exit 1);
- control leg, BASE `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
`08cb6583fe` source)
- **Examples 1, 2 and 3** ship in
`packages/spec/dist/object.zod-Bjpk_moO.d.ts`. Each new string appears
×1 there, each old string ×0.
- Untouched control: `condition: 'record.amount > 100000 &&
record.approval == null'` ×1.
- **The describe** ships in the runtime bundles, not in a `.d.ts`. The
new text appears ×1 in each of the 22 dist JS files that carry it, and
in the gitignored `json-schema/` output (14 files). The old example
``P`record.end_date > record.start_date` `` appears ×0 across `dist/`
and `json-schema/`.
- In the generated `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.
- **The header `script` example** (:100) is in the file-level comment,
which is attached to no declaration, so it ships in no `dist/` file,
before or after. It ships in the generated `validation.mdx`: `condition:
'record.discount_percent > 0.40'` ×1, `condition: 'discount_percent >
0.40'` ×0.
## Fixtures
No `validation.test.ts` fixture copies any of the five strings verbatim,
so no test is touched. The fixture named `close_date_future` at
`validation.test.ts:267` is a `script` rule with `close_date < TODAY()`,
a different string.
## Local verification (all at head `08cb6583fe`)
- **Builds.**
- `pnpm --workspace-concurrency=2 --filter '@objectstack/formula...'
build` (spec + formula): VERDICT command-exit 0. It ran at `efc0b4aa15`,
whose `packages/` tree equals `08cb6583fe` (`git diff --stat efc0b4a
08cb658 -- packages/` is empty).
- `@objectstack/sdui-parser` + `@objectstack/lint` build: exit 0.
- **Tests.** `pnpm --filter @objectstack/spec exec vitest run --project
local --maxWorkers=2`: `Test Files 550 passed (550)`, `Tests 16239
passed | 1 todo (16240)`.
- **Typecheck.** `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)".
- **Generated artifacts.** `pnpm --filter @objectstack/spec
check:generated`:
- first run: "1 of 15 artifact(s) stale: content/docs/references/**";
- after `gen:docs`: "All 15 generated artifacts are up to date".
- **Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --ran ran.list` gave "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.
- **NOT MEASURED: `check:dual-build-cjs-loads` and
`check:lean-entry-closure`.** Both refuse with PREREQUISITE NOT MET.
They need the whole-workspace build and the `@objectstack/objectql`
build closure. CI's lanes run them.
- **NOT MEASURED: `check:skill-examples`.** It refuses with PREREQUISITE
NOT MET because the `@objectstack/client-react` / `@objectstack/client`
`.d.ts` build closure is absent. That closure is 36 workspace packages,
out of this card's local scope. The gate reads only blocks carrying its
`os:check` marker, and neither changed file carries one (grep count 0 in
each). CI runs it.
- **NOT MEASURED locally: the Clause-② level axis of
`check-changeset-no-major`.** It is PR-scoped and needs a `pull_request`
payload. Its no-major half is green.
- **eslint, a proven narrowing** of the CI-owned `pnpm lint`:
- Population, read from eslint's own config: `validation.zod.ts` is
linted (`parserOptions.project` undefined). The `.changeset` file and
the `.mdx` page are ignored ("no matching configuration").
- `--format json` over the three changed paths: 3 results.
`validation.zod.ts` has 0 errors and 0 warnings.
- Invariance: type-aware linting is not enabled anywhere in
`eslint.config.mjs`, so this diff cannot move any untouched file's
verdict.
## Acceptance notes
- **The Salesforce formula beside Example 1 is kept as it is, per the
card, but it is not a strict "before the current month" test.**
`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 reads `false` in
transcript A.
- **The parse-only `validation.test.ts` cross_field fixtures stay out of
scope, per the order.** They still carry non-CEL strings such as `amount
> 1000 AND discount_percent > 50` and `percent_a + percent_b + percent_c
= 100`. They are schema-acceptance fixtures, since the schema does not
compile CEL, and are not documented examples.
- **Left as prose on purpose:** the "Use Cases" bullets in the
`cross_field` docblock, `Date range validations (end_date > start_date)`
and `Amount comparisons (discount < total)`. They state the requirement
each rule enforces. They are not `condition:` / `when:` / describe
predicates, so the extended check does not read them.
- **The PR title still reads "the two cross_field … conditions".** This
round's single write is a body-only edit, so the title is left for the
seat.
Authored by the `domain:spec` seat 2 dispatch, session
`session_01QcAS3qiYYZNezaxZxaUdMV`
(https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV).
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae8e3ca commit e6b7d8c
3 files changed
Lines changed: 25 additions & 10 deletions
File tree
- .changeset
- content/docs/references/data
- packages/spec/src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
99 | | - | |
| 99 | + | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| |||
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
| |||
360 | 360 | | |
361 | 361 | | |
362 | 362 | | |
363 | | - | |
| 363 | + | |
364 | 364 | | |
365 | 365 | | |
366 | 366 | | |
| |||
413 | 413 | | |
414 | 414 | | |
415 | 415 | | |
416 | | - | |
| 416 | + | |
417 | 417 | | |
418 | 418 | | |
419 | 419 | | |
| |||
658 | 658 | | |
659 | 659 | | |
660 | 660 | | |
661 | | - | |
| 661 | + | |
662 | 662 | | |
663 | 663 | | |
664 | 664 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
100 | | - | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| |||
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
240 | | - | |
| 240 | + | |
241 | 241 | | |
242 | 242 | | |
243 | 243 | | |
| |||
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | | - | |
| 257 | + | |
258 | 258 | | |
259 | 259 | | |
260 | 260 | | |
| |||
271 | 271 | | |
272 | 272 | | |
273 | 273 | | |
274 | | - | |
| 274 | + | |
275 | 275 | | |
276 | 276 | | |
277 | 277 | | |
| |||
291 | 291 | | |
292 | 292 | | |
293 | 293 | | |
294 | | - | |
| 294 | + | |
295 | 295 | | |
296 | 296 | | |
297 | 297 | | |
| |||
0 commit comments