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/22032-object-save-door-field-rule-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/lint": minor
"@objectstack/metadata-protocol": minor
---

fix(lint)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032)

Clause-②: no (narrowing)

`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` 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 runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint.

**BREAKING — what moves for consumers.**

- An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. 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, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories.
- A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again.

**Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand 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, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.

**Unchanged.**

- 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. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row.
- `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before.
- Option `visibleWhen` and the object's own action predicates 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: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships.
- 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-rule 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 field-rule 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). -->
19 changes: 17 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 pass alone
* (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
* field-formula pass and (#22032) its validation-rule and field-rule-slot
* 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 @@ -613,6 +613,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// the pass, and 0 door errors and 0 advisories at the door's own snapshot
// shape, against 2 refusals at each for the card's two bodies in the same
// harness.
//
// [#22032, pass 2] The field-rule-slot pass joins the object door: every
// field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` /
// `visibleWhen`, with the `parent` gate, the `requiredWhen` null guard and
// the reference-traversal refusal — the same sentence of `formulas.mdx`,
// and the gap the card measured (a bare `requiredWhen: 'amount > 1'` saved
// with a 200). The per-option `visibleWhen` stays fenced. No entry-level
// change. MEASURED first, at both the raw and the parsed shape: every
// field-rule slot the repository ships — 9 slots on 8 fields of 3 objects
// (examples: app-showcase 8 slots on 2 objects, three of them
// `parent`-scoped; platform: plugin-security 1 on `sys_permission_set`),
// 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 the
// card's body in the same harness.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'action', 'hook', 'object'],
run: (stack, ctx) =>
Expand Down
174 changes: 174 additions & 0 deletions packages/lint/src/runtime-gate.object-field-rule-writes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #22032, pass 2 — the OBJECT write door runs the build's field-rule-slot
* pass.
*
* ## The state this closes
*
* `validateStackExpressions` is the build's expression rule. Its field walk
* judges every field's `requiredWhen` / `readonlyWhen` / `conditionalRequired`
* / `visibleWhen` as a `record`-scoped predicate, with the root verdict, the
* `parent` gate (a slot reading `parent` on an object that does not declare
* exactly one `master_detail`), the #4811 null-guard gate over `requiredWhen`,
* and the #20078 refusal of a `requiredWhen` / `readonlyWhen` read through a
* reference field. #22019 put that rule on the object door for its
* field-formula pass alone and fenced the rest off by name, so a bare
* `requiredWhen: 'amount > 1'` — refused by `os build` — published clean, and
* the write path then refused every write whose requirement it could not
* evaluate (ADR-0137 D2).
*
* ## The crossing
*
* No registry change: the entry already declares `object`. The fence in
* `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`.
*
* The protocol-level half — the same verdict through the real `saveMetaItem`,
* `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 2 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; `slots` lands on its `name` field. `sharingModel` keeps
* `security-owd-unset` quiet, so a refusal is the rule's.
*/
const fxField = (slots: Record<string, unknown>) => ({
name: 'fx_field',
label: 'Field Rule Probe',
sharingModel: 'private',
fields: {
name: { type: 'text', label: 'Name', ...slots },
amount: { type: 'number', label: 'Amount' },
status: {
type: 'select',
label: 'Status',
options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }],
},
account: { type: 'lookup', label: 'Account', reference: 'fx_account' },
},
});

/**
* One refused body per slot and per gate of the pass. Each `subject` is the
* named subject of the build's finding (what the author typed), not its prose.
*/
const REFUSED = [
// The card's body: a bare field reference.
{ slot: 'requiredWhen', slots: { requiredWhen: 'amount > 1' }, subject: 'bare reference `amount`' },
{ slot: 'readonlyWhen', slots: { readonlyWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' },
{ slot: 'visibleWhen', slots: { visibleWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' },
{ slot: 'conditionalRequired', slots: { conditionalRequired: 'amount > 1' }, subject: 'bare reference `amount`' },
// The root verdict: a root a field-level rule never binds.
{ slot: 'requiredWhen', slots: { requiredWhen: 'current_user.id != null' }, subject: 'reads `current_user`' },
// The `parent` gate: `fx_field` declares no `master_detail`.
{ slot: 'readonlyWhen', slots: { readonlyWhen: "parent.status == 'paid'" }, subject: 'reads `parent`' },
// The null-guard gate: `amount` is nullable, and the binding is total.
{ slot: 'requiredWhen', slots: { requiredWhen: 'record.amount > 100' }, subject: '`record.amount`' },
// The traversal refusal: a field-level predicate never reads the related record.
{ slot: 'requiredWhen', slots: { requiredWhen: "record.account.name == 'x'" }, subject: 'through `record.account`' },
] as const;

/** Valid predicates on all four declared slots of the field walk. */
const VALID = {
requiredWhen: 'record.amount != null && record.amount > 100',
readonlyWhen: "record.status == 'closed'",
visibleWhen: "record.status == 'open'",
};

/** A detail of `fx_field`: exactly one `master_detail`, so `parent` binds. */
const fxLine = () => ({
name: 'fx_line',
label: 'Line Probe',
sharingModel: 'private',
fields: {
header: { type: 'master_detail', label: 'Header', reference: 'fx_field' },
qty: {
type: 'number',
label: 'Quantity',
readonlyWhen: "parent.status == 'closed'",
requiredWhen: "parent.status == 'open'",
},
},
});

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 2 — the object door gives the build\'s field-rule-slot verdict', () => {
it('needs no registry change: `validateStackExpressions` is already on the object door', () => {
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
});

for (const { slot, slots, subject } of REFUSED) {
it(`⭐ LIT — \`${slot}: ${Object.values(slots)[0]}\` is REFUSED, located at the slot the author edits`, () => {
const result = gateObject(fxField(slots));

expect(result.rulesRun).toContain('validateStackExpressions');
const errs = expressionFindings(result.errors);
const where = `object 'fx_field' · field 'name' ${slot}`;
expect(errs, dump(result)).toHaveLength(1);
expect(errs[0]).toMatchObject({ severity: 'error', where, path: where });
expect(errs[0]!.message).toContain(subject);
});
}

it('⭐ CONTROL — valid predicates on every slot publish clean, and so does a `parent`-scoped detail', () => {
const result = gateObject(fxField(VALID));

expect(result.rulesRun).toContain('validateStackExpressions');
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
// The detail reads `parent` with its one master stored beside it.
const line = gateObject(fxLine(), [fxField(VALID)]);
expect(expressionFindings(line.errors), dump(line)).toEqual([]);
expect(expressionFindings(line.advisories), dump(line)).toEqual([]);
// And the build agrees: the control is clean at both doors, not only this one.
expect(buildFindings(fxField(VALID), fxLine())).toEqual([]);
});

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

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

it('a stored sibling\'s broken field rules are not this write\'s to answer for (the differential)', () => {
const sibling = {
...fxField({}),
name: 'fx_sibling',
fields: {
...fxField({}).fields,
...Object.fromEntries(REFUSED.map(({ slots }, i) => [`f${i}`, { type: 'text', label: `F${i}`, ...slots }])),
},
};
// Non-vacuous: the sibling is refused at the build.
expect(buildFindings(sibling).length).toBeGreaterThan(0);

const result = gateObject(fxField(VALID), [sibling]);

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