Skip to content

Commit 073b1b2

Browse files
fix(objectql,lint)!: a conditional rule's when reads one hop through a reference, hydrated like a condition (#24650)
Fixes #24512 Clause-②: no (narrowing) (the save door newly refuses a `when` that reads a second hop or an undeclared related column, which today saves and then refuses every write it guards; a narrowing owes one `CONTRACT_REVIEW_TIER` review of record before the queue) Dispatched by the `domain:engine` seat 2 PM (claim 6275835421, `session_01AHeUBbe6S3JRawcpbLejXD`). The developer ran as that session's subagent. The route is triage 6265543836's: hydrate `when`. ## What changes **Engine (`packages/objectql`, `validation/rule-validator.ts`).** No change in `engine.ts`: the engine's related read already takes its input from the collector below. - `collectPredicateRelationships` now collects a `conditional` rule's `when`, at every depth, beside the rules it wraps. So the engine preloads a `when`'s hop through the same loader as a condition's. It uses the same system authority, the same projection, and PR #24508's per-rule handling of undeclared columns. - `checkConditional` evaluates `when` on the scope `resolveTraversalScope` decides, the function `checkPredicate` calls. The hydrated copy belongs to the `when` alone. The branch gets the untouched context and decides its own scope, so a branch comparing the stored id still sees the id. - The scope can refuse a `when`: a second hop, a reference read both through it and as a value, an undeclared related column, or an empty, unresolved or unreadable reference. That refusal goes through one new builder, `traversalScopeRefusal`, which `checkPredicate` now uses too. It answers `code: 'rule_violation'` with `constraint: { rule, reason: 'unevaluable', fault }`. `checkPredicate`'s message and log line are byte-identical to before. - A `when` that faults on a delete's reference cleanup gets `referentialClearRefusal`'s text, as a condition does. **Lint (`packages/lint`, `validate-expressions.ts`).** This is the cross-domain rider the claim declares to `domain:spec`. - The top-level `when` and every nested predicate pass `traversalHydration`. The existing hop checks now judge a `when`: second hop, both-ways read, stored-metadata-body hop, and undeclared hop column. They run under the existing `EXPRESSION_INVALID` rule and are located at the `when` slot. No new rule, code or message. - `RulePredicate.slot` lost its only reader and is removed. ## Measurement ### Write time The fixture is the real `ObjectQL` engine over an in-memory driver double. A `conditional` has `when` W, `then` and `otherwise` rules that both refuse an amount over 10, and the insert has `amount: 50`. So "`then`" or "`otherwise`" names the branch that was judged. - **Before:** `rule-validator.ts` restored from `1e8b6284c` (this PR's base); `engine.ts` is unchanged by this PR. - **After:** this branch. - The probe was a one-off file. It is not committed. | W | empty reference, before | empty reference, after | partner account, before | partner account, after | |---|---|---|---|---| | `record.account.type == 'partner'` | refused, `No such key: type` | refused, "no single related record" (names the guard) | refused, `No such key: type` | **`then`** | | `record.account != null && record.account.type == 'partner'` | `otherwise` | refused, both-ways read | refused, `No such key: type` | refused, both-ways read | | `record.account.?type.orValue('') == 'partner'` | `otherwise` | refused, "no single related record" | `otherwise` (wrong branch, silently) | **`then`** | | `has(record.account.type) && record.account.type == 'partner'` | `otherwise` | refused, "no single related record" | `otherwise` (wrong branch, silently) | **`then`** | | `record.account.owner.region == 'x'` | refused, `No such key: owner` | refused, more than one hop | refused, `No such key: owner` | refused, more than one hop | | `record.account.region == 'x'` (undeclared) | refused, `No such key: region` | refused, `'crm_account' declares no 'region'` | refused, `No such key: region` | refused, `'crm_account' declares no 'region'` | | `record.account != null` (the guard) | `otherwise` | `otherwise` | `then` | `then` | The "before" refusal text for every row was "The when-predicate reads 'type', which this object does not declare — fix the rule's condition, or declare the field". That sentence points the author at the wrong object. **The runtime narrowing.** Rows 2 to 4, empty-reference column: a write that `otherwise` judged before is refused now. The `when` is judged as a `condition` reading through an empty reference has been judged since hydration landed for conditions: the verdict does not depend on which CEL operator is written. The refusal names the guard, which is a separate `when: record.account != null` with this rule nested as its `then`. This was not in the claim's measurement assumptions; see the deviations. ### Save door and build These run `validateStackExpressions`, the pass `os validate`, `os build` and the object save door run. The fixtures pass `ObjectSchema` / `ObjectStackSchema` validation. | `when` | before (`1e8b6284c`) | after | |---|---|---| | `record.account.region == 'north'` (target declares no `region`) | 0 errors | 1 `EXPRESSION_INVALID` at `validation 'gate' when`, "unknown field `region` on `hop_account`" | | `record.account.owner.email == 'x'` | 0 errors | 1, "read through more than one relationship hop" | | `record.account != null && record.account.status == 'active'` | 0 errors | 1, "read BOTH through the relationship", naming the guard | | `record.account.status == 'active'` (declared) | 0 errors | 0 errors | | `record.account != null` (the guard) | 0 errors | 0 errors | The "before" column is the new pins run on the unfixed tree: three red, with `expected [] to deeply equal [ "object 'hop_order' · validation 'gate' when" ]`. The stored-metadata-body hop takes the same opt-in and is not separately pinned. **Exposure in the shipped tree.** Measured at `1e8b6284c` with `git grep` outside tests: zero `when` reads through a reference. The control is the same regex with `condition` in place of `when`, which finds 8 lines, among them `examples/app-showcase/src/data/objects/invoice.object.ts:191`. The only `when` in `examples/` is `record.status == 'churned'`. ## Pins - `packages/objectql/src/validation/rule-relationship-traversal.test.ts`, `[#24512]` block, on the evaluator: - the collector names a top-level and a nested `when` hop, and nothing for the guard spelling; - `then` and `otherwise` are each picked on the related row, at the top level and nested; - the branch keeps the stored id, and the payload is not mutated; - the guard control; - a second hop and an undeclared column are refused (`code` + `constraint.rule` / `reason` / `fault`) while a sibling keeps its own verdict; - an empty reference names the guard; - a delete's cleanup is named. - `packages/objectql/src/engine-predicate-relationship.test.ts`, `[#24512]` block, on the real engine: - both branches on real related rows, with the `VALIDATION_FAILED` envelope and per-field `code`; - one system read projecting `['id', 'type']`; - a sibling `when` reading an undeclared column refuses alone and never enters the read. - `packages/lint/src/validate-expressions.rule-one-hop.test.ts`, `[#24512]` block, on the build: the three refusals at the top-level and nested `when` slots, the declared read admitted, and the guard control. - `packages/lint/src/runtime-gate.object-validation-writes.test.ts`: - The `#22042` per-slot pin used to assert the opt-out ("OFF for a nested `when`"). It is rewritten to assert the opt-in at both slots, top level and nested. - A new `#24512` block at the save door: a target from another package, a nested `when`, and the control, all asserting `rule: EXPRESSION_INVALID`. ## Ablations Each leg went through `scripts/ablation-replace.mjs`. The anchor hit once and the mutation landed (blob changed). The file was restored to the HEAD blob with `git diff HEAD` empty. The subjects are imported by relative path from `src/`, so no `dist/` is involved. - **A** — `addFrom(r.when)` deleted from the collector: 5 red, the two collector pins and the three engine pins. Pin (a) on the real engine shows the card's defect: `Validation rule 'partner_gate' could not be evaluated (runtime: No such key: type)`. - **B** — `checkConditional` evaluating `ctx.merged` instead of the hydrated copy: 6 red (both branch pins, nested, branch-isolation, engine branch pin, engine sibling pin). - **C** — the `when` scope refusal replaced by `return null`: 4 red (second hop, undeclared, empty reference, engine sibling). - **D** — the `when` cleanup text replaced by `null`: 1 red (the cleanup pin). - **E1 / E2** — the lint opt-in reverted at the top-level `when` / the nested `when`: 5 red / 4 red. ## Verification Final head `a8614e4116`, a merge of `origin/main` `14ccb1da9`. The incoming commits are `service-automation` and a `spec/ui` export, and they touch none of this diff's files. - `pnpm install --frozen-lockfile`: exit 0. `pnpm turbo run build --concurrency=2`: 77/77 tasks, exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --project local`: 447 files, 8432 tests passed, exit 0. - `pnpm --filter @objectstack/lint exec vitest run`: 148 files, 6932 tests passed, exit 0. - `typecheck`: objectql exit 0 and lint exit 0. Both include their `check:test-typecheck` leg. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 70 commands. All 70 ran on `a8614e4116` and exited 0. `--ran` reports 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. - An earlier battery on `e5a2eecb0` had three non-zero exits, each resolved before the final run: - `check:error-code-casing` (exit 1): the new test literals asserted `code: 'rule_violation'` without the `field` key that marks them field-addressed. Fixed in `9b80c99f6`. - `check:engine-split-ratio` (exit 2): the shallow-clone refusal. Deepened with `git fetch --shallow-since=2026-07-14`. - `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET): missing dists. Ran after the full build. - `test:repo` was not run locally. It is CI-owned, and this diff touches no `repo`-project test. ## Changeset `.changeset/24512-conditional-when-hydration.md`: - `@objectstack/objectql` `minor` and `@objectstack/lint` `minor`; - `fix(objectql,lint)!:`, BREAKING (an accept-set narrowing at the save door and at write time), `Clause-②: no (narrowing)`; - ADR-0087 marker `not-required (no-migration-prescription)`, accepted by `check-adr-0087-registration`; - FROM / TO lines for each package and a "What to do". ## Deviations from the dispatch - **`@objectstack/objectql` is `minor` BREAKING, not `patch`.** The write-time probe above measured a narrowing on that face. A write on an empty reference under a null-safe `when` (`.?`, `has()`, or an inline `!= null` test of the same reference) was judged by `otherwise` before. Now it is refused. Precedent for an objectql write-time narrowing: the `8d8f965` entry, `Clause-②: no (narrowing)`, minor. - **The triage premise holds in part.** "Hydration admits nothing new at the door, and it makes a write that is refused today evaluate" is true for the plain spelling. It is not the whole effect: - the null-safe spellings silently took the wrong branch on every set reference before (now corrected); - they took `otherwise` on an empty reference (now refused, as above). - **Rulings kept.** The route is unchanged: hydrate `when` as `checkPredicate` hydrates a condition, with the same helpers and no new resolver. - **No dogfood pin.** A booted pin is outside the claim's file surface (`packages/qa/dogfood` is `domain:cli`). The envelope is asserted at the engine (`code: 'VALIDATION_FAILED'` plus per-field `code` and `constraint`) and at the save-door pass (`rule: 'expression-invalid'`). The HTTP statuses (`400` on the data door, `422 INVALID_METADATA` on the save door) are the existing mappings and were not re-measured on a booted stack here. ## Acceptance notes - A `when` now follows a condition's rules for an empty reference. No single-expression spelling skips a rule while the reference is empty. The guard is its own `when`, as the refusal says. This is the platform's existing stance for conditions, now applied to `when`. - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts` carries no `when`-hop pin. The save-door pass is pinned in lint's runtime-gate test, which is the body the door runs. Not added here. - `pnpm --filter @objectstack/core test:repo` was not run. `packages/core`'s per-file census already has rows for both edited source files, and CI's `Test Core` runs it. --- _Generated by [Claude Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73d4cc7 commit 073b1b2

