Skip to content

Commit 48b9329

Browse files
committed
test(lint,metadata-protocol): pin the option visibleWhen root verdict at the build and the object save door (#22157)
Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87ca3bb commit 48b9329

3 files changed

Lines changed: 271 additions & 0 deletions

File tree

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,69 @@ describe('#22032 pass 3 — the object door gives the build\'s option `visibleWh
181181
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
182182
});
183183
});
184+
185+
/**
186+
* #22157 — the option's root verdict, at the object door.
187+
*
188+
* The server's option check (`evaluateOptionVisibility`) binds `record`,
189+
* `previous` and the acting user. An option `visibleWhen` reading `parent` (on
190+
* a detail with exactly one `master_detail`, where the field-rule slots DO
191+
* bind it) published clean through this door and then faulted open on every
192+
* write that picked the option. The verdict lives in the build's option pass,
193+
* which this door runs since #22032's pass 3, so the door's finding is the
194+
* build's. The protocol-level half, through the real `saveMetaItem`, is the
195+
* #22157 block of `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
196+
*/
197+
describe('#22157 — the object door refuses an option `visibleWhen` reading `parent`, as the build does', () => {
198+
const header = {
199+
name: 'fx_header',
200+
label: 'Header',
201+
sharingModel: 'private',
202+
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
203+
};
204+
const line = (visibleWhen: unknown) => ({
205+
name: 'fx_line',
206+
label: 'Line',
207+
sharingModel: 'private',
208+
fields: {
209+
hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' },
210+
x: { type: 'text', label: 'X' },
211+
tier: {
212+
type: 'select',
213+
label: 'Tier',
214+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
215+
},
216+
},
217+
});
218+
const LINE_WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
219+
const PARENT = "parent.status == 'closed'";
220+
221+
it('⭐ LIT — the measured body is REFUSED at the door, located at the option, naming the root', () => {
222+
const result = gateObject(line(PARENT), [header]);
223+
224+
expect(result.rulesRun).toContain('validateStackExpressions');
225+
const errs = expressionFindings(result.errors);
226+
expect(errs, dump(result)).toHaveLength(1);
227+
expect(errs[0]).toMatchObject({ severity: 'error', where: LINE_WHERE, path: LINE_WHERE });
228+
expect(errs[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`");
229+
});
230+
231+
it('⭐ PARITY — the door finding IS the build finding', () => {
232+
const atBuild = buildFindings(header, line(PARENT));
233+
const atDoor = expressionFindings(gateObject(line(PARENT), [header]).errors);
234+
235+
// Non-vacuous: the build refuses it.
236+
expect(atBuild, dump(atBuild)).toHaveLength(1);
237+
expect(atDoor, dump(atDoor)).toEqual(atBuild);
238+
});
239+
240+
for (const body of ["record.x == 'a'", "'org_admin' in current_user.positions"]) {
241+
it(`⭐ CONTROL — \`${body}\` on the same option publishes clean, and the build agrees`, () => {
242+
const result = gateObject(line(body), [header]);
243+
244+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
245+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
246+
expect(buildFindings(header, line(body))).toEqual([]);
247+
});
248+
}
249+
});

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,6 +2005,89 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
20052005
});
20062006
});
20072007

