Skip to content

Commit 913fd6d

Browse files
fix(lint,objectql)!: a validation rule reading a field its lookup target does not declare is refused at save, and no longer leaves its sibling rules without a verdict (#24508)
Fixes #24468 Clause-②: no (narrowing) (the save door newly refuses a validation rule it accepted; owes one `CONTRACT_REVIEW_TIER` review, on the narrowing face, before the queue) Dispatched by the `domain:engine` seat 1 PM (claim 6262728643, `session_01JtSQQ3EWsbNPzyYMug1tXy`). Developer session: `session_01XPyxHgkHDoFuEza6RvCJtd`. This is the predicate-seam twin of #24410 (PR #24466, `45bec43cd9`), and it follows that PR's pattern. ## What changes **Engine: per-rule isolation (`packages/objectql`).** `resolvePredicateRelated` reads, per reference, the union of the related columns every validation rule reads. When one column was undeclared on the target, `resolveOneHopRelated` answered the whole reference `undeclared-field`. Every rule through that lookup then had no verdict and refused the write, naming the other rule's field. - `resolvePredicateRelated` now takes the undeclared columns out of the shared read, using the shared `undeclaredRelatedFields` helper (no second definition of "declared"). It lists them on the binding as `undeclaredFields`. The loader reads the declared rest under `authority: 'predicate'`, with the same bound as before. - `resolveTraversalScope` (`validation/rule-validator.ts`, the binding's consumer) first refuses a rule only for its OWN undeclared columns, naming only those. A rule naming none of them is judged on the row exactly as it is alone. - A reference whose every read column is undeclared still gets a binding (`unavailable: 'undeclared-field'`), so no rule meets a bare id. - ⛔ Not moved: the formula seam (`resolveFormulaRelated`, `formulaHopScope`), `resolveOneHopRelated`, and the predicate read's authority and bound. No undeclared column enters the system-authority read. **Lint: save-door refusal (`packages/lint`, the `domain:spec` rider declared in 6262742638).** `refuseUndeclaredHopField` (#24410) now also runs in `check()` wherever `traversalHydration` is set. Those are a `script` / `cross_field` rule's `condition`, at the top level and inside a `conditional`'s `then` / `otherwise`. It judges the hop column against the target's field index (declared fields, injected columns and `id`) under the existing `expression-invalid` rule, with no new code. - The message opens with the shared validator's "unknown field" words, naming the column and the target, then names the reference read. Its consequence clause is per site: a rule "has no verdict and every write it judges is refused". The formula message is byte-identical to before. - One body serves `os validate`, `os build` and the object save door. - A target the stack does not carry is not judged: a missed catch, never a false refusal. - A reference read more than one hop deep is left to the traversal checks' own refusal (one finding, not two). This applies to the formula site too, where it only removes a duplicate finding on an expression already refused. **Census rows.** objectql `federated-injected-column-readers.test.ts` gets one row for the new engine use, `engine.ts#resolvePredicateRelated :: referenceTargetOf()` → `caller-predicate`. `packages/core`'s `[#22661]` census is per file, and both caller files (`engine.ts`, `validate-expressions.ts`) already have rows. `pnpm --filter @objectstack/core test:repo` passed 82/82. ## Measurement (M, booted composition: `packages/qa/dogfood/test/rule-hop-undeclared-related-field.dogfood.test.ts`) Fixture: `rrh_order` with a lookup `account` to `rrh_account` (declares `status`). It carries two `script` rules: `open_account` reads `record.account.status`, which is declared, and `north_only` reads `record.account.region`, which is not. | Reading | Before (`45bec43cd9`) | After (`69f76c6ef`) | |---|---|---| | `PUT /meta/object/rrh_order_saved` with both rules | `200` (saved) | `422 INVALID_METADATA`, one `expression-invalid` issue at `validation 'north_only'`, message opens "unknown field `region` on `rrh_account`" | | Control: same PUT with only `open_account` | `200` | `200` | | Control: `open_account` alone, open / closed account | `201` / `400` with its own message | `201` / `400` with its own message | | Stored copy, open account | `400`: BOTH rules "could not be evaluated ('rrh_account' declares no 'region')" | `400`: only `north_only`, naming `region`; `open_account` absent | | Stored copy, closed account | not reached (the pin stopped at the open-account row) | `400`: `open_account`'s own message, plus `north_only`'s refusal | The before run on `45bec43cd9` had 3 of 4 pins red. The save door answered 200, and the stored copy's open-account write was refused with `open_account` "could not be evaluated ... declares no 'region'". The control's red was the pin's own `200` vs `201` create status, corrected before the after run. The after run is 4/4, with the #24410 formula pin also green (7/7). **Ablations** (each through `scripts/ablation-replace.mjs`, `ablation-dist-preflight.mjs` marker present in dist, restored to the HEAD blob with `git diff HEAD` empty, then rebuilt with the marker absent from dist): - **A, per-rule isolation** (the `named.delete` of the undeclared columns removed in `resolvePredicateRelated`). 3 engine pins red (`engine-predicate-relationship.test.ts`, the `[#24468]` block), and the booted sibling pin red. - **B, lint refusal** (the `refuseUndeclaredHopField(..., 'rule')` call removed). 3 lint pins red (`validate-expressions.rule-one-hop.test.ts`, and both LIT pins in `runtime-gate.object-validation-writes.test.ts`), and the booted save-door pin red. - Restore: `git status --porcelain` clean, both markers absent from dist, booted pin 4/4. ## Pins - `packages/objectql/src/engine-predicate-relationship.test.ts`, `[#24468]` block, on the real engine: - the valid sibling admits when it admits alone, and refuses with its own message; - the bad rule names only `not_a_column`; - one system read, projecting `['id', 'type']`. - `packages/objectql/src/validation/rule-relationship-traversal.test.ts`, `[#24468]` block, on the binding contract: verdicts per rule; the bad rule names only its own field when the binding lists two; a fully undeclared reference still refuses. - `packages/lint/src/validate-expressions.rule-one-hop.test.ts`, on the build: - refusal naming field and target; - `id` and `created_at` admitted; - a target outside the stack not judged. - `packages/lint/src/runtime-gate.object-validation-writes.test.ts`, `#24468` block, at the save door: - a target from another package in the closure, beside its valid sibling; - a nested `conditional` → `then`; - the control. - `packages/lint/src/validate-expressions.test.ts`: the `#5017` receiver census registers the refusal's `analysis` local as plumbing. - `packages/qa/dogfood/test/rule-hop-undeclared-related-field.dogfood.test.ts`: the booted pin above. Test-only; declared to `domain:cli` by the seat at review. **Pin sweep.** The whole-reference `undeclared-field` verdict for a rule is pinned in two places, and both still hold: - `rule-relationship-traversal.test.ts` hands a single-rule binding by hand. - `engine-predicate-relationship.test.ts` "never smuggles an UNDECLARED field" has one rule, whose own field is undeclared. No other test asserts the sibling refusal. ## Other predicate surfaces that read through a lookup Probed with `validateStackExpressions`: - **Not reached, and not needed:** a field-rule slot (`requiredWhen`) or an option `visibleWhen` through a lookup is already refused whole at save. Nothing hydrates there; the message says "a field-level predicate never reads the related record". This pass does not run there. - **Not reached:** a `conditional` rule's `when` is opted out of hydration by the existing walk (`checkConditional` evaluates it unhydrated), so this pass does not judge it. See the acceptance notes. ## Verification (final head `69f76c6ef`; merge of `origin/main` `959ee317b` at `0453ca518`) - Build: `turbo run build --filter=@objectstack/dogfood^... --filter=@objectstack/dogfood` exited 0 (66 tasks) on the merged tree. The 8 extra dists that `check:dual-build-cjs-loads` needed also built, exit 0. - `pnpm --filter @objectstack/objectql test`: 446 files, 8387 tests passed. - `pnpm --filter @objectstack/lint test`: 146 files, 6885 tests passed on `69f76c6ef`. The first run, on `0453ca518`, failed one test: the receiver census's `analysis`, fixed in `69f76c6ef`. - Dogfood: the rule and formula hop pins, 7/7. - `typecheck`: objectql exit 0, lint exit 0 (both include their `check:test-typecheck` leg; their `tsc` excludes tests), dogfood exit 0 (its `tsc --listFiles` includes the new pin). - `pnpm --filter @objectstack/core test:repo`: 6 files, 82 tests passed. - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 76 commands. All 76 ran on `0453ca518`; 75 exited 0. `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: 8 dists missing), was built for, and re-ran with exit 0. - After `69f76c6ef` (one lint test file), the derivation is identical. Re-ran: `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:type-check-debt`, `check:nul-bytes`, `check:issue-citations`, `check:tier-file-adoption`, and the three changeset gates. All exited 0. - `--ran` reconciliation: 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN. - `origin/main` moved to `7e58e0a27` after the merge: one `packages/cli` test budget, no overlap, not merged. ## Changesets - `@objectstack/lint` `minor`, `fix(lint)!:`, BREAKING (an accept-set narrowing), with `Clause-②: no (narrowing)`. Its ADR-0087 marker is `not-required (no-migration-prescription)`. It carries a FROM → TO and a "What to do". - `@objectstack/objectql` `patch`, `Clause-②: no` with no arm. ## Acceptance notes - A `conditional` rule's `when` that reads through a lookup is not hydrated (`checkConditional`), and the lint opts it out of the traversal checks. So `when: record.account.status == 'x'` saves clean, and it meets the bare id at evaluation. This is read from the code (the walk's own comment says it), not run as a failing probe. It is outside this card's two seams. - The lint pass does not see a target field that an `objectExtensions` entry contributes at `os build` / `os validate`, as for the formula site (#24410's changeset says the same). The save door judges the merged object. --- _Generated by [Claude Code](https://claude.ai/code/session_01XPyxHgkHDoFuEza6RvCJtd)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ad3ce5 commit 913fd6d

12 files changed

Lines changed: 514 additions & 12 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: a validation rule that reads, through a reference, a field the reference's target does not declare is refused where it is written
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape or authorable spelling is added, removed or renamed. The validation-rule pass now refuses one condition shape it accepted before: a one-hop read of a field the reference's target does not declare, which the runtime never judged (the rule had no verdict and refused every write it judged). Nothing stored changes, so there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The validation-rule pass judges the field a rule's `condition` reads one hop through a reference against the target object's fields: the fields it declares, the system columns the platform adds to it, and `id`. It is the same check the field-formula pass makes for a formula. `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. It covers a `script` or `cross_field` rule's `condition`, at the top level and inside a `conditional` rule's `then` / `otherwise`.
14+
15+
- FROM: `record.account.region == 'north'`, where `account` is a lookup to an object that declares no `region`, saved with a 200, and every write the rule judged was refused. TO: refused (`422 INVALID_METADATA` at the save door). The message names the field and the target: "unknown field `region` on `<target>`".
16+
- `record.account.status == 'closed'`, where the target declares `status`, passes as before.
17+
- A target the stack does not carry is not judged. At the save door the stack carries the live registry's objects in the written package's closure, so a target from a package it depends on is judged.
18+
- A reference read more than one hop deep (`record.account.owner.email`) keeps its own refusal and is not judged a second time.
19+
20+
What to do. Read a field the target declares, or declare the field on the target object. Do not declare it on the object that carries the rule. At `os build` and `os validate` this pass does not see a target field that an `objectExtensions` entry contributes, while the save door judges the merged object.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a validation rule reading a field its reference's target does not declare no longer refuses every write through the other rules reading the same reference
6+
7+
Clause-②: no
8+
9+
A write loads, once per page, every column the object's validation rules read through each reference. When one rule named a field the target does not declare, the whole reference was answered as undeclared. Every rule reading through it then had no verdict and refused the write, each naming the other rule's field.
10+
11+
- The undeclared fields are left out of that shared read and judged for each rule on its own.
12+
- A rule that names one has no verdict and refuses the write, naming only its own undeclared fields.
13+
- A rule that reads only declared fields through the same reference gives the verdict it gives alone: it admits what it admits alone, and refuses with its own message.
14+
- The related read keeps its authority and its bound; an undeclared field never enters it.
15+
16+
What to do. Nothing. To find the rule at fault, re-save the object: the save door refuses the misspelt read and names the field.

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,54 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
331331
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
332332
});
333333
});
334+
335+
describe('#24468 — the object door refuses a rule hop to a field the target does not declare', () => {
336+
/** The lookup's target, delivered by ANOTHER package the written one depends on. */
337+
const account = {
338+
name: 'fx_account', label: 'Account', sharingModel: 'private', _packageId: 'com.example.accounts',
339+
fields: { status: { type: 'text', label: 'Status' } },
340+
};
341+
const order = (validations: unknown[]) => ({
342+
name: 'fx_order',
343+
label: 'Order',
344+
sharingModel: 'private',
345+
fields: { account: { type: 'lookup', label: 'Account', reference: 'fx_account' } },
346+
validations,
347+
});
348+
const rule = (name: string, condition: string) => ({ name, type: 'script', condition, message: 'x' });
349+
const gateOrder = (validations: unknown[]) => runRuntimeAuthoringRules({
350+
type: 'object',
351+
item: order(validations),
352+
context: { objects: [account] },
353+
packageScope: { packageId: 'com.example.orders', dependencies: ['com.example.accounts'] },
354+
});
355+
356+
it('⭐ LIT — the rule is REFUSED under EXPRESSION_INVALID, naming the field and the target from the other package; its valid sibling is not', () => {
357+
const result = gateOrder([rule('open_account', "record.account.status == 'closed'"), rule('north_only', "record.account.region == 'north'")]);
358+
359+
const errs = expressionFindings(result.errors);
360+
expect(errs, dump(result)).toHaveLength(1);
361+
const where = "object 'fx_order' · validation 'north_only'";
362+
expect(errs[0]).toMatchObject({ severity: 'error', where, path: where });
363+
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
364+
expect(errs[0]!.message).toContain('the rule has no verdict');
365+
});
366+
367+
it('⭐ LIT — a nested `condition` inside a `conditional` is judged too', () => {
368+
const result = gateOrder([{
369+
type: 'conditional', name: 'outer', when: 'record.account != null', message: 'x',
370+
then: rule('inner', "record.account.region == 'north'"),
371+
}]);
372+
373+
const errs = expressionFindings(result.errors);
374+
expect(errs.map((e) => e.where), dump(result)).toEqual(["object 'fx_order' · validation rule 'outer' then → 'inner'"]);
375+
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
376+
});
377+
378+
it('⭐ CONTROL — a rule reading a field the target declares publishes clean', () => {
379+
const result = gateOrder([rule('open_account', "record.account.status == 'closed'")]);
380+
381+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
382+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
383+
});
384+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24468] A validation rule's `condition` reads ONE hop through a reference
5+
* field, and the validation-rule pass judges the hop's COLUMN against the
6+
* target's field index, as the field-formula pass does for a formula (#24410).
7+
*
8+
* ObjectQL's predicate read loads only the columns the target declares; a rule
9+
* naming one it does not declare has no verdict and refuses every write it
10+
* judges. So the build, `os validate` and the object save door refuse it,
11+
* naming the field and the target, under the existing `expression-invalid`
12+
* rule. The runtime half is `packages/objectql/src/engine-predicate-relationship.test.ts`.
13+
* Every fixture goes through `specValid`.
14+
*/
15+
import { describe, expect, it } from 'vitest';
16+
import { ObjectStackSchema } from '@objectstack/spec';
17+
18+
import { validateStackExpressions } from './validate-expressions.js';
19+
20+
const MANIFEST = { id: 'com.example.rule-hop', name: 'rule_hop', version: '1.0.0', type: 'app' } as const;
21+
22+
/** A fixture is only a fixture if the spec accepts it. */
23+
function specValid(stack: Record<string, unknown>): Record<string, unknown> {
24+
const result = ObjectStackSchema.safeParse({ manifest: MANIFEST, ...stack });
25+
if (!result.success) {
26+
throw new Error(
27+
'fixture is not spec-valid: '
28+
+ result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '),
29+
);
30+
}
31+
return result.data as unknown as Record<string, unknown>;
32+
}
33+
34+
const account = { name: 'hop_account', label: 'Account', fields: { status: { type: 'text', label: 'Status' } } };
35+
const order = (condition: string, reference = 'hop_account') => ({
36+
name: 'hop_order',
37+
label: 'Order',
38+
fields: {
39+
amount: { type: 'number', label: 'Amount' },
40+
account: { type: 'lookup', label: 'Account', reference },
41+
},
42+
validations: [{ name: 'r', type: 'script', severity: 'error', message: 'x', condition }],
43+
});
44+
45+
const WHERE = "object 'hop_order' · validation 'r'";
46+
47+
const errorsOf = (condition: string, reference?: string) =>
48+
validateStackExpressions(specValid({ objects: [account, order(condition, reference)] }))
49+
.filter((i) => i.where === WHERE && (i.severity ?? 'error') === 'error');
50+
51+
describe('[#24468] a validation rule reads one hop through a reference', () => {
52+
it('admits a read of a field the target declares', () => {
53+
expect(errorsOf("record.account.status == 'closed'")).toEqual([]);
54+
});
55+
56+
it('refuses a read of a field the target does not declare, naming the field and the target', () => {
57+
const errors = errorsOf("record.account.region == 'north' && record.account.status == 'open'");
58+
expect(errors).toHaveLength(1);
59+
expect(errors[0].message).toMatch(/^unknown field `region` on `hop_account`/);
60+
expect(errors[0].message).toContain('through the reference `record.account`');
61+
expect(errors[0].message).toContain("declare 'region' on 'hop_account' — not on 'hop_order', the object carrying this rule");
62+
expect(errors[0].source).toBe("record.account.region == 'north' && record.account.status == 'open'");
63+
});
64+
65+
it('admits the primary key and a column the platform injects on the target', () => {
66+
expect(errorsOf("record.account.id != ''")).toEqual([]);
67+
expect(errorsOf('record.account.created_at != null')).toEqual([]);
68+
});
69+
70+
it('does not judge a target the stack does not carry', () => {
71+
expect(errorsOf("record.account.region == 'north'", 'hop_elsewhere')).toEqual([]);
72+
});
73+
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3549,6 +3549,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
35493549
// metadata keys; the metadata keys the index is built from (`type`,
35503550
// `reference`) are read off the tabled `def` receiver.
35513551
'storedMetadataBodyReferenceIndex', 'familyRefs', 'traversals',
3552+
// [#24468] The undeclared-hop refusal's whole traversal analysis, read for
3553+
// its `traversals` and `multiHopFields` — the formula package's own
3554+
// result keys, never metadata keys.
3555+
'analysis',
35523556
]);
35533557
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
35543558
});

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

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,12 +1774,25 @@ function storedMetadataBodyHopMessage(
17741774
* words, so the two first-segment and second-segment verdicts read as one
17751775
* family, and names the read, the target and where the field would have to be
17761776
* declared — never on the object carrying the formula.
1777+
*
1778+
* [#24468] The same refusal for a validation rule's hydrated `condition`; only
1779+
* the consequence differs: the rule has no verdict, so every write it judges
1780+
* is refused.
17771781
*/
1778-
function undeclaredHopFieldMessage(objectName: string, field: string, target: string, column: string): string {
1779-
return `unknown field \`${column}\` on \`${target}\`: this formula reads it through the reference `
1780-
+ `${celMemberPath('record', field)}, and '${target}' declares no field '${column}', so the formula reads `
1781-
+ `null on every row. Read a field '${target}' declares, or declare '${column}' on '${target}' — not on `
1782-
+ `'${objectName}', the object carrying this formula.`;
1782+
function undeclaredHopFieldMessage(
1783+
site: 'formula' | 'rule',
1784+
objectName: string,
1785+
field: string,
1786+
target: string,
1787+
column: string,
1788+
): string {
1789+
const consequence = site === 'formula'
1790+
? 'so the formula reads null on every row'
1791+
: 'so the rule has no verdict and every write it judges is refused';
1792+
return `unknown field \`${column}\` on \`${target}\`: this ${site} reads it through the reference `
1793+
+ `${celMemberPath('record', field)}, and '${target}' declares no field '${column}', ${consequence}. `
1794+
+ `Read a field '${target}' declares, or declare '${column}' on '${target}' — not on `
1795+
+ `'${objectName}', the object carrying this ${site}.`;
17831796
}
17841797

17851798
/**
@@ -1969,6 +1982,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
19691982
* written the formula reads null on every row; this says so where it is
19701983
* written, at the build and at the object save door alike.
19711984
*
1985+
* [#24468] The two sites that serve a hop share it: a formula field's
1986+
* `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.
1989+
*
19721990
* Judged against the target's field index — its declared fields and the
19731991
* system columns the platform injects on it, the set the registry carries —
19741992
* and the primary key, which no field map carries and the loader reads. A
@@ -1977,22 +1995,30 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
19771995
* not judged; a hop into the stored-metadata-body family is refused by
19781996
* {@link refuseStoredMetadataBodyHop} and not judged twice.
19791997
*/
1980-
const refuseUndeclaredHopField = (where: string, raw: unknown, objectName: string): void => {
1998+
const refuseUndeclaredHopField = (
1999+
where: string,
2000+
raw: unknown,
2001+
objectName: string,
2002+
site: 'formula' | 'rule',
2003+
): void => {
19812004
const source = celSourceOf(raw);
19822005
if (!source) return;
1983-
const traversals = analyzeRelationshipTraversals(source, 'record')?.traversals;
2006+
const analysis = analyzeRelationshipTraversals(source, 'record');
2007+
const traversals = analysis?.traversals;
19842008
if (!traversals || traversals.size === 0) return;
19852009
for (const obj of objects) {
19862010
if (obj.name !== objectName) continue;
19872011
for (const [field, def] of fieldEntries(obj)) {
1988-
const columns = traversals.get(field);
2012+
// [#24468] A reference read more than one hop deep is refused whole by
2013+
// the traversal checks; its first column is not judged a second time.
2014+
const columns = analysis?.multiHopFields.has(field) ? undefined : traversals.get(field);
19892015
const target = columns ? referenceTargetOf(def)?.trim() : undefined;
19902016
if (!columns || !target || isStoredMetadataBodyObject(target) || !fieldIndex.has(target)) continue;
19912017
for (const column of [...columns].sort()) {
19922018
if (column === 'id' || fieldIndex.get(target)?.includes(column)) continue;
19932019
issues.push({
19942020
where,
1995-
message: undeclaredHopFieldMessage(objectName, field, target, column),
2021+
message: undeclaredHopFieldMessage(site, objectName, field, target, column),
19962022
source,
19972023
severity: 'error',
19982024
});
@@ -2180,6 +2206,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
21802206
}
21812207
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
21822208
if (objectName && traversalHydration) refuseStoredMetadataBodyHop(where, raw, objectName, 'rule');
2209+
if (objectName && traversalHydration) refuseUndeclaredHopField(where, raw, objectName, 'rule');
21832210
// [#8116] Provenance rides every object-bound predicate this helper
21842211
// validates, whichever scope: the `record`/`previous` roots are explicit
21852212
// in the source, so flattened flow conditions are covered without ever
@@ -2902,7 +2929,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
29022929
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' });
29032930
if (objectName) refuseStoredMetadataBodyHop(fieldWhere, f.expression, objectName, 'formula');
29042931
// [#24410] And a hop to a field the target does not declare.
2905-
if (objectName) refuseUndeclaredHopField(fieldWhere, f.expression, objectName);
2932+
if (objectName) refuseUndeclaredHopField(fieldWhere, f.expression, objectName, 'formula');
29062933
// [#8116] Formulas read the same record binding the predicates do, so
29072934
// a `record.<anchor>` read inside one degrades identically.
29082935
warnUnprovisionedAnchors(fieldWhere, f.expression, objectName);

‎packages/objectql/src/engine-predicate-relationship.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,59 @@ describe('#18682 — engine-produced relationship bindings', () => {
231231
}
232232
});
233233

234+
// [#24468] One rule's undeclared column is judged against THAT rule: a valid
235+
// sibling reading through the same reference keeps the verdict it gives
236+
// alone, and the bad rule's refusal names only its own field.
237+
describe('[#24468] a sibling of a rule reading an undeclared related field', () => {
238+
const refusal = async (data: Record<string, unknown>): Promise<string> => {
239+
try {
240+
await engine.insert('crm_opportunity', data, { context: ACTING } as any);
241+
} catch (e) {
242+
return JSON.stringify((e as { errors?: unknown }).errors ?? (e as Error).message);
243+
}
244+
return '';
245+
};
246+
247+
beforeEach(() => {
248+
engine.registry.registerObject({
249+
name: 'crm_opportunity',
250+
fields: {
251+
name: { type: 'text' }, amount: { type: 'number' },
252+
account: { type: 'lookup', reference: 'crm_account' },
253+
},
254+
validations: [
255+
{
256+
name: 'partner_cap', type: 'script', severity: 'error',
257+
message: 'Partner accounts are capped at 10000.',
258+
condition: "record.account.type == 'partner' && record.amount > 10000",
259+
},
260+
{ name: 'sneaky', type: 'script', severity: 'error', message: 'x',
261+
condition: "record.account.not_a_column == 'x'" },
262+
],
263+
} as any, 'test-package');
264+
d.calls.length = 0;
265+
});
266+
267+
it('the valid rule admits what it admits alone; only the bad rule refuses, naming its own field', async () => {
268+
const text = await refusal({ name: 'A', amount: 50000, account: 'acc_d' });
269+
expect(text).toContain("Validation rule 'sneaky' could not be evaluated");
270+
expect(text).toContain("declares no 'not_a_column'");
271+
expect(text).not.toContain('partner_cap');
272+
});
273+
274+
it('the valid rule refuses with its own message, as it does alone', async () => {
275+
const text = await refusal({ name: 'A', amount: 50000, account: 'acc_p' });
276+
expect(text).toContain('Partner accounts are capped');
277+
expect(text).not.toContain("Validation rule 'partner_cap' could not be evaluated");
278+
});
279+
280+
it('the shared system read names the declared columns only, once', async () => {
281+
await refusal({ name: 'A', amount: 10, account: 'acc_d' });
282+
expect(relatedReads()).toHaveLength(1);
283+
expect([...(relatedReads()[0].ast?.fields ?? [])].sort()).toEqual(['id', 'type']);
284+
});
285+
});
286+
234287
// The refusal is about the WRITE, never about the value. A caller can infer a
235288
// value by observing refusals — an accepted, deliberately narrow channel — so
236289
// nothing may make the refusal more informative than "this rule refused".

0 commit comments

Comments
 (0)