Skip to content
20 changes: 20 additions & 0 deletions .changeset/24468-lint-rule-hop-undeclared-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': minor
---

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

Clause-②: no (narrowing)

<!-- 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. -->

**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.

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`.

- 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>`".
- `record.account.status == 'closed'`, where the target declares `status`, passes as before.
- 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.
- A reference read more than one hop deep (`record.account.owner.email`) keeps its own refusal and is not judged a second time.

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.
16 changes: 16 additions & 0 deletions .changeset/24468-objectql-rule-hop-sibling-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
---

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

Clause-②: no

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.

- The undeclared fields are left out of that shared read and judged for each rule on its own.
- A rule that names one has no verdict and refuses the write, naming only its own undeclared fields.
- 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.
- The related read keeps its authority and its bound; an undeclared field never enters it.

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.
51 changes: 51 additions & 0 deletions packages/lint/src/runtime-gate.object-validation-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,54 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
});
});

describe('#24468 — the object door refuses a rule hop to a field the target does not declare', () => {
/** The lookup's target, delivered by ANOTHER package the written one depends on. */
const account = {
name: 'fx_account', label: 'Account', sharingModel: 'private', _packageId: 'com.example.accounts',
fields: { status: { type: 'text', label: 'Status' } },
};
const order = (validations: unknown[]) => ({
name: 'fx_order',
label: 'Order',
sharingModel: 'private',
fields: { account: { type: 'lookup', label: 'Account', reference: 'fx_account' } },
validations,
});
const rule = (name: string, condition: string) => ({ name, type: 'script', condition, message: 'x' });
const gateOrder = (validations: unknown[]) => runRuntimeAuthoringRules({
type: 'object',
item: order(validations),
context: { objects: [account] },
packageScope: { packageId: 'com.example.orders', dependencies: ['com.example.accounts'] },
});

it('⭐ LIT — the rule is REFUSED under EXPRESSION_INVALID, naming the field and the target from the other package; its valid sibling is not', () => {
const result = gateOrder([rule('open_account', "record.account.status == 'closed'"), rule('north_only', "record.account.region == 'north'")]);

const errs = expressionFindings(result.errors);
expect(errs, dump(result)).toHaveLength(1);
const where = "object 'fx_order' · validation 'north_only'";
expect(errs[0]).toMatchObject({ severity: 'error', where, path: where });
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
expect(errs[0]!.message).toContain('the rule has no verdict');
});

it('⭐ LIT — a nested `condition` inside a `conditional` is judged too', () => {
const result = gateOrder([{
type: 'conditional', name: 'outer', when: 'record.account != null', message: 'x',
then: rule('inner', "record.account.region == 'north'"),
}]);

const errs = expressionFindings(result.errors);
expect(errs.map((e) => e.where), dump(result)).toEqual(["object 'fx_order' · validation rule 'outer' then → 'inner'"]);
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
});

it('⭐ CONTROL — a rule reading a field the target declares publishes clean', () => {
const result = gateOrder([rule('open_account', "record.account.status == 'closed'")]);

expect(expressionFindings(result.errors), dump(result)).toEqual([]);
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
});
});
73 changes: 73 additions & 0 deletions packages/lint/src/validate-expressions.rule-one-hop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24468] A validation rule's `condition` reads ONE hop through a reference
* field, and the validation-rule pass judges the hop's COLUMN against the
* target's field index, as the field-formula pass does for a formula (#24410).
*
* ObjectQL's predicate read loads only the columns the target declares; a rule
* naming one it does not declare has no verdict and refuses every write it
* judges. So the build, `os validate` and the object save door refuse it,
* naming the field and the target, under the existing `expression-invalid`
* rule. The runtime half is `packages/objectql/src/engine-predicate-relationship.test.ts`.
* Every fixture goes through `specValid`.
*/
import { describe, expect, it } from 'vitest';
import { ObjectStackSchema } from '@objectstack/spec';

import { validateStackExpressions } from './validate-expressions.js';

const MANIFEST = { id: 'com.example.rule-hop', name: 'rule_hop', version: '1.0.0', type: 'app' } as const;

/** A fixture is only a fixture if the spec accepts it. */
function specValid(stack: Record<string, unknown>): Record<string, unknown> {
const result = ObjectStackSchema.safeParse({ manifest: MANIFEST, ...stack });
if (!result.success) {
throw new Error(
'fixture is not spec-valid: '
+ result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '),
);
}
return result.data as unknown as Record<string, unknown>;
}