2008+
/**
2009+
* ── The option's root verdict (#22157) ──────────────────────────────────
2010+
*
2011+
* The server's option check (`evaluateOptionVisibility`) binds `record`,
2012+
* `previous` and the acting user, and nothing else. A predicate reading any
2013+
* other root faults on every write that picks the option and is admitted
2014+
* unchecked, so the build refuses it. The measured body is `parent` on a
2015+
* detail with exactly one `master_detail`: the object shape on which the
2016+
* field-rule slots one level up DO bind `parent`, so it is the shape an
2017+
* author copies from.
2018+
*/
2019+
describe('a per-option `visibleWhen` root the option check does not bind is refused (#22157)', () => {
2020+
const detail = (visibleWhen: unknown, readonlyWhen?: unknown) => ({
2021+
objects: [
2022+
{ name: 'fx_header', fields: { status: { type: 'text' } } },
2023+
{
2024+
name: 'fx_line',
2025+
fields: {
2026+
hdr: { type: 'master_detail', reference: 'fx_header' },
2027+
x: { type: 'text', ...(readonlyWhen === undefined ? {} : { readonlyWhen }) },
2028+
parent_code: { type: 'text' },
2029+
tier: {
2030+
type: 'select',
2031+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
2032+
},
2033+
},
2034+
},
2035+
],
2036+
});
2037+
const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
2038+
const PARENT = "parent.status == 'closed'";
2039+
/** What `evaluateOptionVisibility` binds, read off its call (`rule-validator.ts`). */
2040+
const BOUND = ['record', 'previous', 'current_user', 'user', 'ctx', 'os'];
2041+
2042+
it('⭐ refuses `parent` at error, located at the option, naming the option, the field and the root', () => {
2043+
const issues = validateStackExpressions(detail(PARENT));
2044+
expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1);
2045+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT });
2046+
expect(issues[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`");
2047+
});
2048+
2049+
it('⭐ CONTRAST — the same `parent` read on the field\'s own `readonlyWhen` passes: that slot binds it', () => {
2050+
expect(validateStackExpressions(detail("record.x == 'a'", PARENT))).toEqual([]);
2051+
});
2052+
2053+
it('⭐ CONTROL — `record`, `previous` and the acting user under every ADR-0068 spelling pass', () => {
2054+
for (const body of [
2055+
"record.x == 'a'",
2056+
"previous.x == 'a'",
2057+
"'org_admin' in current_user.positions",
2058+
"'org_admin' in user.positions",
2059+
"ctx.user.id != ''",
2060+
"os.user.id != ''",
2061+
"current_user.can('fx_line', 'edit')",
2062+
// A `record` member merely spelled like the refused root.
2063+
"record.parent_code == 'a'",
2064+
]) {
2065+
expect(validateStackExpressions(detail(body)), body).toEqual([]);
2066+
}
2067+
});
2068+
2069+
/**
2070+
* An ALLOWLIST read against the real `SCOPE_ROOTS`, never a copy of it:
2071+
* every platform-wide root outside what the option check binds is
2072+
* refused, one finding each, so a root added to the baseline later is
2073+
* covered the day it lands.
2074+
*/
2075+
it('refuses every `SCOPE_ROOTS` member the option check does not bind, one finding each', () => {
2076+
const unbound = (SCOPE_ROOTS as readonly string[]).filter((r) => !BOUND.includes(r));
2077+
expect(unbound).toContain('parent');
2078+
for (const root of unbound) {
2079+
const issues = validateStackExpressions(detail(`${root}.k == 'a'`));
2080+
expect(issues, root).toHaveLength(1);
2081+
expect(issues[0]!.where, root).toBe(WHERE);
2082+
expect(issues[0]!.message, root).toContain(`reads \`${root}\``);
2083+
}
2084+
});
2085+
2086+
it('gives one finding when the predicate reads two unbound roots', () => {
2087+
expect(validateStackExpressions(detail(`${PARENT} && input.k == 1`))).toHaveLength(1);
2088+
});
2089+
});
2090+
20082091
it('flags a bare-field sharing-rule condition', () => {
20092092
const issues = validateStackExpressions({
20102093
objects: [{ name: 'crm_account', fields: { region: { type: 'text' } } }],

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2210,6 +2210,128 @@ describe('runtime authoring gate on OBJECT writes — the option visibleWhen ver
22102210
});
22112211
});
22122212

2213+
/**
2214+
* [#22157] The object save door refuses a field option's `visibleWhen` that
2215+
* reads `parent`, as `os build` does.
2216+
*
2217+
* The server's option check (`evaluateOptionVisibility` in ObjectQL) binds
2218+
* `record`, `previous` and the acting user, never `parent`. Measured before
2219+
* this change: on a detail with exactly one `master_detail` (where the
2220+
* field-rule slots DO bind `parent`), an option whose `visibleWhen` read
2221+
* `parent.status` saved through this door with a 200 and landed `active`, and
2222+
* every write that then picked the option faulted and was admitted unchecked.
2223+
*
2224+
* The refusal is in `@objectstack/lint` (the option pass's root verdict); no
2225+
* code here moves. Pinned through the REAL `saveMetaItem`, the header stored
2226+
* in the registry so the write is judged against the universe it lands in:
2227+
*
2228+
* (a) the measured body is refused on an active save — a 422
2229+
* `INVALID_METADATA` carrying the build's located finding — and nothing
2230+
* lands;
2231+
* (b) the same body still saves as a DRAFT (drafts are never gated);
2232+
* (c) control: an option reading `record` saves, and the row lands;
2233+
* (d) the door's issue and the build's finding are the same finding.
2234+
*
2235+
* ⚠️ As in the blocks above: this package reaches `@objectstack/lint` through
2236+
* its built `dist/`, so an edit to the rule is invisible here until
2237+
* `pnpm --filter @objectstack/lint build` has run.
2238+
*/
2239+
describe('runtime authoring gate on OBJECT writes — an option visibleWhen reading parent (#22157)', () => {
2240+
const header = {
2241+
name: 'fx_header',
2242+
label: 'Header',
2243+
sharingModel: 'private',
2244+
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
2245+
};
2246+
const line = (visibleWhen: unknown) => ({
2247+
name: 'fx_line',
2248+
label: 'Line',
2249+
sharingModel: 'private',
2250+
fields: {
2251+
hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' },
2252+
x: { type: 'text', label: 'X' },
2253+
tier: {
2254+
type: 'select',
2255+
label: 'Tier',
2256+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
2257+
},
2258+
},
2259+
});
2260+
/** The card's measured body. */
2261+
const PARENT = "parent.status == 'closed'";
2262+
/** Where the build locates it — the option the author edits. */
2263+
const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
2264+
2265+
/** A protocol whose live registry holds the header — the stored universe. */
2266+
const hostWithHeader = () => {
2267+
const { engine, rows } = makeStubEngine();
2268+
engine.registry.listItems = (type: string) => (type === 'object' ? [header] : []);
2269+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any;
2270+
return { protocol, rows };
2271+
};
2272+
const lineRows = (rows: Map<string, Row>) =>
2273+
Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_line');
2274+
const saveLine = (protocol: any, item: unknown, extra: Record<string, unknown> = {}) =>
2275+
protocol.saveMetaItem({ type: 'object', name: 'fx_line', item, ...extra });
2276+
const buildFindings = (obj: unknown) => {
2277+
const stack = { objects: [header, obj] };
2278+
return runAuthoringRules('build', { normalized: stack, parsed: stack })
2279+
.filter((f) => f.rule === EXPRESSION_INVALID);
2280+
};
2281+
2282+
it('(a) REFUSES an active save with a 422 carrying the build\'s located finding, and nothing lands', async () => {
2283+
const { protocol, rows } = hostWithHeader();
2284+
2285+
const err = await saveLine(protocol, line(PARENT)).catch((e: any) => e);
2286+
2287+
expect(err, 'the save resolved — the door still accepts the option predicate').toBeInstanceOf(Error);
2288+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
2289+
expect(err.rulesRun).toContain('validateStackExpressions');
2290+
const issues = err.issues.filter((i: any) => i.rule === EXPRESSION_INVALID);
2291+
expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1);
2292+
expect(issues[0].path).toBe(WHERE);
2293+
expect(issues[0].severity).toBe('error');
2294+
// The named subject: the option, the field and the root.
2295+
expect(issues[0].message).toContain("option 'gold' on field 'tier' reads `parent`");
2296+
expect(lineRows(rows)).toEqual([]);
2297+
});
2298+
2299+
it('(b) the same body still saves as a DRAFT — drafts are never gated', async () => {
2300+
const { protocol, rows } = hostWithHeader();
2301+
2302+
await expect(saveLine(protocol, line(PARENT), { mode: 'draft' })).resolves.toMatchObject({ success: true });
2303+
2304+
expect(lineRows(rows).map((r) => r.state)).toEqual(['draft']);
2305+
});
2306+
2307+
it('(c) control: an option reading `record` saves, and the row lands', async () => {
2308+
const { protocol, rows } = hostWithHeader();
2309+
2310+
const result = await saveLine(protocol, line("record.x == 'a'"));
2311+
2312+
expect(result.success).toBe(true);
2313+
expect(lineRows(rows).map((r) => r.state)).toEqual(['active']);
2314+
});
2315+
2316+
it('(d) the door and `os build` give the SAME finding', async () => {
2317+
const { protocol } = hostWithHeader();
2318+
const err = await saveLine(protocol, line(PARENT)).catch((e: any) => e);
2319+
const atDoor = (err.issues ?? []).filter((i: any) => i.rule === EXPRESSION_INVALID);
2320+
2321+
const atBuild = buildFindings(line(PARENT));
2322+
2323+
// Non-vacuous on both sides.
2324+
expect(atBuild).toHaveLength(1);
2325+
expect(atBuild[0]!.severity).toBe('error');
2326+
expect(atDoor).toHaveLength(1);
2327+
for (const key of ['rule', 'where', 'path', 'message', 'hint'] as const) {
2328+
expect(atDoor[0][key], `door and build disagree on '${key}'`).toBe(atBuild[0]![key]);
2329+
}
2330+
// And the control is clean at the build too, not just at the door.
2331+
expect(buildFindings(line("record.x == 'a'"))).toEqual([]);
2332+
});
2333+
});
2334+
22132335
/**
22142336
* [#22118] The object save door judges a stored sibling's finding against the
22152337
* STORED universe — the registry's objects WITH the written object's stored

0 commit comments

Comments
 (0)