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
31 changes: 31 additions & 0 deletions .changeset/22157-option-visible-when-parent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/lint": minor
"@objectstack/metadata-protocol": minor
---

fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157)

Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind)

A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted.

The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint.

**BREAKING — what moves for consumers.**

- `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way.
- An object write in publish mode that carries such an option answered 200. 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`).

**Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.

**Unchanged.**

- The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change.
- The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before.
- A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message.
- 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.
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
- Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it.
- 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 `os build` and at the object save door of a select option's visibleWhen predicate that reads a root the server's option check does not bind: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose option predicate is refused keeps loading until it is next saved, and the repair is the author's rewrite of the predicate against a bound root, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,69 @@ describe('#22032 pass 3 — the object door gives the build\'s option `visibleWh
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
});
});

/**
* #22157 — the option's root verdict, at the object door.
*
* The server's option check (`evaluateOptionVisibility`) binds `record`,
* `previous` and the acting user. An option `visibleWhen` reading `parent` (on
* a detail with exactly one `master_detail`, where the field-rule slots DO
* bind it) published clean through this door and then faulted open on every
* write that picked the option. The verdict lives in the build's option pass,
* which this door runs since #22032's pass 3, so the door's finding is the
* build's. The protocol-level half, through the real `saveMetaItem`, is the
* #22157 block of `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
*/
describe('#22157 — the object door refuses an option `visibleWhen` reading `parent`, as the build does', () => {
const header = {
name: 'fx_header',
label: 'Header',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
};
const line = (visibleWhen: unknown) => ({
name: 'fx_line',
label: 'Line',
sharingModel: 'private',
fields: {
hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' },
x: { type: 'text', label: 'X' },
tier: {
type: 'select',
label: 'Tier',
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
},
},
});
const LINE_WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
const PARENT = "parent.status == 'closed'";

it('⭐ LIT — the measured body is REFUSED at the door, located at the option, naming the root', () => {
const result = gateObject(line(PARENT), [header]);

expect(result.rulesRun).toContain('validateStackExpressions');
const errs = expressionFindings(result.errors);
expect(errs, dump(result)).toHaveLength(1);
expect(errs[0]).toMatchObject({ severity: 'error', where: LINE_WHERE, path: LINE_WHERE });
expect(errs[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`");
});

it('⭐ PARITY — the door finding IS the build finding', () => {
const atBuild = buildFindings(header, line(PARENT));
const atDoor = expressionFindings(gateObject(line(PARENT), [header]).errors);

// Non-vacuous: the build refuses it.
expect(atBuild, dump(atBuild)).toHaveLength(1);
expect(atDoor, dump(atDoor)).toEqual(atBuild);
});

for (const body of ["record.x == 'a'", "'org_admin' in current_user.positions"]) {
it(`⭐ CONTROL — \`${body}\` on the same option publishes clean, and the build agrees`, () => {
const result = gateObject(line(body), [header]);

expect(expressionFindings(result.errors), dump(result)).toEqual([]);
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
expect(buildFindings(header, line(body))).toEqual([]);
});
}
});
83 changes: 83 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2005,6 +2005,89 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
});

/**
* ── The option's root verdict (#22157) ──────────────────────────────────
*
* The server's option check (`evaluateOptionVisibility`) binds `record`,
* `previous` and the acting user, and nothing else. A predicate reading any
* other root faults on every write that picks the option and is admitted
* unchecked, so the build refuses it. The measured body is `parent` on a
* detail with exactly one `master_detail`: the object shape on which the
* field-rule slots one level up DO bind `parent`, so it is the shape an
* author copies from.
*/
describe('a per-option `visibleWhen` root the option check does not bind is refused (#22157)', () => {
const detail = (visibleWhen: unknown, readonlyWhen?: unknown) => ({
objects: [
{ name: 'fx_header', fields: { status: { type: 'text' } } },
{
name: 'fx_line',
fields: {
hdr: { type: 'master_detail', reference: 'fx_header' },
x: { type: 'text', ...(readonlyWhen === undefined ? {} : { readonlyWhen }) },
parent_code: { type: 'text' },
tier: {
type: 'select',
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
},
},
},
],
});
const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
const PARENT = "parent.status == 'closed'";
/** What `evaluateOptionVisibility` binds, read off its call (`rule-validator.ts`). */
const BOUND = ['record', 'previous', 'current_user', 'user', 'ctx', 'os'];

it('⭐ refuses `parent` at error, located at the option, naming the option, the field and the root', () => {
const issues = validateStackExpressions(detail(PARENT));
expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1);
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT });
expect(issues[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`");
});

it('⭐ CONTRAST — the same `parent` read on the field\'s own `readonlyWhen` passes: that slot binds it', () => {
expect(validateStackExpressions(detail("record.x == 'a'", PARENT))).toEqual([]);
});

it('⭐ CONTROL — `record`, `previous` and the acting user under every ADR-0068 spelling pass', () => {
for (const body of [
"record.x == 'a'",
"previous.x == 'a'",
"'org_admin' in current_user.positions",
"'org_admin' in user.positions",
"ctx.user.id != ''",
"os.user.id != ''",
"current_user.can('fx_line', 'edit')",
// A `record` member merely spelled like the refused root.
"record.parent_code == 'a'",
]) {
expect(validateStackExpressions(detail(body)), body).toEqual([]);
}
});

/**
* An ALLOWLIST read against the real `SCOPE_ROOTS`, never a copy of it:
* every platform-wide root outside what the option check binds is
* refused, one finding each, so a root added to the baseline later is
* covered the day it lands.
*/
it('refuses every `SCOPE_ROOTS` member the option check does not bind, one finding each', () => {
const unbound = (SCOPE_ROOTS as readonly string[]).filter((r) => !BOUND.includes(r));
expect(unbound).toContain('parent');
for (const root of unbound) {
const issues = validateStackExpressions(detail(`${root}.k == 'a'`));
expect(issues, root).toHaveLength(1);
expect(issues[0]!.where, root).toBe(WHERE);
expect(issues[0]!.message, root).toContain(`reads \`${root}\``);
}
});

it('gives one finding when the predicate reads two unbound roots', () => {
expect(validateStackExpressions(detail(`${PARENT} && input.k == 1`))).toHaveLength(1);
});
});

it('flags a bare-field sharing-rule condition', () => {
const issues = validateStackExpressions({
objects: [{ name: 'crm_account', fields: { region: { type: 'text' } } }],
Expand Down
Loading
Loading