Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/22032-object-save-door-option-visible-when.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/lint": minor
"@objectstack/metadata-protocol": minor
---

fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032)

Clause-②: no (narrowing)

`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced.

The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. This supersedes the earlier #22032 entries' line that option `visibleWhen` is not judged at this door.

**BREAKING — what moves for consumers.**

- An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories.

**Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.

**Unchanged.**

- `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before.
- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged.
- The object's own action predicates (`actions[].visible`, `actions[].disabled`) are still not judged at this door. `os build` judges them, and the door does not, as before.
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
- Measured before crossing: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door.
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at the object save door of a field option's visibleWhen predicate the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose option predicate the validator refuses keeps loading until it is next saved, and the repair is the author's edit of the predicate, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
16 changes: 14 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,8 @@ export interface AuthoringRuleContext {
*
* [#22019] One other rule reads it, on that argument: `validateStackExpressions`
* is one entry over several PASSES, and an `object` write is admitted for its
* field-formula pass and (#22032) its validation-rule and field-rule-slot
* passes alone (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
* field-formula pass and (#22032) its validation-rule, field-rule-slot and
* per-option `visibleWhen` passes alone (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
* `runtimeTypes` can say that an object write reaches the rule; it cannot say
* which of the rule's passes judge that write.
*/
Expand Down Expand Up @@ -646,6 +646,18 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// errors and 0 warnings for the pass, and 0 door errors and 0 advisories
// at the door's own snapshot shape, against a refusal at each for the
// card's body in the same harness.
//
// [#22032, pass 3] The per-option `visibleWhen` pass joins the object
// door: every `fields[].options[].visibleWhen`, with the reference-traversal
// refusal, and `current_user` accepted there as the build accepts it. The
// object's own `actions[]` predicates stay fenced. No entry-level change.
// MEASURED first, at both the raw and the parsed shape: every option
// predicate the repository ships — 5 options on 2 fields of 1 object
// (examples: app-showcase `showcase_cascade`, four `record.country`
// cascades and one `current_user.positions` role gate), over 118 objects
// → 0 build errors and 0 warnings for the pass, and 0 door errors and 0
// advisories at the door's own snapshot shape, against a refusal at each
// for a bare `amount > 1` option in the same harness.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'action', 'hook', 'object'],
run: (stack, ctx) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
* `runStackExpressionPasses` admits the field-rule slots on an object write,
* at the build's own position in the field walk, so the door's finding IS the
* build's finding — rule, location, message and hint. The per-option
* `visibleWhen` (pass 3) and the object's own action predicates (pass 4) stay
* fenced; that pin is in `runtime-gate.object-formula-writes.test.ts`.
* `visibleWhen` joined the door in pass 3
* (`runtime-gate.object-option-visibility-writes.test.ts`); the object's own
* action predicates (pass 4) stay fenced, and that pin is in
* `runtime-gate.object-formula-writes.test.ts`.
*
* The protocol-level half — the same verdict through the real `saveMetaItem`,
* `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 2 block of
Expand Down
30 changes: 16 additions & 14 deletions packages/lint/src/runtime-gate.object-formula-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@
* `object` joins `runtimeTypes`, and the gate's `runtimeWriteType` reaches the
* rule (`runStackExpressionPasses`), which on an object write runs the
* field-formula pass — and, since #22032's pass 1, the validation-rule pass
* (its pins: `runtime-gate.object-validation-writes.test.ts`), and since its
* pass 2 the field-rule slots (`runtime-gate.object-field-rule-writes.test.ts`).
* Every other object-borne pass the build runs — option `visibleWhen`, the
* object's own action predicates — is FENCED off this door by name, and the
* fence is pinned below with the build still flagging the same body, so a
* later widening moves that line consciously rather than by drift.
* (its pins: `runtime-gate.object-validation-writes.test.ts`), since its
* pass 2 the field-rule slots (`runtime-gate.object-field-rule-writes.test.ts`),
* and since its pass 3 the per-option `visibleWhen`
* (`runtime-gate.object-option-visibility-writes.test.ts`). The one other
* object-borne pass the build runs — the object's own action predicates — is
* FENCED off this door by name, and the fence is pinned below with the build
* still flagging the same body, so a later widening moves that line
* consciously rather than by drift.
*
* The protocol-level half — the same verdict through the real `saveMetaItem`
* and `publishMetaItem`, and the door/build equality of the finding — is
Expand Down Expand Up @@ -114,10 +116,10 @@ describe('#22019 — the object door dispatches the build\'s expression rule', (

describe('#22019 — the fence: every other object-borne expression pass stays off this door', () => {
/**
* One body carrying a fault in each FENCED pass — #22032's passes 3 and 4,
* one site each: an option's `visibleWhen`, an object action's `visible` —
* beside a fault in each LIFTED pass, the validation-rule pass (#22032 pass
* 1) and a field-rule slot (`requiredWhen`, #22032 pass 2), and a CLEAN
* One body carrying a fault in the FENCED pass — #22032's pass 4, one site:
* an object action's `visible` — beside a fault in each LIFTED pass, the
* validation-rule pass (#22032 pass 1), a field-rule slot (`requiredWhen`,
* #22032 pass 2) and an option's `visibleWhen` (#22032 pass 3), and a CLEAN
* formula. The build flags every fault; the object door flags the lifted
* passes' alone. Each fault is one the build refuses at `error`, so "the
* door is silent on a fenced site" cannot be read as "there was nothing to
Expand All @@ -142,14 +144,14 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
};
return body;
};
/** The fenced sites (passes 3–4) and the lifted ones (passes 1–2), by the build's `where`, in the build's order. */
/** The fenced site (pass 4) and the lifted ones (passes 1–3), by the build's `where`, in the build's order. */
const FENCED_SITES = [
"object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen",
"object 'fx_sqrt' · action 'fx_close' visible",
];
const LIFTED_SITES = [
"object 'fx_sqrt' · validation 'amount_root'",
"object 'fx_sqrt' · field 'name' requiredWhen",
"object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen",
];

it('the build (no `runtimeWriteType`) still flags each fenced site, and the lifted ones', () => {
Expand All @@ -163,11 +165,11 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
expect(wheres.some((w) => w === WHERE), 'the clean formula must not be flagged').toBe(false);
});

it('the object door flags none of the fenced sites — only the formula, validation-rule and field-rule-slot passes judge there', () => {
it('the object door flags none of the fenced sites — only the formula, validation-rule, field-rule-slot and option passes judge there', () => {
const result = gateObject(withFieldRule());

expect(result.rulesRun).toContain('validateStackExpressions');
// [#22032 passes 1–2] The lifted passes' findings, and nothing else.
// [#22032 passes 1–3] The lifted passes' findings, and nothing else.
expect(expressionFindings(result.errors).map((f) => f.where), dump(result)).toEqual(LIFTED_SITES);
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
});
Expand Down
181 changes: 181 additions & 0 deletions packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #22032, pass 3 — the OBJECT write door runs the build's per-option
* `visibleWhen` pass.
*
* ## The state this closes
*
* `validateStackExpressions` is the build's expression rule. Its field walk
* judges every `fields[].options[].visibleWhen` as a `record`-scoped predicate,
* plus the #20078 refusal of a read through a reference field. After #22032's
* pass 2 the object door ran the rest of the field walk and fenced the option
* loop off by its own guard, so an option whose `visibleWhen` read a bare
* `amount` — refused by `os build` — published clean, and the server's option
* check then could not evaluate it and failed open on every write.
*
* ## The crossing
*
* No registry change: the entry already declares `object`. The option loop's
* guard is gone, so on an object write it runs at the build's own position in
* the field walk, and the door's finding IS the build's finding — rule,
* location, message and hint. The object's own action predicates (pass 4)
* stay fenced; that pin is in `runtime-gate.object-formula-writes.test.ts`.
*
* ## What still publishes
*
* An option's evaluator binds `current_user` (ADR-0068 D1), so the build
* accepts it there while it refuses it on the field-rule slots one level up.
* The door gives the two verdicts the build gives: the showcase's role gate
* (`'org_admin' in current_user.positions`) still publishes on an option, and
* the same text on the field's own `visibleWhen` is refused, at both doors.
*
* The protocol-level half — the same verdict through the real `saveMetaItem`,
* `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 3 block of
* `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';

/**
* The probe object; `visibleWhen` lands on the `gold` option of `tier`, and
* `fieldVisibleWhen` (when given) on the `name` field's own slot.
* `sharingModel` keeps `security-owd-unset` quiet, so a refusal is the rule's.
*/
const fxOption = (visibleWhen: unknown, fieldVisibleWhen?: unknown) => ({
name: 'fx_option',
label: 'Option Probe',
sharingModel: 'private',
fields: {
name: { type: 'text', label: 'Name', ...(fieldVisibleWhen === undefined ? {} : { visibleWhen: fieldVisibleWhen }) },
amount: { type: 'number', label: 'Amount' },
country: {
type: 'select',
label: 'Country',
options: [{ label: 'China', value: 'cn' }, { label: 'United States', value: 'us' }],
},
account: { type: 'lookup', label: 'Account', reference: 'fx_account' },
tier: {
type: 'select',
label: 'Tier',
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
},
},
});

const WHERE = "object 'fx_option' · field 'tier' option 'gold' visibleWhen";

/**
* One refused body per finding the pass gives. Each `subject` is the named
* subject of the build's finding (what the author typed), not its prose.
*/
const REFUSED = [
// The card's shape: a bare field reference.
{ body: 'amount > 1', subject: 'bare reference `amount`' },
{ body: 'sqrt(record.amount) > 1', subject: '`sqrt`' },
{ body: 'record.amont > 1', subject: 'unknown field `amont`' },
{ body: 'record.country ==', subject: 'invalid CEL predicate' },
// The traversal refusal, on both roots an option binds.
{ body: "record.account.name == 'x'", subject: 'reads `name` through `record.account`' },
{ body: "previous.account.name == 'x'", subject: 'reads `name` through `previous.account`' },
] as const;

/**
* Bodies the build accepts on an option, and so must the door: the cascade,
* the showcase's role gate, the grant check, and a reference compared as a
* value rather than read through.
*/
const ACCEPTED = [
"record.country == 'cn'",
"'org_admin' in current_user.positions",
"current_user.can('fx_option', 'edit') && 'org_admin' in current_user.positions",
'record.account != null',
] as const;

const gateObject = (item: unknown, objects: unknown[] = []) =>
runRuntimeAuthoringRules({ type: 'object', item, context: { objects } });

const expressionFindings = <T extends { rule: string }>(fs: readonly T[]): T[] =>
fs.filter((f) => f.rule === EXPRESSION_INVALID);

const buildFindings = (...objects: unknown[]) => {
const stack = { objects };
return expressionFindings(runAuthoringRules('build', { normalized: stack, parsed: stack }));
};

const dump = (r: unknown) => JSON.stringify(r, null, 2);

describe('#22032 pass 3 — the object door gives the build\'s option `visibleWhen` verdict', () => {
it('needs no registry change: `validateStackExpressions` is already on the object door', () => {
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
});

for (const { body, subject } of REFUSED) {
it(`⭐ LIT — an option's \`visibleWhen: ${body}\` is REFUSED, located at the option the author edits`, () => {
const result = gateObject(fxOption(body));

expect(result.rulesRun).toContain('validateStackExpressions');
const errs = expressionFindings(result.errors);
expect(errs, dump(result)).toHaveLength(1);
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE });
expect(errs[0]!.message).toContain(subject);
});
}

for (const body of ACCEPTED) {
it(`⭐ CONTROL — an option's \`visibleWhen: ${body}\` still publishes clean, and the build agrees`, () => {
const result = gateObject(fxOption(body));

expect(result.rulesRun).toContain('validateStackExpressions');
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
// Clean at both doors, not only this one.
expect(buildFindings(fxOption(body))).toEqual([]);
});
}

it('⭐ CONTRAST — `current_user` is accepted on the option and refused on the field\'s own slot, at both doors', () => {
const role = "'org_admin' in current_user.positions";
const body = fxOption(role, role);
const atBuild = buildFindings(body);
const atDoor = expressionFindings(gateObject(body).errors);

// The field-rule slot's root verdict, and nothing at the option.
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual(["object 'fx_option' · field 'name' visibleWhen"]);
expect(atBuild[0]!.message).toContain('reads `current_user`');
expect(atDoor, dump(atDoor)).toEqual(atBuild);
});

it('⭐ PARITY — for each refused body the door findings ARE the build findings', () => {
for (const { body } of REFUSED) {
const atBuild = buildFindings(fxOption(body));
const atDoor = expressionFindings(gateObject(fxOption(body)).errors);

// Non-vacuous: the build refuses each of them.
expect(atBuild.length, body).toBeGreaterThan(0);
expect(atDoor, body).toEqual(atBuild);
}
});

it('a stored sibling\'s broken options are not this write\'s to answer for (the differential)', () => {
const sibling = {
...fxOption(REFUSED[0].body),
name: 'fx_sibling',
fields: {
...fxOption(REFUSED[0].body).fields,
grade: {
type: 'select',
label: 'Grade',
options: REFUSED.map(({ body }, i) => ({ label: `G${i}`, value: `g${i}`, visibleWhen: body })),
},
},
};
// Non-vacuous: the sibling is refused at the build.
expect(buildFindings(sibling).length).toBeGreaterThan(0);

const result = gateObject(fxOption(ACCEPTED[0]), [sibling]);

expect(expressionFindings(result.errors), dump(result)).toEqual([]);
});
});
Loading
Loading