7 files changed

Lines changed: 498 additions & 55 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(objectql,lint)!: a `conditional` validation rule's `when` reads one hop through a reference, the way a rule's `condition` does
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape, export or authorable spelling is added, removed or renamed, so there is nothing for `objectstack migrate meta` to rewrite. A rule keeps its authored shape. What changes is how a `conditional` rule's `when` is judged: the engine now serves it one hop through a reference (it read the bare id before), and the validation-rule pass judges its hops as it judges a `condition`'s. Both narrow: the save door refuses four `when` shapes it accepted, and the runtime refuses a write it judged on an empty reference under a null-safe `when`. -->
11+
12+
**BREAKING** (an accept-set narrowing, at the save door and at write time), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
A `conditional` rule's `when` that reads through a lookup (`record.account.status == 'active'`) now works. The engine loads the related row for it, as it does for a `script` or `cross_field` rule's `condition`, and `when` picks `then` or `otherwise` on that row. Before, `when` met the stored id, so the read faulted and every write the rule guarded was refused with "The when-predicate reads 'status', which this object does not declare". A `when` nested in a `then` or `otherwise` is served the same way. A sibling rule keeps its own verdict when a `when` cannot be evaluated.
15+
16+
`@objectstack/objectql`, at write time:
17+
18+
- FROM: `when: record.account.status == 'active'` refused every write it guarded as unevaluable. TO: it is judged on the related row.
19+
- FROM: a null-safe spelling (`record.account.?status.orValue('') == 'active'`, `has(record.account.status) && …`) read the stored id and silently took `otherwise` for every account. TO: it is judged on the related row.
20+
- FROM: on a write whose reference is empty, a null-safe `when`, or one that tests `record.account != null` in the same expression, evaluated to false and judged `otherwise`. TO: the write is refused (`400 VALIDATION_FAILED`, `_record` `rule_violation`), as a `condition` reading through an empty reference is. The refusal names the guard.
21+
- A `when` that reads a second hop, a field the related object does not declare, or the reference both through it and as a value is refused with a sentence that names the related object and field, instead of the generic one. A `when` that faults on a delete's reference cleanup names the delete, as a `condition` does.
22+
- The guard spelling `when: record.account != null` reads the stored id, as before.
23+
24+
`@objectstack/lint`, where a rule is written: `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`) run the same pass, under the existing `EXPRESSION_INVALID` rule. They now judge a `when`, top level and nested, with the checks a `condition` gets. Each of these saved before (`200`) and is refused now (`422 INVALID_METADATA` at the save door), located at the `when` slot. At run time each refused every write it guarded, and the third also took `otherwise` on an empty reference:
25+
26+
- FROM: a second hop (`record.account.owner.email != null`), saved. TO: refused.
27+
- FROM: a field the target does not declare (`record.account.region == 'north'`), saved. TO: refused, "unknown field `region` on `<target>`".
28+
- FROM: the reference read both through it and as a value (`record.account != null && record.account.status == 'active'`), saved. TO: refused, naming the guard.
29+
- FROM: a hop into a stored-metadata-body table, saved. TO: refused.
30+
31+
What to do. To skip a rule while the reference is empty, keep the reference test in its own `when` and nest the rule that reads through it: a `conditional` whose `when` is `record.account != null`, with your `conditional` as its `then`. To refuse an empty reference, make the field `required: true`. Read a field the target declares, and read one hop.

