Skip to content

fix(spec): rewrite the cross_field and script examples in validation.zod.ts in evaluable CEL, un-inverting two (#20252) - #20276

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20252-cross-field-salesforce-examples-cel
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20252-cross-field-salesforce-examples-cel

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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)

## 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

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

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

## 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

: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 efc0b4aa15 08cb6583fe -- 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).

…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
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hooks.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/automation/index.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/automation/workflows.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/concepts/architecture.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/analytics.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/seed-data.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/validation.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectql/state-machine.mdx (via closed_won (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectui/concept.mdx (via closed_won (literal, a string literal in a comment on a changed line))
What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from dde7145b95c0abf842317e3a846fe8778089f677 — the merge of head 08cb6583fe8787bfdb1555a79a2500418be1d925 into base 17bd31877109b7cc692e7e54c4fe39f82a5c32d5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… 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
@objectstack-fleet objectstack-fleet Bot changed the title fix(spec): rewrite the two cross_field Salesforce-example conditions in evaluable CEL (#20252) fix(spec): rewrite the cross_field and script examples in validation.zod.ts in evaluable CEL, un-inverting two (#20252) Sep 27, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 73/73 CONTRACT_REVIEW_TIER
Head-sha: 08cb6583fe8787bfdb1555a79a2500418be1d925

① Derived judgments

  1. Semantics. packages/objectql/src/validation/rule-validator.ts checkPredicate (:4026) wraps the condition via toExpression (:2858, bare string → dialect: 'cel'), calls ExpressionEngine.evaluate(expr, { record, previous }) with no now/timezone (:4059), returns rule_violation only when result.value === true (:4078), and turns a fault into a fail-closed refusal (:4064-4075). TRUE is the violation.
  2. Five predicates, probed as copied. Literals cut byte-for-byte from the head blob (:100,:240,:257,:274,:294), JS-unescaped by node; the P example cut from the unescaped describe text and run as a tagged template with the spec dist's real P → {dialect:'cel', source:'record.end_date < record.start_date'}. Evaluated through a built @objectstack/formula dist whose source is byte-identical to head (git diff 1fa8251067 08cb6583fe -- packages/formula/src empty). All five: validateExpression('predicate', src, {scope:'record'}) ok=true, 0 errors, 0 warnings.
    • Ex1. today, addDays, date are stdlib (packages/formula/src/stdlib.ts:247,324, date beside addMonths); getDate() is cel-js's Timestamp receiver, 1-based, read in UTC (cel-js/lib/functions.js:476; the 0-based one is getDayOfMonth, :478; probe 27 vs 26 on 2026-09-27). today() is the reference-tz calendar day as UTC midnight (stdlib.ts:59-68), so addDays(today(), 1 - today().getDate()) is the first of that month at UTC midnight and date('YYYY-MM-DD') is UTC midnight too. Probe, 2026-09-27 clock: 08-31 → true; 09-01, 09-30, 2027-01-10 → false; Date-valued 08-31 → true. Year boundary (2027-01-15 clock): 2026-12-31 → true, 2027-01-01 → false. Day-1 clock (2026-03-01T00:30Z): 02-28 → true, 03-01 → false. Day-31 clock: 03-01 → false, 02-28 → true. Real clock: 2000-01-01 → true, 2999-01-01 → false. Timezone: checkPredicate passes none, so the engine uses UTC wall clock (cel-engine.ts:1785,1793); with timezone:'America/Los_Angeles' at 2026-10-01T03:00Z (Sep 30 in LA) 09-01 → false, 08-31 → true, so tz-day and stored date agree by construction. Non-blocking edges: close_date: null → false; a missing key faults No such key, the file-wide behaviour of every record.x example.
    • Ex3. isBlank is the stdlib's (stdlib.ts:260-268): true for null/undefined, '', []. Probe: null/closed_won → true, []/closed_won → true, ['prod_001']/closed_won → false, null/prospecting → false.
    • :257 500/1000 → true, 400/1000 → false, 100/1000 → false. :100 0.5 → true, 0.4 → false, 0.1 → false. Direction unchanged and matches each Salesforce formula and message.
    • Describe. end 09-01/start 09-10 → true; end 09-10/start 09-01 → false; equal → false; Date-valued violating → true. The wording "If TRUE, validation fails — the condition describes the violation" is what :4078 does. Control: old record.end_date > record.start_date → true on the valid range (inverted).
    • Controls: old Ex1, Ex3 and :257 strings fault at parse/type as claimed.
  3. Salesforce side untouched. The diff changes only the five ObjectStack lines; MONTH(CloseDate) < …, Discount__c > (Amount__c * 0.40), ISBLANK(Products__c) && ISPICKVAL(…), Discount_Percent__c > 0.40 are byte-identical. Scan of head validation.zod.ts: 22 docblock condition:/when: literals (:100,:240,:257,:274,:378-:532) and 3 P-tagged describes (:180,:294,:546), all record.-rooted CEL; none bare or non-CEL.
  4. TSDoc and describe only. validation.zod.ts diff is 5 lines: 4 inside comments plus the one .describe() string literal; no code token changed. validation.mdx diff is exactly the 4 describe renders and the 1 header render. check:docs (build-docs.ts --check) runs at lint.yml:5564 inside the green Lint & Repo Gates check, so the page matches the generator.
  5. Changeset. Each sentence checked against the diff and probe: old strings quoted verbatim, the parse faults (AND, lone =, bare fields) and both inversions reproduced, "only stdlib date(), today(), addDays() plus CEL's built-in getDate()" true, "no schema, behaviour or export change" true. patch is right; readClause2Line → {kind:'declared', value:'no'}.

CI on the head: 49 check-runs, 42 success, 7 skipped, 0 failures (all three Check Changeset runs succeeded; nothing pending). Minor, non-blocking: the body's Acceptance note that the title "still reads 'the two cross_field …'" is stale; the live title already says "cross_field and script examples … un-inverting two".

② Semver level

@objectstack/spec patch, Clause-②: no — correct. TSDoc plus one .describe() text; EvaluatedExpressionInputSchema and every accepted shape unchanged, so no accept-set widening or narrowing and no public-surface change. The describe only alters the JSON-schema description and the generated reference page.

③ Boundary flags

  • Salesforce Ex1 future-year quirk: real (MONTH(CloseDate) < MONTH(TODAY()) fires on 2027-01-10 from a 2026-09 clock); pinned unchanged per the card; the ObjectStack condition implements the stated intent (probe 2027-01-10 → false).
  • Parse-only validation.test.ts fixtures (:308 end_date > start_date, :320 … AND …, :381 … = 100): schema-acceptance fixtures, not documented examples; out of scope per triage, not blocking.
  • Use-Cases bullets end_date > start_date, discount < total (:221-222): stated as requirements, so their direction is the opposite of a condition; mildly misleading under TRUE-is-violation, but prose, not condition:, and the describe line below now states the direction. Out of scope; a one-line follow-up ("write the violation as the condition") would close it.

Implemented-by: claude/issue-20252-cross-field-salesforce-examples-cel
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 18:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit e6b7d8c Sep 27, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20252-cross-field-salesforce-examples-cel branch September 27, 2026 19:08
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 protocol:data size/s tooling

Projects

None yet

2 participants