Repository navigation
fix(spec): rewrite ConditionalValidationSchema TSDoc examples in evaluable CEL (#20026) - #20239
Conversation
…uable CEL (#20026) Every predicate in the TSDoc docblock above ConditionalValidationSchema (all seven Use Cases plus the ObjectStack side of the Salesforce Pattern Comparison) used `=` (or AND/NOT/REGEX word-form operators) for comparison instead of CEL's `==`/`&&`/`!`/`matches(...)`, and two named a bare field with no `record.` root. Every one is now valid CEL that ExpressionEngine.evaluate accepts, verified true-branch and false-branch for each rewritten predicate. The Salesforce-formula half of the comparison block is left untouched — it documents Salesforce's own syntax, not CEL. validation.test.ts's ConditionalValidationSchema fixtures that copy these strings verbatim move with them (parse-only fixtures; no behaviour change). Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
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): |
Contract reviewServed-tier: 67/67 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL |
…s, 10 more fixtures moved (#20026) REWORK round 1 per PR #20239's contract-review FAIL (comment 5856311938): 1. The matches() example's regex `"^\d{2}-\d{7}$"` parses fine as raw comment text, but a reader who pastes the TSDoc literal into a real .ts string literal gets one level of JS unescaping the comment never applied, so `\\d` in the comment becomes `\d` at runtime and cel-js refuses it (Invalid escape sequence: \d). Rewritten to the escape-free character class `[0-9]`, which needs no escaping at any copy depth. 2. Corrected two false changeset sentences: cel-js does not have an assignment operator to parse `=` as the start of (it rejects a lone `=` outright; CEL equality is `==`), and the "all 19 evaluate" / matches table row claims were not true before item 1's fix. 3. Ten more validation.test.ts fixtures whose surrounding test name and message text verbatim-copy the docblock (Use Cases 4, 5, 6, 7) move with the examples, matching the same criterion already applied to the first seven. Every predicate this round is re-verified by extracting its literal text from the source file byte for byte and handing it to Node's own parser to unescape — never hand-retyped — before evaluating through ExpressionEngine.evaluate, so the transcript matches what a reader's copy-paste actually produces. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 140/140 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…zod.ts in evaluable CEL, un-inverting two (objectstack-ai#20252) (objectstack-ai#20276) Fixes objectstack-ai#20252 Clause-②: no ## Rework round 1: what changed since the first revision The order of record is the seat's comment 5858340569 on objectstack-ai#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 objectstack-ai#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>
Fixes #20026
Clause-②: no
REWORK round 1 — what changed since the last revision
Per the contract-review FAIL on this PR (comment 5856311938 on this PR, order of record 5856314403 on #20026), three things were wrong and are fixed in this round:
matches()example did not parse when copied. The docblock's"^\d{2}-\d{7}$"reads fine as raw comment text (comments are not JS-escape-processed), but a reader who pastes that literal into a real.tsstring literal gets ONE level of JS unescaping the comment text never went through:\\din the comment becomes\dat runtime, and cel-js refuses\das an invalid escape sequence (it only accepts\\ ? " ' \a b f n r t v,\u,\x, octal). Rewritten to the escape-free character class"^[0-9]{2}-[0-9]{7}$"`, which needs no escaping at any copy depth. The previous revision's transcript row for this predicate, and its "20/20 …" claim, were not measured on the copied literal — see the corrected measurement methodology below.@marcbachmann/cel-jsparses=as the start of an assignment" → cel-js has no assignment operator; it rejects a lone=as an unexpected character, and CEL equality is==. The "both branches evaluate as expected for all 19" claim and thematchestable row are now true, after item 1.validation.test.tsfixtures were left behind under the already-widened scope. Each one's surrounding test name and message text verbatim-copy a Use Case 4/5/6/7 docblock predicate the same way the original seven did, so the PR's own stated criterion for moving a fixture applies to them too:country = "US"/state = "CA"/tax_id = null(nested_validation, Use Case 5 —tax_idnow the full escape-freerecord.tax_id == null || !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")),is_taxable = true/tax_code = null(tax_validation, Use Case 6 —tax_codenow the fullrecord.tax_code == null || record.tax_code == ''),region = "EU"/gdpr_consent_given = false/tos_accepted = false(regional_validation, Use Case 4),user_role = "manager"/approval_amount > 50000(role_based_validation, Use Case 7).Nothing else changed: the other 18 predicates, the Salesforce-formula side of the comparison, and the
validation.zod.ts-TSDoc-only /patch/Clause-②: noshape all passed review and are untouched this round.What changed overall (both rounds)
Every example predicate in the
ConditionalValidationSchemaTSDoc docblock (packages/spec/src/data/validation.zod.ts, ~lines 362-537) is rewritten in the CEL the evaluator actually accepts:=for comparison, orAND/NOT/REGEX(...)word-form operators — cel-js rejects a lone=as an unexpected character (CEL equality is==), and CEL has noAND/OR/NOTkeyword form.order_total > 10000,approval_amount > 50000) named a bare field with norecord.root — an unknown-variable type error.REGEX(tax_id, "…")is rewritten to the evaluator's registered stdlib functionmatches(record.tax_id, "…")(packages/formula/src/stdlib.ts:361), using the escape-free character class[0-9]rather than\dfor the reason in item 1 above.IF(ISPICKVAL(...), AND(...), FALSE)) is deliberately untouched — it documents Salesforce's own formula syntax, not CEL.packages/spec/src/data/validation.test.ts'sConditionalValidationSchemafixtures whose surrounding test name/message text verbatim-copy a docblock predicate move with it (parse-only —ValidationRuleSchema.parse(...).not.toThrow(), no CEL evaluation, so no behaviour changes): the six exact copies of Use Cases 1-3's original seven strings;manager_approval = null→record.manager_approval == null(order_value_validation, Use Case 3, one field-name spelling apart); and, from this round, the ten listed above. 17 fixture strings moved in total.No schema, behaviour, or public export changes — TSDoc text only, in the same two files the strings already lived in, plus the changeset.
Evaluation transcript — corrected methodology (
ExpressionEngine.evaluate,@objectstack/formula)The previous transcript was measured on hand-typed strings that were logically equivalent to the CEL source but never simulated what a reader actually gets by pasting the TSDoc text into real code. This transcript is measured differently: each
when:/condition:literal is extracted fromvalidation.zod.tsbyte for byte (comment prefix stripped, quote characters kept), then handed to Node's own parser (eval) to apply the exact JS string-literal unescaping a reader's paste would trigger — never hand-retyped. That runtime string is what gets passed toExpressionEngine.evaluate."record.account_type == 'enterprise'"record.account_type == 'enterprise'{"account_type":"enterprise"}{"ok":true,"value":true}OK{"account_type":"smb"}{"ok":true,"value":false}OK'record.approval_status == null'record.approval_status == null{"approval_status":null}{"ok":true,"value":true}OK{"approval_status":"approved"}{"ok":true,"value":false}OK'record.requires_shipping == true'record.requires_shipping == true{"requires_shipping":true}{"ok":true,"value":true}OK{"requires_shipping":false}{"ok":true,"value":false}OK"record.shipping_address == null || record.shipping_address == ''"record.shipping_address == null || record.shipping_address == ''{"shipping_address":null}{"ok":true,"value":true}OK{"shipping_address":"123 Main St"}{"ok":true,"value":false}OK'record.order_total > 10000'record.order_total > 10000{"order_total":15000}{"ok":true,"value":true}OK{"order_total":5000}{"ok":true,"value":false}OK'record.manager_approval_id == null'record.manager_approval_id == null{"manager_approval_id":null}{"ok":true,"value":true}OK{"manager_approval_id":"user_123"}{"ok":true,"value":false}OK'record.payment_method == null'record.payment_method == null{"payment_method":null}{"ok":true,"value":true}OK{"payment_method":"credit_card"}{"ok":true,"value":false}OK"record.region == 'EU'"record.region == 'EU'{"region":"EU"}{"ok":true,"value":true}OK{"region":"US"}{"ok":true,"value":false}OK'record.gdpr_consent_given == false'record.gdpr_consent_given == false{"gdpr_consent_given":false}{"ok":true,"value":true}OK{"gdpr_consent_given":true}{"ok":true,"value":false}OK'record.tos_accepted == false'record.tos_accepted == false{"tos_accepted":false}{"ok":true,"value":true}OK{"tos_accepted":true}{"ok":true,"value":false}OK"record.country == 'US'"record.country == 'US'{"country":"US"}{"ok":true,"value":true}OK{"country":"CA"}{"ok":true,"value":false}OK"record.state == 'CA'"record.state == 'CA'{"state":"CA"}{"ok":true,"value":true}OK{"state":"NY"}{"ok":true,"value":false}OK'record.tax_id == null || !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")'record.tax_id == null || !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$"){"tax_id":null}{"ok":true,"value":true}OK{"tax_id":"12-3456789"}{"ok":true,"value":false}OK'record.is_taxable == true'record.is_taxable == true{"is_taxable":true}{"ok":true,"value":true}OK{"is_taxable":false}{"ok":true,"value":false}OK"record.tax_code == null || record.tax_code == ''"record.tax_code == null || record.tax_code == ''{"tax_code":null}{"ok":true,"value":true}OK{"tax_code":"VAT-1"}{"ok":true,"value":false}OK"record.user_role == 'manager'"record.user_role == 'manager'{"user_role":"manager"}{"ok":true,"value":true}OK{"user_role":"rep"}{"ok":true,"value":false}OK'record.approval_amount > 50000'record.approval_amount > 50000{"approval_amount":75000}{"ok":true,"value":true}OK{"approval_amount":10000}{"ok":true,"value":false}OK"record.type == 'enterprise'"record.type == 'enterprise'{"type":"enterprise"}{"ok":true,"value":true}OK{"type":"smb"}{"ok":true,"value":false}OK'record.amount > 100000 && record.approval == null'record.amount > 100000 && record.approval == null{"amount":150000,"approval":null}{"ok":true,"value":true}OK{"amount":150000,"approval":"user_1"}{"ok":true,"value":false}OKAll 19 predicates, extracted from the source and evaluated as the runtime string a copy-paste produces: true-branch and false-branch both evaluate as expected.
The
matches()site specifically — old (broken) literal vs. new (fixed) literal, same simulationThe old literal reproduces, exactly, the
Invalid escape sequence: \dfailure the review measured; the new literal evaluates correctly on all four probes.dist confirmation (H2)
Rebuilt
@objectstack/specafter this round's edit.grep -conpackages/spec/dist/object.zod-*.d.ts: every one of the 19 new strings present exactly once; every one of the 19 original broken strings absent (checked both\descape variants of thematches()predicate —\\dand\d— zero occurrences of either).Local verification
pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/formula build— both build clean.pnpm --filter @objectstack/spec check:generated— 15/15 generated artifacts up to date.pnpm --filter @objectstack/spec exec vitest run src/data/validation.test.ts— 72/72 tests pass.pnpm --filter @objectstack/spec run typecheck— clean; the 255 pre-existing debt errors are the shrink-only ledger, untouched by this diff.check-empty-changeset,check-adr-0087-registration,check-changeset-no-major) — green against the corrected changeset.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list: 82 derived families, 79 run (all green), 3 NOT-MEASURED (check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt— each refuses its own prerequisite without the full workspace build closure, out of this card's local scope, left to CI's Build Core lane). 0 unrun.Acceptance notes
content/docs/protocol/objectql/index.mdx:186(condition: "account_type = 'Enterprise' AND contract_value = null") carries the same non-CEL spelling in an unrelated docs page; already recorded, unresolved, indocs/audits/2026-06-handwritten-docs-accuracy-followups.md. Confirmed still out of scope by this round's review; left untouched.Generated by Claude Code