‎packages/lint/src/runtime-gate.object-validation-writes.test.ts‎

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
* down, in `os build` and at this door alike. The pass now runs the same
4040
* `check()` on every nested predicate, at the location the null-guard gate
4141
* already gives it (`validation rule 'outer' then → 'inner'`), with the
42-
* relationship-traversal checks on a nested `condition` (ObjectQL hydrates it)
43-
* and not on a nested `when` (it does not). The second describe block below
42+
* relationship-traversal checks on a nested `condition` and — since #24512 —
43+
* on a nested `when` (ObjectQL hydrates both). The second describe block below
4444
* pins it; its protocol half is the #22042 block of the same protocol file.
4545
*/
4646
import { describe, expect, it } from 'vitest';
@@ -236,7 +236,7 @@ const fxRef = (validations: unknown[]) => {
236236
const base = fxRule(validations);
237237
return { ...base, fields: { ...base.fields, account: { type: 'lookup', label: 'Account', reference: 'fx_rule' } } };
238238
};
239-
/** Reads more than one relationship hop — a shape `checkPredicate` refuses, and `checkConditional` never judges. */
239+
/** Reads more than one relationship hop — a shape `checkPredicate` and (#24512) `checkConditional` refuse. */
240240
const MULTI_HOP = 'record.account.owner.email != null';
241241
const HYDRATION = {
242242
type: 'conditional',
@@ -311,16 +311,21 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
311311
]);
312312
});
313313