const account = { name: 'hop_account', label: 'Account', fields: { status: { type: 'text', label: 'Status' } } };
const order = (condition: string, reference = 'hop_account') => ({
name: 'hop_order',
label: 'Order',
fields: {
amount: { type: 'number', label: 'Amount' },
account: { type: 'lookup', label: 'Account', reference },
},
validations: [{ name: 'r', type: 'script', severity: 'error', message: 'x', condition }],
});

const WHERE = "object 'hop_order' · validation 'r'";

const errorsOf = (condition: string, reference?: string) =>
validateStackExpressions(specValid({ objects: [account, order(condition, reference)] }))
.filter((i) => i.where === WHERE && (i.severity ?? 'error') === 'error');

describe('[#24468] a validation rule reads one hop through a reference', () => {
it('admits a read of a field the target declares', () => {
expect(errorsOf("record.account.status == 'closed'")).toEqual([]);
});

it('refuses a read of a field the target does not declare, naming the field and the target', () => {
const errors = errorsOf("record.account.region == 'north' && record.account.status == 'open'");
expect(errors).toHaveLength(1);
expect(errors[0].message).toMatch(/^unknown field `region` on `hop_account`/);
expect(errors[0].message).toContain('through the reference `record.account`');
expect(errors[0].message).toContain("declare 'region' on 'hop_account' — not on 'hop_order', the object carrying this rule");
expect(errors[0].source).toBe("record.account.region == 'north' && record.account.status == 'open'");
});

it('admits the primary key and a column the platform injects on the target', () => {
expect(errorsOf("record.account.id != ''")).toEqual([]);
expect(errorsOf('record.account.created_at != null')).toEqual([]);
});

it('does not judge a target the stack does not carry', () => {
expect(errorsOf("record.account.region == 'north'", 'hop_elsewhere')).toEqual([]);
});
});
4 changes: 4 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3549,6 +3549,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
// metadata keys; the metadata keys the index is built from (`type`,
// `reference`) are read off the tabled `def` receiver.
'storedMetadataBodyReferenceIndex', 'familyRefs', 'traversals',
// [#24468] The undeclared-hop refusal's whole traversal analysis, read for
// its `traversals` and `multiHopFields` — the formula package's own
// result keys, never metadata keys.
'analysis',
]);
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
});
Expand Down
47 changes: 37 additions & 10 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1774,12 +1774,25 @@ function storedMetadataBodyHopMessage(
* words, so the two first-segment and second-segment verdicts read as one
* family, and names the read, the target and where the field would have to be
* declared — never on the object carrying the formula.
*
* [#24468] The same refusal for a validation rule's hydrated `condition`; only
* the consequence differs: the rule has no verdict, so every write it judges
* is refused.
*/
function undeclaredHopFieldMessage(objectName: string, field: string, target: string, column: string): string {
return `unknown field \`${column}\` on \`${target}\`: this formula reads it through the reference `
+ `${celMemberPath('record', field)}, and '${target}' declares no field '${column}', so the formula reads `
+ `null on every row. Read a field '${target}' declares, or declare '${column}' on '${target}' — not on `
+ `'${objectName}', the object carrying this formula.`;
function undeclaredHopFieldMessage(
site: 'formula' | 'rule',
objectName: string,
field: string,
target: string,
column: string,
): string {
const consequence = site === 'formula'
? 'so the formula reads null on every row'
: 'so the rule has no verdict and every write it judges is refused';
return `unknown field \`${column}\` on \`${target}\`: this ${site} reads it through the reference `
+ `${celMemberPath('record', field)}, and '${target}' declares no field '${column}', ${consequence}. `
+ `Read a field '${target}' declares, or declare '${column}' on '${target}' — not on `
+ `'${objectName}', the object carrying this ${site}.`;
}

/**
Expand Down Expand Up @@ -1969,6 +1982,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
* written the formula reads null on every row; this says so where it is
* written, at the build and at the object save door alike.
*
* [#24468] The two sites that serve a hop share it: a formula field's
* `expression` ({@link judgeFieldFormula}) and a validation rule's hydrated
* `condition` ({@link check} with `traversalHydration`), where the rule has
* no verdict and refuses every write it judges.
*
* Judged against the target's field index — its declared fields and the
* system columns the platform injects on it, the set the registry carries —
* and the primary key, which no field map carries and the loader reads. A
Expand All @@ -1977,22 +1995,30 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
* not judged; a hop into the stored-metadata-body family is refused by
* {@link refuseStoredMetadataBodyHop} and not judged twice.
*/
const refuseUndeclaredHopField = (where: string, raw: unknown, objectName: string): void => {
const refuseUndeclaredHopField = (
where: string,
raw: unknown,
objectName: string,
site: 'formula' | 'rule',
): void => {
const source = celSourceOf(raw);
if (!source) return;
const traversals = analyzeRelationshipTraversals(source, 'record')?.traversals;
const analysis = analyzeRelationshipTraversals(source, 'record');
const traversals = analysis?.traversals;
if (!traversals || traversals.size === 0) return;
for (const obj of objects) {
if (obj.name !== objectName) continue;
for (const [field, def] of fieldEntries(obj)) {
const columns = traversals.get(field);
// [#24468] A reference read more than one hop deep is refused whole by
// the traversal checks; its first column is not judged a second time.
const columns = analysis?.multiHopFields.has(field) ? undefined : traversals.get(field);
const target = columns ? referenceTargetOf(def)?.trim() : undefined;
if (!columns || !target || isStoredMetadataBodyObject(target) || !fieldIndex.has(target)) continue;
for (const column of [...columns].sort()) {
if (column === 'id' || fieldIndex.get(target)?.includes(column)) continue;
issues.push({
where,
message: undeclaredHopFieldMessage(objectName, field, target, column),
message: undeclaredHopFieldMessage(site, objectName, field, target, column),
source,
severity: 'error',
});
Expand Down Expand Up @@ -2180,6 +2206,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
}
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
if (objectName && traversalHydration) refuseStoredMetadataBodyHop(where, raw, objectName, 'rule');
if (objectName && traversalHydration) refuseUndeclaredHopField(where, raw, objectName, 'rule');
// [#8116] Provenance rides every object-bound predicate this helper
// validates, whichever scope: the `record`/`previous` roots are explicit
// in the source, so flattened flow conditions are covered without ever
Expand Down Expand Up @@ -2902,7 +2929,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' });
if (objectName) refuseStoredMetadataBodyHop(fieldWhere, f.expression, objectName, 'formula');
// [#24410] And a hop to a field the target does not declare.
if (objectName) refuseUndeclaredHopField(fieldWhere, f.expression, objectName);
if (objectName) refuseUndeclaredHopField(fieldWhere, f.expression, objectName, 'formula');
// [#8116] Formulas read the same record binding the predicates do, so
// a `record.<anchor>` read inside one degrades identically.
warnUnprovisionedAnchors(fieldWhere, f.expression, objectName);
Expand Down
53 changes: 53 additions & 0 deletions packages/objectql/src/engine-predicate-relationship.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,59 @@ describe('#18682 — engine-produced relationship bindings', () => {
}
});

// [#24468] One rule's undeclared column is judged against THAT rule: a valid
// sibling reading through the same reference keeps the verdict it gives
// alone, and the bad rule's refusal names only its own field.
describe('[#24468] a sibling of a rule reading an undeclared related field', () => {
const refusal = async (data: Record<string, unknown>): Promise<string> => {
try {
await engine.insert('crm_opportunity', data, { context: ACTING } as any);
} catch (e) {
return JSON.stringify((e as { errors?: unknown }).errors ?? (e as Error).message);
}
return '';
};

beforeEach(() => {
engine.registry.registerObject({
name: 'crm_opportunity',
fields: {
name: { type: 'text' }, amount: { type: 'number' },
account: { type: 'lookup', reference: 'crm_account' },
},
validations: [
{
name: 'partner_cap', type: 'script', severity: 'error',
message: 'Partner accounts are capped at 10000.',
condition: "record.account.type == 'partner' && record.amount > 10000",
},
{ name: 'sneaky', type: 'script', severity: 'error', message: 'x',
condition: "record.account.not_a_column == 'x'" },
],
} as any, 'test-package');
d.calls.length = 0;
});

it('the valid rule admits what it admits alone; only the bad rule refuses, naming its own field', async () => {
const text = await refusal({ name: 'A', amount: 50000, account: 'acc_d' });
expect(text).toContain("Validation rule 'sneaky' could not be evaluated");
expect(text).toContain("declares no 'not_a_column'");
expect(text).not.toContain('partner_cap');
});

it('the valid rule refuses with its own message, as it does alone', async () => {
const text = await refusal({ name: 'A', amount: 50000, account: 'acc_p' });
expect(text).toContain('Partner accounts are capped');
expect(text).not.toContain("Validation rule 'partner_cap' could not be evaluated");
});

it('the shared system read names the declared columns only, once', async () => {
await refusal({ name: 'A', amount: 10, account: 'acc_d' });
expect(relatedReads()).toHaveLength(1);
expect([...(relatedReads()[0].ast?.fields ?? [])].sort()).toEqual(['id', 'type']);
});
});

// The refusal is about the WRITE, never about the value. A caller can infer a
// value by observing refusals — an accepted, deliberately narrow channel — so
// nothing may make the refusal more informative than "this rule refused".
Expand Down
Loading
Loading