From 2547ac62040f77a403a838e717ea1b9ca7aa2b1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:27:02 +0000 Subject: [PATCH 1/8] test(dogfood): pin a formula reading a field its lookup target does not declare (wip, red on main) Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- ...p-undeclared-related-field.dogfood.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts diff --git a/packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts b/packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts new file mode 100644 index 00000000000..157f820fe18 --- /dev/null +++ b/packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24410] A formula that reads, through a lookup, a field the lookup's target +// does not declare — on a real boot: the real `ObjectQL`, SQL driver, REST, +// auth and metadata layers. +// +// ## The two halves +// +// - The save door. `PUT /api/v1/meta/object/:name` refuses a formula reading +// `record.book.name` when `rhf_book` declares no `name`, under the existing +// `EXPRESSION_INVALID` rule, naming the field and the target object. The +// same object with the formula reading a declared field saves. +// - The read. A formula already stored with such a read (here: declared in +// code, which this door never judged) reads `null` with its fault report, +// and a VALID sibling formula reading through the SAME lookup is served +// exactly as it is when it is read alone (`?fields=`). +// +// Fixtures are synthetic. ⚠️ No test title states a value. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const SYS = { context: { isSystem: true } } as const; +const BOOK_TITLE = 'RHFBOOK71'; +const READER_NAME = 'RHFREADER72'; +/** The valid sibling's expression, and what it reads for the fixture row. */ +const VALID = "joinNonEmpty([record.book.title, record.reader.reader_name], ' · ')"; +const VALID_VALUE = `${BOOK_TITLE} · ${READER_NAME}`; +/** Reads a field `rhf_book` does not declare. */ +const UNDECLARED = 'record.book.name'; + +const object = (name: string, fields: Record) => + ObjectSchema.create({ name, label: name, pluralLabel: name, sharingModel: 'public_read_write', fields } as never); + +const loanFields = (extra: Record = {}) => ({ + label: Field.text({ label: 'Label' }), + book: Field.lookup('rhf_book', { label: 'Book' }), + reader: Field.lookup('rhf_reader', { label: 'Reader' }), + summary: Field.formula({ label: 'Summary', expression: VALID, returnType: 'text' } as never), + ...extra, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.formula-hop-undeclared', namespace: 'rhf', version: '0.0.0', type: 'app', + name: 'Formula Hop Undeclared Field Fixture', + description: 'A loan whose formulas read through a lookup to a book and a reader.', + }, + objects: [ + object('rhf_book', { title: Field.text({ label: 'Title' }) }), + object('rhf_reader', { reader_name: Field.text({ label: 'Reader Name' }) }), + // The stored bad formula: declared in code, beside the valid sibling. + object('rhf_loan', loanFields({ + bad_title: Field.formula({ label: 'Bad Title', expression: UNDECLARED, returnType: 'text' } as never), + })), + ], +}); + +/** The object body a `PUT /meta/object/:name` writes: plain JSON, as Studio or an AI author sends it. */ +const saveBody = (name: string, expression: string) => ({ + name, + label: name, + pluralLabel: name, + sharingModel: 'public_read_write', + fields: { + label: { type: 'text', label: 'Label' }, + book: { type: 'lookup', label: 'Book', reference: 'rhf_book' }, + summary: { type: 'formula', label: 'Summary', expression, returnType: 'text' }, + }, +}); + +type Row = Record; + +describe('[#24410] a formula reading a field its lookup target does not declare', () => { + let stack: VerifyStack; + let token = ''; + let loanId = ''; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const json: any = await res.json().catch(() => ({})); + return { status: res.status, json }; + }; + const rowsOf = (json: any): Row[] => (Array.isArray(json?.data) ? json.data : json?.data?.records ?? json?.records ?? []); + + beforeAll(async () => { + stack = await bootStack(fixtureStack as unknown as Parameters[0]); + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + token = await stack.signIn(); + const book = await ql.insert('rhf_book', { title: BOOK_TITLE }, SYS); + const reader = await ql.insert('rhf_reader', { reader_name: READER_NAME }, SYS); + loanId = String((await ql.insert('rhf_loan', { label: 'loan', book: book.id, reader: reader.id }, SYS)).id); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('the save door refuses the read, naming the field and the target object', async () => { + const saved = await call('PUT', '/meta/object/rhf_loan_saved', saveBody('rhf_loan_saved', UNDECLARED)); + expect(saved.status, JSON.stringify(saved.json)).toBe(422); + const text = JSON.stringify(saved.json); + expect(text).toContain('EXPRESSION_INVALID'); + expect(text).toContain("field 'summary' expression"); + expect(text).toMatch(/`name`[^"]*`rhf_book`/); + }); + + it('control: the same object reading a declared field of the target saves', async () => { + const saved = await call('PUT', '/meta/object/rhf_loan_ok', saveBody('rhf_loan_ok', 'record.book.title')); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + }); + + it('a stored bad formula reads null while its valid sibling is served as it is alone', async () => { + const alone = await call('GET', '/data/rhf_loan?fields=summary'); + expect(alone.status, JSON.stringify(alone.json)).toBe(200); + // One loan exists; a `fields=` projection carries no `id`. + expect(rowsOf(alone.json), JSON.stringify(alone.json)).toHaveLength(1); + const aloneRow = rowsOf(alone.json)[0]; + // Armed: the valid formula hydrates through both lookups when read alone. + expect(aloneRow?.summary, JSON.stringify(alone.json)).toBe(VALID_VALUE); + + const list = await call('GET', '/data/rhf_loan'); + expect(list.status, JSON.stringify(list.json)).toBe(200); + const listed = rowsOf(list.json).find((r) => String(r.id) === loanId); + expect(listed?.bad_title, JSON.stringify(list.json)).toBeNull(); + expect(listed?.summary, JSON.stringify(list.json)).toBe(VALID_VALUE); + + const one = await call('GET', `/data/rhf_loan/${loanId}`); + expect(one.status, JSON.stringify(one.json)).toBe(200); + const row: Row = one.json?.data?.record ?? one.json?.record ?? one.json?.data ?? one.json; + expect(row.bad_title, JSON.stringify(one.json)).toBeNull(); + expect(row.summary, JSON.stringify(one.json)).toBe(VALID_VALUE); + }); +}); From 20a37e397149ed313da2e43531ddcd9943fc9c71 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:28:53 +0000 Subject: [PATCH 2/8] fix(objectql): one formula's undeclared related field no longer nulls its siblings The formula read merged every formula's hop columns per reference and the shared loader answered the whole reference undeclared-field when any column was undeclared, so a valid sibling reading through the same lookup read null with the bad formula's fault. The undeclared columns now leave the shared read and are judged per formula. Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- .../src/engine-formula-one-hop.test.ts | 18 +++++ packages/objectql/src/engine.ts | 78 +++++++++++++++++-- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/engine-formula-one-hop.test.ts b/packages/objectql/src/engine-formula-one-hop.test.ts index b880f705b14..e08ad56dbcc 100644 --- a/packages/objectql/src/engine-formula-one-hop.test.ts +++ b/packages/objectql/src/engine-formula-one-hop.test.ts @@ -289,6 +289,24 @@ describe('#24197 — a formula field reads one hop through a reference', () => { expect(row?.title).toBeNull(); expect(faultsOn('lib_loan', 'title')[0].message).toContain("'lib_book' declares no such field"); }); + + it('[#24410] an undeclared related field nulls only its own formula: a sibling through the same reference is served as it is alone', async () => { + registerLoan(TITLE, { bad: { type: 'formula', expression: 'record.book.isbn' } }); + const alone = await engine.find('lib_loan', { fields: ['title'], limit: 4, context: READER }); + d.calls.length = 0; + const rows = await engine.find('lib_loan', { limit: 4, context: READER }); + expect(rows.map((r) => r.title)).toEqual(alone.map((r) => r.title)); + expect(rows.map((r) => r.title)).toEqual(['Dune · Ann', 'Emma · Ann', 'Ulysses · Bo', 'Dune · Bo']); + expect(rows.map((r) => r.bad)).toEqual([null, null, null, null]); + // The undeclared column never enters the shared read; ONE book read still serves the page. + expect(relatedReads('lib_book')).toBe(1); + expect([...d.calls.find((c) => c.object === 'lib_book')!.ast.fields].sort()).toEqual(['id', 'title']); + // Each formula's own report: the bad one names its column, the sibling reports nothing. + expect(faultsOn('lib_loan', 'title')).toEqual([]); + const fault = faultsOn('lib_loan', 'bad'); + expect(fault).toHaveLength(1); + expect(fault[0].message).toContain("the formula reads 'isbn' through `book` (object 'lib_book')"); + }); }); describe('one hop is the depth', () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 7ca9a353fe8..fa07e6e44d8 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2651,8 +2651,36 @@ type FormulaFaultSink = (field: string, error: EvalError) => void; * ({@link ObjectQL.resolveFormulaRelated}), or — when this read serves no hop * at all — the sentence saying why, which every traversing formula of the read * then reports. + * + * [#24410] `undeclared` is decided per FORMULA, never per reference: the + * related fields a reference's target does not declare, which the shared read + * never asks for. A formula naming one of them reads `null` with the + * `undeclared-field` reason; a sibling reading only declared fields through the + * same reference is served from the shared read exactly as it is alone. + */ +type FormulaRelated = { + readonly bindings: (row: Record) => RelatedRecordBinding | undefined; + readonly undeclared: ReadonlyMap }>; +} | string; + +/** + * [#24410] The named related fields a reference's target does not DECLARE — + * the one reading both seams that serve a hop make of it: the shared loader + * ({@link ObjectQL.resolveOneHopRelated}) and the formula read, which judges + * them per formula ({@link ObjectQL.resolveFormulaRelated}). + * + * [#8215] The PRIMARY KEY counts as declared, though no field map carries it: + * `record..id` is the repair the traversal conflict refusal prescribes. An + * unknown target (`declared` absent) declares nothing to judge against, so + * nothing is undeclared. */ -type FormulaRelated = ((row: Record) => RelatedRecordBinding | undefined) | string; +function undeclaredRelatedFields( + declared: Record | undefined, + named: Iterable, +): string[] { + if (!declared) return []; + return [...named].filter((n) => n !== SystemFieldName.ID && !(n in declared)); +} /** [#24197] Why a hop was not served where no reader supplied one. */ const FORMULA_HOP_NOT_SERVED = @@ -2687,8 +2715,22 @@ function formulaHopScope( hops: ReadonlyMap>, related: FormulaRelated | undefined, ): { ok: true; record: Record; empty: string[] } | { ok: false; reason: string } { - if (typeof related !== 'function') return { ok: false, reason: related ?? FORMULA_HOP_NOT_SERVED }; - const binding = related(rec); + if (related === undefined || typeof related === 'string') { + return { ok: false, reason: related ?? FORMULA_HOP_NOT_SERVED }; + } + // [#24410] This formula's own undeclared reads first, before any row: they + // are a fault of the expression, whatever the record holds. + for (const [field, named] of hops) { + const missing = related.undeclared.get(field); + const own = missing ? [...named].filter((n) => missing.fields.has(n)) : []; + if (missing && own.length > 0) { + return { + ok: false, + reason: formulaHopRefusal(field, { object: missing.object, unavailable: 'undeclared-field', undeclaredFields: own }, named), + }; + } + } + const binding = related.bindings(rec); let copy: Record | undefined; const empty: string[] = []; for (const [field, named] of hops) { @@ -6262,8 +6304,30 @@ export class ObjectQL implements IObjectQLEngine { + 'formula resolves ONE hop: a formula read this way does not read through a reference of its own ' + '(a second hop). Store the value on this object as a field of its own to read it from there'; } - const lookup = await this.resolveOneHopRelated(schema, wanted, rows, context, 'caller'); - return (row) => lookup(row); + // [#24410] The read set per reference is the UNION of every formula's + // columns, and the loader answers a whole reference `undeclared-field` when + // any column of it is undeclared — so one formula's misspelt column nulled + // every sibling reading through the same reference. The undeclared columns + // are taken out of the shared read here and judged per formula + // ({@link formulaHopScope}); the loader reads the declared rest. + const fields = (schema?.fields ?? {}) as Record; + const undeclared = new Map }>(); + for (const [fk, named] of wanted) { + const target = referenceTargetOf(fields[fk]); + // The loader's own verdicts stand for these: no target, or a hop into + // the stored-metadata-body family, which is never read. + if (!target || isStoredMetadataBodyObject(target)) continue; + const declared = (this._registry.getObject(target) as { fields?: Record } | undefined)?.fields; + const missing = undeclaredRelatedFields(declared, named); + if (missing.length === 0) continue; + undeclared.set(fk, { object: target, fields: new Set(missing) }); + for (const name of missing) named.delete(name); + if (named.size === 0) wanted.delete(fk); + } + const lookup = wanted.size > 0 + ? await this.resolveOneHopRelated(schema, wanted, rows, context, 'caller') + : () => undefined; + return { bindings: (row) => lookup(row), undeclared }; } /** @@ -10277,9 +10341,7 @@ export class ObjectQL implements IObjectQLEngine { // // ⛔ This widens the READ SET by nothing: `id` is already unconditionally // in the projection below, because the by-id map is keyed on it. - const declaredHere = (n: string): boolean => - n === SystemFieldName.ID || !!declared && n in declared; - const undeclared = declared ? named.filter((n) => !declaredHere(n)) : []; + const undeclared = undeclaredRelatedFields(declared, named); if (undeclared.length > 0) { resolved.set(fk, { object: target, byId: new Map(), undeclared }); continue; From d74c110cb1a02665eb0a27c17e440bfdd2a30790 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:30:49 +0000 Subject: [PATCH 3/8] fix(lint): refuse a formula hop to a field the lookup's target does not declare judgeFieldFormula validated the expression against the source object only, so record.book.name saved with a 200 when the book declares no name, and the formula then read null on every row. The field-formula pass now refuses it under EXPRESSION_INVALID, naming the field and the target, at the build and at the object save door alike. Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- ...runtime-gate.object-formula-writes.test.ts | 40 +++++++++++++ ...lidate-expressions.formula-one-hop.test.ts | 28 +++++++++ packages/lint/src/validate-expressions.ts | 58 ++++++++++++++++++- 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/runtime-gate.object-formula-writes.test.ts b/packages/lint/src/runtime-gate.object-formula-writes.test.ts index 778bc1448c0..e15f93de437 100644 --- a/packages/lint/src/runtime-gate.object-formula-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-formula-writes.test.ts @@ -117,6 +117,46 @@ describe('#22019 — the object door dispatches the build\'s expression rule', ( }); }); +describe('#24410 — the object door refuses a formula hop to a field the target does not declare', () => { + /** The lookup's target, delivered by ANOTHER package the written one depends on. */ + const book = { + name: 'fx_book', label: 'Book', sharingModel: 'private', _packageId: 'com.example.library', + fields: { title: { type: 'text', label: 'Title' } }, + }; + const loan = (expression: string) => ({ + name: 'fx_loan', + label: 'Loan', + sharingModel: 'private', + fields: { + book: { type: 'lookup', label: 'Book', reference: 'fx_book' }, + summary: { type: 'formula', label: 'Summary', expression }, + }, + }); + const gateLoan = (expression: string) => runRuntimeAuthoringRules({ + type: 'object', + item: loan(expression), + context: { objects: [book] }, + packageScope: { packageId: 'com.example.loans', dependencies: ['com.example.library'] }, + }); + const LOAN_WHERE = "object 'fx_loan' · field 'summary' expression"; + + it('⭐ LIT — the read is REFUSED under EXPRESSION_INVALID, naming the field and the target from the other package', () => { + const result = gateLoan('record.book.name'); + + const errs = expressionFindings(result.errors); + expect(errs, dump(result)).toHaveLength(1); + expect(errs[0]).toMatchObject({ severity: 'error', where: LOAN_WHERE, path: LOAN_WHERE }); + expect(errs[0]!.message).toContain('unknown field `name` on `fx_book`'); + }); + + it('⭐ CONTROL — a read of a field the target declares publishes clean', () => { + const result = gateLoan('record.book.title'); + + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); + }); +}); + describe('#22019 / #22032 — every pass over the object\'s body judges on this door, and no pass over another collection', () => { /** * One body carrying a fault in EVERY pass over the object's own body — the diff --git a/packages/lint/src/validate-expressions.formula-one-hop.test.ts b/packages/lint/src/validate-expressions.formula-one-hop.test.ts index a7b464c8c95..f4c5941b4e8 100644 --- a/packages/lint/src/validate-expressions.formula-one-hop.test.ts +++ b/packages/lint/src/validate-expressions.formula-one-hop.test.ts @@ -87,6 +87,34 @@ describe('a formula expression reads one hop through a reference (#24197)', () = expect(errors[0].message).toContain('`record.reader` is read BOTH through the relationship'); expect(errors[0].message).toContain("`record.reader.?.orValue('…')`"); }); + + it('[#24410] refuses a read of a field the target does not declare, naming the field and the target', () => { + const errors = errorsOf("record.book.name + ' · ' + record.reader.name"); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('unknown field `name` on `hop_book`'); + expect(errors[0].message).toContain('through the reference `record.book`'); + expect(errors[0].message).toContain("declare 'name' on 'hop_book' — not on 'hop_loan'"); + expect(errors[0].source).toBe("record.book.name + ' · ' + record.reader.name"); + }); + + it('[#24410] admits the primary key and a column the platform injects on the target', () => { + expect(errorsOf('record.book.id')).toEqual([]); + expect(errorsOf('string(record.book.created_at) + record.book.title')).toEqual([]); + }); + + it('[#24410] does not judge a target the stack does not carry', () => { + const stray = { + name: 'hop_loan', + label: 'Loan', + fields: { + book: { type: 'lookup', label: 'Book', reference: 'hop_elsewhere' }, + title: { type: 'formula', label: 'Title', expression: 'record.book.name' }, + }, + }; + const errors = validateStackExpressions(specValid({ objects: [book, shelf, reader, stray] })) + .filter((i) => i.where === WHERE && (i.severity ?? 'error') === 'error'); + expect(errors).toEqual([]); + }); }); /** diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index d9e2113fc57..97bf3b03ac5 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -115,7 +115,7 @@ import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automatio // hand-written notion of "blank" here — that drift is what #15662 built the // shared refusal to prevent. import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared'; -import { referenceCarrierOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; +import { referenceCarrierOf, referenceTargetOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; // [#22394] The acting user's declared members — the contract half of the option // verdict's member allowlist for `current_user` and its ADR-0068 aliases. Read @@ -1768,6 +1768,20 @@ function storedMetadataBodyHopMessage( + 'stored id, if the id is what you need.'; } +/** + * [#24410] The refusal of a formula's one-hop read of a field the reference's + * target does not declare. It opens in the shared validator's `unknown field` + * 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. + */ +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.`; +} + /** * [#17495] Why a structural condition whose SHAPE is fine is still not * authorable: its source is blank. The author-time twin of the gate #17322 put @@ -1948,6 +1962,46 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression } }; + /** + * [#24410] Refuse a formula's one-hop read of a field the reference's target + * does not declare (`record.book.name` where the book declares no `name`). + * ObjectQL's one-hop loader reads only declared columns of the target, so as + * 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. + * + * 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 + * target outside the stack (or the door's snapshot, which carries the live + * registry's objects, another package's included) has no index here and is + * 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 source = celSourceOf(raw); + if (!source) return; + const traversals = analyzeRelationshipTraversals(source, 'record')?.traversals; + if (!traversals || traversals.size === 0) return; + const own = objects.find((o) => o.name === objectName); + if (!own) return; + const defs = new Map(fieldEntries(own)); + for (const [field, columns] of traversals) { + const target = referenceTargetOf(defs.get(field))?.trim(); + if (!target || isStoredMetadataBodyObject(target)) continue; + const declared = fieldIndex.get(target); + if (!declared) continue; + for (const column of [...columns].sort()) { + if (column === 'id' || declared.includes(column)) continue; + issues.push({ + where, + message: undeclaredHopFieldMessage(objectName, field, target, column), + source, + severity: 'error', + }); + } + } + }; + // [#8116] object name → the injected anchors registered with NO storage // behind them (non-empty only for ADR-0015 `external` objects). The other // half of {@link buildFieldIndex}'s #5378 widening: the anchors ARE @@ -2848,6 +2902,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' }); 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); // [#8116] Formulas read the same record binding the predicates do, so // a `record.` read inside one degrades identically. warnUnprovisionedAnchors(fieldWhere, f.expression, objectName); From 304417624eae98fdc002d42f2236ffb160e70536 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:33:05 +0000 Subject: [PATCH 4/8] test(dogfood): assert the save refusal's envelope for an undeclared formula hop field Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- ...rmula-hop-undeclared-related-field.dogfood.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts b/packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts index 157f820fe18..756586b4700 100644 --- a/packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts @@ -101,10 +101,13 @@ describe('[#24410] a formula reading a field its lookup target does not declare' it('the save door refuses the read, naming the field and the target object', async () => { const saved = await call('PUT', '/meta/object/rhf_loan_saved', saveBody('rhf_loan_saved', UNDECLARED)); expect(saved.status, JSON.stringify(saved.json)).toBe(422); - const text = JSON.stringify(saved.json); - expect(text).toContain('EXPRESSION_INVALID'); - expect(text).toContain("field 'summary' expression"); - expect(text).toMatch(/`name`[^"]*`rhf_book`/); + expect(saved.json?.code, JSON.stringify(saved.json)).toBe('INVALID_METADATA'); + const issues = (saved.json?.issues ?? []) as Row[]; + // The existing `EXPRESSION_INVALID` rule, located at the key the author edits. + expect(issues.map((i) => [i.rule, i.where]), JSON.stringify(saved.json)).toEqual([ + ['expression-invalid', "object 'rhf_loan_saved' · field 'summary' expression"], + ]); + expect(String(issues[0].message)).toMatch(/^unknown field `name` on `rhf_book`/); }); it('control: the same object reading a declared field of the target saves', async () => { From 0485c6811220666946d163221f39efd5fabac662 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:38:28 +0000 Subject: [PATCH 5/8] chore(changeset): lint refusal and objectql sibling isolation for a formula hop to an undeclared field Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- ...24410-lint-formula-hop-undeclared-field.md | 19 +++++++++++++++++++ ...-objectql-formula-hop-sibling-isolation.md | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 .changeset/24410-lint-formula-hop-undeclared-field.md create mode 100644 .changeset/24410-objectql-formula-hop-sibling-isolation.md diff --git a/.changeset/24410-lint-formula-hop-undeclared-field.md b/.changeset/24410-lint-formula-hop-undeclared-field.md new file mode 100644 index 00000000000..b56bda27ee3 --- /dev/null +++ b/.changeset/24410-lint-formula-hop-undeclared-field.md @@ -0,0 +1,19 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: a formula that reads, through a reference, a field the reference's target does not declare is refused where it is written + +Clause-②: no (narrowing) + + + +**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The field-formula pass judges the field a formula 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`. `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. + +- FROM: `record.book.name`, where `book` is a lookup to an object that declares no `name`, saved with a 200 and read `null` on every row. TO: refused (`422 INVALID_METADATA` at the save door). The message names the field and the target: "unknown field `name` on ``". +- `record.book.title`, where the target declares `title`, 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. + +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 formula. diff --git a/.changeset/24410-objectql-formula-hop-sibling-isolation.md b/.changeset/24410-objectql-formula-hop-sibling-isolation.md new file mode 100644 index 00000000000..5b8665973ed --- /dev/null +++ b/.changeset/24410-objectql-formula-hop-sibling-isolation.md @@ -0,0 +1,17 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a formula reading a field its reference's target does not declare no longer makes the other formulas reading through the same reference read null + +Clause-②: no (narrowing) + + + +The formula read loads, once per page, every column the object's formulas read through each reference. When one formula named a field the target does not declare, the whole reference was answered as undeclared. Every formula reading through it then read `null` and reported the other formula's field. + +- The undeclared fields are left out of that shared read and judged for each formula on its own. +- A formula that names one reads `null`, with a fault report naming only its own undeclared fields. +- A formula that reads only declared fields through the same reference gets the value it gets when read alone (`?fields=`). + +What to do. Nothing. A formula that read `null` this way now reads its value. To find the formula at fault, re-save the object: the save door refuses the misspelt read and names the field. From 041562666b5b80dcc6973cf0b95a8c21bd6b7092 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:51:52 +0000 Subject: [PATCH 6/8] fix(lint): read the undeclared-hop refusal through the tabled obj/def receivers The #5017 receiver scan reads every member access in the rule's source; the closure now walks the object's fields through the receivers the scan already tables instead of adding new ones. Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- packages/lint/src/validate-expressions.ts | 31 +++++++++++------------ 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 97bf3b03ac5..82a510dfb52 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1982,22 +1982,21 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression if (!source) return; const traversals = analyzeRelationshipTraversals(source, 'record')?.traversals; if (!traversals || traversals.size === 0) return; - const own = objects.find((o) => o.name === objectName); - if (!own) return; - const defs = new Map(fieldEntries(own)); - for (const [field, columns] of traversals) { - const target = referenceTargetOf(defs.get(field))?.trim(); - if (!target || isStoredMetadataBodyObject(target)) continue; - const declared = fieldIndex.get(target); - if (!declared) continue; - for (const column of [...columns].sort()) { - if (column === 'id' || declared.includes(column)) continue; - issues.push({ - where, - message: undeclaredHopFieldMessage(objectName, field, target, column), - source, - severity: 'error', - }); + for (const obj of objects) { + if (obj.name !== objectName) continue; + for (const [field, def] of fieldEntries(obj)) { + const columns = 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), + source, + severity: 'error', + }); + } } } }; From 2293b1c4512ebd648e2e9698bf822ad7fc29cb19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:02:16 +0000 Subject: [PATCH 7/8] test(objectql): give the formula read's referenceTargetOf() use its federated disposition Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- .../objectql/src/federated-injected-column-readers.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 93b1f08bf0c..273666e74a7 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -185,6 +185,12 @@ const READERS: Record = { disposition: 'exempt', why: 'decides the routing beside the federated test, on the related object, never as a filter', }, + // [#24410] The formula read names each reference's target to judge, per + // formula, the related fields that target does not declare. + 'engine.ts#resolveFormulaRelated :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: "judges a formula's own hop against the target's declared field map; no row is read here", + }, 'engine.ts#resolveSystemInsertOrganization :: isFederatedObject()': { disposition: 'exempt', why: 'a system insert into a federated object is never stamped with an organization', From 362cd23e347656b16b605a7af60b5aeb88706e3e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 06:24:15 +0000 Subject: [PATCH 8/8] fix(core,changeset): classify the lint's referenceTargetOf() caller; the objectql changeset declares no narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The [#22661] census requires a CALLERS row for every source that calls the reference-target arbiter; validate-expressions.ts now does, as authoring-time static analysis. The objectql changeset is a patch and narrows nothing, so its Clause-② line carries no arm; the lint changeset names the objectExtensions blind spot at build time. Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt Co-authored-by: Claude --- .changeset/24410-lint-formula-hop-undeclared-field.md | 2 +- .changeset/24410-objectql-formula-hop-sibling-isolation.md | 2 +- .../src/security/second-object-read-exposure.pin.test.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/24410-lint-formula-hop-undeclared-field.md b/.changeset/24410-lint-formula-hop-undeclared-field.md index b56bda27ee3..d974b833ce8 100644 --- a/.changeset/24410-lint-formula-hop-undeclared-field.md +++ b/.changeset/24410-lint-formula-hop-undeclared-field.md @@ -16,4 +16,4 @@ The field-formula pass judges the field a formula reads one hop through a refere - `record.book.title`, where the target declares `title`, 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. -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 formula. +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 formula. 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. diff --git a/.changeset/24410-objectql-formula-hop-sibling-isolation.md b/.changeset/24410-objectql-formula-hop-sibling-isolation.md index 5b8665973ed..5c5eea11764 100644 --- a/.changeset/24410-objectql-formula-hop-sibling-isolation.md +++ b/.changeset/24410-objectql-formula-hop-sibling-isolation.md @@ -4,7 +4,7 @@ fix(objectql): a formula reading a field its reference's target does not declare no longer makes the other formulas reading through the same reference read null -Clause-②: no (narrowing) +Clause-②: no diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index aefc2585b25..9766670d8f9 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -201,6 +201,11 @@ const CALLERS: Record = { kind: 'not-a-served-read', reason: 'authoring-time static analysis of metadata; reads no record', }, + // [#24410] A formula's one-hop read judged against its target's declared fields. + 'packages/lint/src/validate-expressions.ts': { + kind: 'not-a-served-read', + reason: 'authoring-time static analysis of metadata; reads no record', + }, 'packages/metadata-protocol/src/seed-loader.ts': { kind: 'not-a-served-read', reason: "resolves a seed's references at install, under the system identity; serves nothing to a caller",