314-
it('the traversal checks follow the evaluator per slot: ON for a nested `condition`, OFF for a nested `when`', () => {
314+
it('the traversal checks follow the evaluator per slot: ON for a nested `condition` and, since #24512, a nested `when`', () => {
315315
const atBuild = buildFindings(fxRef([HYDRATION]));
316316

317-
// `checkPredicate` refuses a read deeper than one hop at any depth, so the build says so…
318-
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual(["object 'fx_rule' · validation rule 'outer' then → 'inner'"]);
319-
expect(atBuild[0]!.message).toContain('ONE hop');
320-
// …and `checkConditional` never hydrates a `when`, so the same source there earns no
321-
// traversal prescription — exactly as the top-level `when` site is opted out.
317+
// `checkPredicate` refuses a read deeper than one hop at any depth, and
318+
// `checkConditional` evaluates a `when` on the same hydrated scope, so the
319+
// build says so for both slots…
320+
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual([
321+
"object 'fx_rule' · validation rule 'outer' then → 'inner'",
322+
"object 'fx_rule' · validation rule 'outer' otherwise → 'mid' when-predicate",
323+
]);
324+
for (const f of atBuild) expect(f.message).toContain('ONE hop');
325+
// …and at the top-level `when` site too.
322326
const topWhen = buildFindings(fxRef([{ ...HYDRATION, when: MULTI_HOP, then: NESTED_VALID.otherwise, otherwise: undefined }]));
323-
expect(topWhen, dump(topWhen)).toEqual([]);
327+
expect(topWhen.map((f) => f.where), dump(topWhen)).toEqual(["object 'fx_rule' · validation 'outer' when"]);
328+
expect(topWhen[0]!.message).toContain('ONE hop');
324329
expect(expressionFindings(gateObject(fxRef([HYDRATION])).errors)).toEqual(atBuild);
325330
});
326331

@@ -382,3 +387,68 @@ describe('#24468 — the object door refuses a rule hop to a field the target do
382387
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
383388
});
384389
});
390+
391+
describe('#24512 — the object door judges a `conditional` rule\'s `when` hop as it judges a `condition`\'s', () => {
392+
/** The lookup's target, delivered by ANOTHER package the written one depends on. */
393+
const account = {
394+
name: 'fx_account', label: 'Account', sharingModel: 'private', _packageId: 'com.example.accounts',
395+
fields: { status: { type: 'text', label: 'Status' } },
396+
};
397+
const order = (validations: unknown[]) => ({
398+
name: 'fx_order',
399+
label: 'Order',
400+
sharingModel: 'private',
401+
fields: {
402+
amount: { type: 'number', label: 'Amount' },
403+
account: { type: 'lookup', label: 'Account', reference: 'fx_account' },
404+
},
405+
validations,
406+
});
407+
const gate = (when: string, name = 'gate') => ({
408+
type: 'conditional', name, when, message: 'x',
409+
then: { type: 'script', name: 'cap', condition: 'record.amount != null && record.amount > 100', message: 'y' },
410+
});
411+
const gateOrder = (validations: unknown[]) => runRuntimeAuthoringRules({
412+
type: 'object',
413+
item: order(validations),
414+
context: { objects: [account] },
415+
packageScope: { packageId: 'com.example.orders', dependencies: ['com.example.accounts'] },
416+
});
417+
418+
it('the fixtures are spec-valid: each refusal below is the expression verdict, not the schema\'s', () => {
419+
for (const when of ["record.account.region == 'north'", 'record.account.owner.email != null', "record.account.status == 'open'"]) {
420+
const parsed = ObjectSchema.safeParse(order([gate(when), { type: 'conditional', name: 'outer', when: 'record.amount != null', message: 'x', then: gate(when, 'inner') }]));
421+
expect(parsed.success, dump(parsed.error?.issues)).toBe(true);
422+
}
423+
});
424+
425+
it('⭐ LIT — a `when` reading a field the target does not declare is REFUSED under EXPRESSION_INVALID, naming the slot, the field and the target', () => {
426+
const result = gateOrder([gate("record.account.region == 'north'")]);
427+
428+
const errs = expressionFindings(result.errors);
429+
expect(errs, dump(result)).toHaveLength(1);
430+
const where = "object 'fx_order' · validation 'gate' when";
431+
expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error', where, path: where });
432+
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
433+
expect(errs[0]!.message).toContain('the rule has no verdict');
434+
});
435+
436+
it('⭐ LIT — a `when` reading a second hop is REFUSED, and a nested `when` is judged too', () => {
437+
const result = gateOrder([{
438+
type: 'conditional', name: 'outer', when: 'record.amount != null', message: 'x',
439+
then: gate('record.account.owner.email != null', 'inner'),
440+
}]);
441+
442+
const errs = expressionFindings(result.errors);
443+
expect(errs.map((e) => e.where), dump(result)).toEqual(["object 'fx_order' · validation rule 'outer' then → 'inner' when-predicate"]);
444+
expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error' });
445+
expect(errs[0]!.message).toContain('ONE hop');
446+
});
447+
448+
it('⭐ CONTROL — a `when` reading a field the target declares publishes clean', () => {
449+
const result = gateOrder([gate("record.account.status == 'open'")]);
450+
451+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
452+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
453+
});
454+
});

‎packages/lint/src/validate-expressions.rule-one-hop.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,58 @@ describe('[#24468] a validation rule reads one hop through a reference', () => {
7171
expect(errorsOf("record.account.region == 'north'", 'hop_elsewhere')).toEqual([]);
7272
});
7373
});
74+
75+
/**
76+
* [#24512] A `conditional` rule's `when` is hydrated one hop by ObjectQL's
77+
* `checkConditional`, as a `condition` is by `checkPredicate`, so the same hop
78+
* checks judge it — at the top level and in a nested `conditional` — under the
79+
* same `expression-invalid` rule.
80+
*/
81+
describe('[#24512] a `conditional` rule\'s `when` reads one hop through a reference', () => {
82+
const WHEN_WHERE = "object 'hop_order' · validation 'gate' when";
83+
const NESTED_WHERE = "object 'hop_order' · validation rule 'outer' then → 'gate' when-predicate";
84+
const gate = (when: string) => ({
85+
name: 'gate', type: 'conditional', severity: 'error', message: 'x', when,
86+
then: { name: 'cap', type: 'script', severity: 'error', message: 'y', condition: 'record.amount != null && record.amount > 100' },
87+
});
88+
const withRule = (rule: Record<string, unknown>) => ({ ...order('true'), validations: [rule] });
89+
const errorsAt = (rule: Record<string, unknown>) =>
90+
validateStackExpressions(specValid({ objects: [account, withRule(rule)] }))
91+
.filter((i) => (i.severity ?? 'error') === 'error');
92+
const outer = (when: string) => ({
93+
name: 'outer', type: 'conditional', severity: 'error', message: 'x', when: 'record.amount != null', then: gate(when),
94+
});
95+
96+
it('admits a `when` reading a field the target declares, top level and nested', () => {
97+
expect(errorsAt(gate("record.account.status == 'active'"))).toEqual([]);
98+
expect(errorsAt(outer("record.account.status == 'active'"))).toEqual([]);
99+
});
100+
101+
it('refuses a `when` reading a field the target does not declare, naming the slot, the field and the target', () => {
102+
for (const [rule, where] of [[gate("record.account.region == 'north'"), WHEN_WHERE], [outer("record.account.region == 'north'"), NESTED_WHERE]] as const) {
103+
const errors = errorsAt(rule);
104+
expect(errors.map((e) => e.where)).toEqual([where]);
105+
expect(errors[0].message).toMatch(/^unknown field `region` on `hop_account`/);
106+
expect(errors[0].message).toContain('through the reference `record.account`');
107+
}
108+
});
109+
110+
it('refuses a `when` reading a second hop, naming the slot', () => {
111+
for (const [rule, where] of [[gate("record.account.owner.email == 'x'"), WHEN_WHERE], [outer("record.account.owner.email == 'x'"), NESTED_WHERE]] as const) {
112+
const errors = errorsAt(rule);
113+
expect(errors.map((e) => e.where)).toEqual([where]);
114+
expect(errors[0].message).toContain('read through more than one relationship hop');
115+
}
116+
});
117+
118+
it('refuses a `when` reading the reference both through it and as a value, naming the guard', () => {
119+
const errors = errorsAt(gate("record.account != null && record.account.status == 'active'"));
120+
expect(errors.map((e) => e.where)).toEqual([WHEN_WHERE]);
121+
expect(errors[0].message).toContain('is read BOTH through the relationship');
122+
expect(errors[0].message).toContain('`conditional` rule whose `when` is `record.account != null`');
123+
});
124+
125+
it('CONTROL — the guard spelling reads the stored id and is admitted', () => {
126+
expect(errorsAt(gate('record.account != null'))).toEqual([]);
127+
});
128+
});

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -544,8 +544,6 @@ function celSourceOf(raw: unknown): string | undefined {
544544
interface RulePredicate {
545545
label: string;
546546
raw: unknown;
547-
/** `condition` (a `script` / `cross_field` rule's) or `when` (a `conditional` rule's). */
548-
slot: 'condition' | 'when';
549547
/** 0 for the rule itself; 1 inside its `then` / `otherwise`, and so on down. */
550548
depth: number;
551549
}
@@ -568,9 +566,9 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[]
568566
// author who wrote both `condition` and a rejected alias had their canonical
569567
// predicate short-circuited away and the alias validated instead (#5017).
570568
const main = rule.condition;
571-
if (main != null) out.push({ label: `validation rule ${here}`, raw: main, slot: 'condition', depth });
569+
if (main != null) out.push({ label: `validation rule ${here}`, raw: main, depth });
572570
if (rule.when != null) {
573-
out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, slot: 'when', depth });
571+
out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, depth });
574572
}
575573
for (const branch of ['then', 'otherwise'] as const) {
576574
const nested = rule[branch];
@@ -1946,7 +1944,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
19461944
/**
19471945
* [#24316] Refuse a one-hop read through a reference into a
19481946
* stored-metadata-body table, at the two sites that serve a hop: a
1949-
* validation rule's hydrated `condition` ({@link check} with
1947+
* validation rule's hydrated `condition` or `when` ({@link check} with
19501948
* `traversalHydration`) and a formula field's `expression`
19511949
* ({@link judgeFieldFormula}). ObjectQL's one-hop loader never reads that
19521950
* hop, so as written the field reads null or the rule refuses every write;
@@ -1984,8 +1982,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
19841982
*
19851983
* [#24468] The two sites that serve a hop share it: a formula field's
19861984
* `expression` ({@link judgeFieldFormula}) and a validation rule's hydrated
1987-
* `condition` ({@link check} with `traversalHydration`), where the rule has
1988-
* no verdict and refuses every write it judges.
1985+
* `condition` or `when` ({@link check} with `traversalHydration`), where the
1986+
* rule has no verdict and refuses every write it judges.
19891987
*
19901988
* Judged against the target's field index — its declared fields and the
19911989
* system columns the platform injects on it, the set the registry carries —
@@ -2163,7 +2161,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
21632161
/**
21642162
* [#18682] Opt this ONE site in to the relationship-traversal conflict
21652163
* checks. True only where the traversal is actually SERVED — object
2166-
* validation rules, which ObjectQL's `checkPredicate` hydrates (a formula
2164+
* validation rules' `condition` and `when`, which ObjectQL's
2165+
* `checkPredicate` and `checkConditional` hydrate (#24512) (a formula
21672166
* `expression`, the other served site, is judged by `judgeFieldFormula`,
21682167
* which does not go through this helper). Everywhere
21692168
* else the checks' prescription ("write `record.<fk>.id`") is false,
@@ -2787,15 +2786,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
27872786
// bare refs are flagged (#1928).
27882787
// [#18682] The sites where a relationship traversal is SERVED: these are
27892788
// the `script` / `cross_field` conditions ObjectQL's `checkPredicate`
2790-
// hydrates. `traversalHydration` is passed here, on a nested
2791-
// `condition` below (#22042), and on a formula field's `expression`
2792-
// (`judgeFieldFormula`, #24197), and NOWHERE else.
2789+
// hydrates, and (#24512) a `conditional` rule's `when`, which
2790+
// `checkConditional` evaluates on the same hydrated scope.
2791+
// `traversalHydration` is passed on those two slots here and nested below
2792+
// (#22042), and on a formula field's `expression` (`judgeFieldFormula`,
2793+
// #24197), and NOWHERE else.
27932794
check(where, rule.condition, objectName, 'record', undefined, true);
2794-
// `conditional` rules carry a nested `when` predicate (record-scoped).
2795-
// ⚠️ `when` is evaluated by `checkConditional` WITHOUT hydration today, so
2796-
// it is opted OUT: a traversal there faults, and the conflict checks'
2797-
// prescription would not repair it.
2798-
check(`${where} when`, (rule as AnyRec).when, objectName, 'record');
2795+
check(`${where} when`, (rule as AnyRec).when, objectName, 'record', undefined, true);
27992796
const predicates = rulePredicates(rule, '');
28002797
// [#22042] The same verdict one level down, and every level below it: a
28012798
// `conditional` rule's `then` / `otherwise` is a rule the evaluator runs
@@ -2804,15 +2801,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28042801
// at the label `rulePredicates` builds, the location the null-guard gate
28052802
// below already gives the same predicate. Depth 0 is skipped: the rule's
28062803
// own `condition` / `when` met `check()` above, under their own location.
2807-
// Hydration follows the evaluator per slot, as at the top level: a
2808-
// nested `condition` is a `script` / `cross_field` rule's, which
2809-
// ObjectQL's `collectPredicateRelationships` reaches inside a
2810-
// `conditional` and `checkPredicate` hydrates; a nested `when` is a
2811-
// nested `conditional`'s, which `checkConditional` evaluates WITHOUT
2812-
// hydration, so it is opted out like the top-level `when`.
2804+
// Hydration follows the evaluator, as at the top level: ObjectQL's
2805+
// `collectPredicateRelationships` reaches a nested `condition` and a
2806+
// nested `when` alike, and both are evaluated hydrated, so both slots
2807+
// meet the traversal checks.
28132808
for (const p of predicates) {
28142809
if (p.depth === 0) continue;
2815-
check(`object '${objectName}' · ${p.label}`, p.raw, objectName, 'record', undefined, p.slot === 'condition');
2810+
check(`object '${objectName}' · ${p.label}`, p.raw, objectName, 'record', undefined, true);
28162811
}
28172812
// #4763 — null-guard gate over every predicate the rule carries, nested
28182813
// `then`/`otherwise` branches included.

0 commit comments

Comments
 (0)